From a12402d4de3b7a0d423c83a25bb833e1fa9c85c8 Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Mon, 24 Aug 2026 13:50:35 -0500 Subject: [PATCH 1/3] perf: use SSE2 for CPC_TAKE_TILL_ONE_OF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This proves to be ~1.30 faster compared to current master ``` ./bench/bench_regression.sh master build/csv_simd_demo Summary build/csv_simd_demo < bench/data/customers-1000000.csv ran 1.31 ± 0.06 times faster than /tmp/nix-shell-208285-0/tmp.FxzsLSSdS3/ref/build/csv_simd_demo ``` --- cparsec.h | 76 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/cparsec.h b/cparsec.h index a59ef01..09ff602 100644 --- a/cparsec.h +++ b/cparsec.h @@ -8,6 +8,10 @@ # include #endif +#if defined(__SSE2__) +# include +#endif + // All the functions have to be static inline to ensure good performance (some experiments indicate // almost x2 perf drop if they're not inline). Right now the `cpc_arena_*` functions don't need to // be inline but since they're quite small the savings don't seem worth to separate the definition @@ -16,6 +20,29 @@ # define CPCAPI static inline #endif +// check for availability of __builtin_ctz +#if defined(__has_builtin) +# if __has_builtin(__builtin_ctz) +# define CPC_HAS_BUILTIN_CTZ +# endif +#elif defined(__GNUC__) || defined(__clang__) +# define CPC_HAS_BUILTIN_CTZ +#endif + +// it requires a nonzero value +CPCAPI unsigned ___cpc_count_trailing_zeros(unsigned value) { +#if defined(CPC_HAS_BUILTIN_CTZ) + return (unsigned)__builtin_ctz(value); +#else // provide a simple fallback if not available + unsigned count = 0; + while ((value & 1u) == 0) { + value >>= 1; + count++; + } + return count; +#endif +} + // The basic element of the parser are string slices to enable zero-copy parsing typedef struct { const char *ptr; @@ -477,20 +504,43 @@ CPCAPI ___CPC_ANY(CPC_ANY_) cpc_slice_sub(input, i, input.len - i)); \ } -#if defined(CPC_USE_STRING_H) -// combination of CPC_TAKE_TILL + CPC_ONE_OF that is SIMD-friendly thanks -// to memchr -# define CPC_TAKE_TILL_ONE_OF(name, stops) \ - CPC_DEFINE_PARSER(name) { \ - size_t end = input.len; \ - for (const char *s = (stops); *s; ++s) { \ - const char *p = memchr(input.ptr, *s, end); \ - if (p) end = (size_t)(p - input.ptr); \ - } \ - return cpc_res_ok(cpc_val_slice(cpc_slice_sub(input, 0, end)), \ - cpc_slice_sub(input, end, input.len - end)); \ - } +// Find the first byte in input that occurs in the stops string +CPCAPI size_t ___cpc_find_first_of(const char *input, size_t len, const char *stops, + size_t stop_len) { + size_t i = 0; + +#if defined(__SSE2__) + const size_t simd_width = 16; + + // Scan complete SIMD blocks, the unaligned load works for any input address + for (; i + simd_width <= len; i += simd_width) { + const __m128i bytes = _mm_loadu_si128((const __m128i *)(input + i)); + __m128i match = _mm_setzero_si128(); + for (size_t j = 0; j < stop_len; ++j) + match = _mm_or_si128(match, _mm_cmpeq_epi8(bytes, _mm_set1_epi8(stops[j]))); + const unsigned mask = (unsigned)_mm_movemask_epi8(match); + // ctz identifies the first match index; it is undefined for zero. + if (mask != 0) return i + (size_t)___cpc_count_trailing_zeros(mask); + } +#endif + + // SIMD scans only complete blocks; scan the remaining bytes here. + // This also provides the entire implementation on targets without SSE2. + for (; i < len; ++i) + for (size_t j = 0; j < stop_len; ++j) + if (input[i] == stops[j]) return i; + return len; +} +// combination of CPC_TAKE_TILL + CPC_ONE_OF +#define CPC_TAKE_TILL_ONE_OF(name, stops) \ + CPC_DEFINE_PARSER(name) { \ + size_t end = ___cpc_find_first_of(input.ptr, input.len, (stops), sizeof(stops) - 1); \ + return cpc_res_ok(cpc_val_slice(cpc_slice_sub(input, 0, end)), \ + cpc_slice_sub(input, end, input.len - end)); \ + } + +#if defined(CPC_USE_STRING_H) // Parses a quoted string and returns a slice. Using the escape char to treat escaped content. # define CPC_TAKE_QUOTED(name, quote, escape) \ CPC_DEFINE_PARSER(name) { \ From 53716e38c81035619a6b5ccdc47c33c625f6913f Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Sun, 11 Oct 2026 22:26:53 -0500 Subject: [PATCH 2/3] refactor: replace memchr in CPC_TAKE_QUOTED This doesn't seem to increase the performance, so it's just a refactor. --- cparsec.h | 98 +++++++++++++++++++++++++++---------------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/cparsec.h b/cparsec.h index 09ff602..d008e1e 100644 --- a/cparsec.h +++ b/cparsec.h @@ -540,61 +540,61 @@ CPCAPI size_t ___cpc_find_first_of(const char *input, size_t len, const char *st cpc_slice_sub(input, end, input.len - end)); \ } -#if defined(CPC_USE_STRING_H) // Parses a quoted string and returns a slice. Using the escape char to treat escaped content. -# define CPC_TAKE_QUOTED(name, quote, escape) \ - CPC_DEFINE_PARSER(name) { \ - /* rejects anything that is too short (quoted would need at least 3 chars) or does not start \ - * with the quote*/ \ - if (input.len < 2 || input.ptr[0] != (quote)) \ - return cpc_res_err(input, "missing quote", err); \ - /* start after the opening quote */ \ - if ((escape) == (quote)) { \ - size_t span = 1; \ - while (span < input.len) { \ - /* Jump to the next quote candidate instead of scanning char by char */ \ - const char *p = memchr(input.ptr + span, (quote), input.len - span); \ - if (!p) break; \ - size_t idx = (size_t)(p - input.ptr); \ - if ((idx + 1) < input.len && input.ptr[idx + 1] == (quote)) { \ - /* A doubled quote is escaped content inside the quoted span */ \ - span = idx + 2; \ - continue; \ - } \ - /* A lone quote closes the span, including the opening quote at index 0 */ \ - span = idx + 1; \ - return cpc_res_ok(cpc_val_slice(cpc_slice_sub(input, 0, span)), \ - cpc_slice_sub(input, span, input.len - span)); \ +#define CPC_TAKE_QUOTED(name, quote, escape) \ + CPC_DEFINE_PARSER(name) { \ + /* rejects anything that is too short (quoted would need at least 3 chars) or does not start \ + * with the quote*/ \ + if (input.len < 2 || input.ptr[0] != (quote)) return cpc_res_err(input, "missing quote", err); \ + const char cpc_quote_ = (quote); \ + /* start after the opening quote */ \ + if ((escape) == (quote)) { \ + size_t span = 1; \ + while (span < input.len) { \ + /* Jump to the next quote candidate instead of scanning char by char */ \ + const size_t quote_offset = \ + ___cpc_find_first_of(input.ptr + span, input.len - span, &cpc_quote_, 1); \ + if (quote_offset == input.len - span) break; \ + size_t idx = span + quote_offset; \ + if ((idx + 1) < input.len && input.ptr[idx + 1] == (quote)) { \ + /* A doubled quote is escaped content inside the quoted span */ \ + span = idx + 2; \ + continue; \ } \ - } else { \ - size_t span = 1; \ - while (span < input.len) { \ - /* Jump to the next quote candidate instead of scanning char by char */ \ - const char *p = memchr(input.ptr + span, (quote), input.len - span); \ - if (!p) break; \ - size_t idx = (size_t)(p - input.ptr); \ - size_t escapes = 0; \ - /* Count consecutive escape chars immediately before this quote candidate. */ \ - while (idx > escapes && input.ptr[idx - 1 - escapes] == (escape)) \ - escapes++; \ + /* A lone quote closes the span, including the opening quote at index 0 */ \ + span = idx + 1; \ + return cpc_res_ok(cpc_val_slice(cpc_slice_sub(input, 0, span)), \ + cpc_slice_sub(input, span, input.len - span)); \ + } \ + } else { \ + size_t span = 1; \ + while (span < input.len) { \ + /* Jump to the next quote candidate instead of scanning char by char */ \ + const size_t quote_offset = \ + ___cpc_find_first_of(input.ptr + span, input.len - span, &cpc_quote_, 1); \ + if (quote_offset == input.len - span) break; \ + size_t idx = span + quote_offset; \ + size_t escapes = 0; \ + /* Count consecutive escape chars immediately before this quote candidate. */ \ + while (idx > escapes && input.ptr[idx - 1 - escapes] == (escape)) \ + escapes++; \ \ - /* A quote preceded by an odd number of escape chars is escaped content. */ \ - /* An even number like `//"` would mean that the backlash itself is escaped plus */ \ - /* `"` would be a lone quote. */ \ - if ((escapes & 1u) != 0) { \ - span = idx + 1; \ - continue; \ - } \ - /* A lone quote closes the span, including the opening quote at index 0 */ \ + /* A quote preceded by an odd number of escape chars is escaped content. */ \ + /* An even number like `//"` would mean that the backlash itself is escaped plus */ \ + /* `"` would be a lone quote. */ \ + if ((escapes & 1u) != 0) { \ span = idx + 1; \ - return cpc_res_ok(cpc_val_slice(cpc_slice_sub(input, 0, span)), \ - cpc_slice_sub(input, span, input.len - span)); \ + continue; \ } \ + /* A lone quote closes the span, including the opening quote at index 0 */ \ + span = idx + 1; \ + return cpc_res_ok(cpc_val_slice(cpc_slice_sub(input, 0, span)), \ + cpc_slice_sub(input, span, input.len - span)); \ } \ - /* err if no closing quote is found */ \ - return cpc_res_err(input, "missing quote", err); \ - } -#endif + } \ + /* err if no closing quote is found */ \ + return cpc_res_err(input, "missing quote", err); \ + } #define ___CPC_EOF(name) \ CPC_DEFINE_PARSER(name) { \ From da8fd435e2fe779f931b4c0a13c0268c53947d3c Mon Sep 17 00:00:00 2001 From: steve-chavez Date: Sun, 11 Oct 2026 23:06:50 -0500 Subject: [PATCH 3/3] refactor: don't use strlen on cpc_slice_from_cstr This allows us to remove usage of string.h. --- README.md | 5 +---- cparsec.h | 17 +++++------------ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 54c64b8..ef14b91 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ A CSV parser looks like this: #include #include -#define CPC_USE_STRING_H #include "cparsec.h" CPC_TAKE_QUOTED(quotedField, '"', '"') @@ -90,7 +89,7 @@ All the macros basically generate inlinable functions that take other inlinable ### SIMD combinators -These parsers are specialized versions that make use of `memchr` to be SIMD enabled [^1]. They require `` support, so you have to `#define CPC_USE_STRING_H` to use them. +These parsers are specialized versions that use SSE2 instructions. | Macro | Description | | --- | --- | @@ -144,5 +143,3 @@ All the functions are inspired by Haskell Parsec or AttoParsec. Here's a table w | `CPC_APPLY` | `<*>` | | `CPC_MAP` | `<$>` | | `CPC_PURE` | `pure` | - -[^1]: `memchr` is not inherently SIMD. This depends on the implementation provided by the target libc. For example on `glibc` and `BSD libc` it is SIMD enabled but not on `musl`. diff --git a/cparsec.h b/cparsec.h index d008e1e..d4cd2c3 100644 --- a/cparsec.h +++ b/cparsec.h @@ -4,10 +4,6 @@ #include #include -#if defined(CPC_USE_STRING_H) -# include -#endif - #if defined(__SSE2__) # include #endif @@ -57,18 +53,15 @@ typedef struct { // obtain a CpcSlice from a c string CPCAPI CpcSlice cpc_slice_from_cstr(const char *s) { -#if defined(CPC_USE_STRING_H) - return (CpcSlice){.ptr = s, .len = strlen(s)}; -// The non string.h implementation of this function is very basic and scans byte by byte instead of -// word by word. We can optimize this as discussed on -// https://github.com/steve-chavez/CParseC/issues/3, but for now we leave that to `strlen` for -// environments that use string.h. glibc `strlen` has various optimizations including SIMD. -#else + // TODO This is a very basic implementation and scans byte by byte instead of + // word by word. We can optimize this as discussed on + // https://github.com/steve-chavez/CParseC/issues/3 + // Note: `strlen` has various optimizations but it would require including which + // doesn't seem worth it anymore now that we do native SIMD size_t n = 0; while (s[n] != '\0') n++; return (CpcSlice){.ptr = s, .len = n}; -#endif } // a sub region of the slice