Three months on a performance-sensitive systems project — arena allocators, the C/Zend Engine memory boundary, and using SIMD to parse 32 bytes at a time
Introduction
T-Digital is a Moroccan technology company specialized in digital transformation, and this internship was my introduction to what that looks like at the systems level rather than the product level. I spent it on a performance-sensitive systems project: contributing to csv-toolkit, an open-source CSV parser written in C with a PHP extension on top, built to replace the notoriously slow CSV handling PHP developers usually reach for by default. My job was to find where the library was leaving performance on the table and fix it.
Two pieces of that work stood out. The first was a redundant copy sitting at the boundary between the C library and the PHP extension. The second — the one I want to spend most of this post on — was adding manual SIMD to the C parser itself, which turned into the most interesting piece of low-level work I did all internship. There's also a third project I contributed to, a small SQL-like query language for CSV data, which deserves its own post.

Background
The C core's speed comes from an arena allocator: instead of malloc()/free() per string, it grabs one large block of memory up front and allocates for whatever object by bumping a pointer forward through it. Freeing is a single operation — the whole arena is discarded at once. PHP's memory model doesn't allow that: every string handed to PHP userland code needs its own independently-freeable lifetime, since the caller might hold a reference to one field long after the rest of the row has gone out of scope. That mismatch is why the PHP extension originally copied every field out of the arena and onto PHP's heap on every read, and why the writer allocated and freed scratch buffers on every write call. Fixing both — parsing straight onto PHP's heap on the read side, and reusing persistent buffers on the write side — cut the extension's read overhead compared to the raw C library from roughly 1.6x slower down to about 1.1x slower. I won't go deeper into that fix here since the more interesting work was on the C side.
SIMD: teaching the parser to scan 32 bytes at a time
Here's the shape of the problem. The parser's inner loop does one thing over and over: scan forward through a line looking for a delimiter, a quote character, or a newline. Done the ordinary way, that's a scalar operation — the CPU checks one byte, then the next, then the next, one comparison per cycle.
SIMD (Single Instruction, Multiple Data) instructions do the same comparison across a whole chunk of bytes per cycle — 16 bytes with SSE2 / NEON, 32 with AVX2 — and return a bitmask showing which positions in that chunk matched. For parsing csvs where we continually check for delimiters or quotes, this is close to a perfect fit: instead of 32 individual byte comparisons, you do one vector comparison and then only drop into careful byte-by-byte handling once you're near an actual delimiter or quote.

Using the -O3 flag will auto-SIMD (auto-vectorize) simple loops for you, but the parser's inner loop is a state machine — tracking whether it's inside a quoted field, whether the previous character was an escaped quote, whether there's a literal newline embedded inside quotes — and compilers back off from auto-vectorizing anything with that much branching. So this needed hand-written SIMD, with separate code paths for SSE2 (safe baseline on any x86_64 chip), AVX2 (better and available on effectively any x86_64 chip from 2015 onward), and NEON on ARM, chosen at compile time:
#if defined(__AVX2__)
return csv_parse_line_inplace_avx2(line, arena, config, line_number);
#elif defined(__SSE2__)
return csv_parse_line_inplace_sse2(line, arena, config, line_number);
#elif defined(__ARM_NEON) || defined(__ARM_NEON__)
return csv_parse_line_inplace_neon(line, arena, config, line_number);
#else
return csv_parse_line_inplace_scalar(line, arena, config, line_number);
#endif
This is compile-time dispatch — the compiler picks the implementation while building the binary, based on which instruction-set flags you compiled with (-mavx2, or -march=native to grab whatever the host CPU supports), rather than checking the CPU at runtime. The AVX2 path itself works by loading 32 bytes into a wide register, broadcasting the delimiter and quote characters across a comparison register, and using a movemask-style instruction to collapse the per-byte comparison results into a single integer bitmask — from there, a bit-scan instruction finds the position of the first match directly, without inspecting each byte individually.
To see whether this actually mattered in practice, I benchmarked it against two deliberately different datasets, both compiled with -O3 so the comparison isolates the manual SIMD gain rather than general compiler optimization:
- Long-field data — 3 columns, with a long third field. This is the case SIMD should love: a lot of uninterrupted scanning per field before hitting a delimiter.
- Short-field data — 30 columns, all fields under 16 characters. This is close to a worst case for SIMD: most fields are shorter than a single vector width, so the wide comparison barely gets used before falling back to scalar handling near the delimiter.

On the long-field dataset, AVX2 gave a consistent 1.79x–1.87x speedup over scalar parsing, holding steady from 100 rows up to 10,000. On the short-field dataset, the gain shrank fast — 1.20x at 100 rows, down to 1.14x at 1,000, and by 10,000 rows the SIMD version was, if anything, marginally slower than plain scalar code. That's not a bug, it's the benchmark being honest: once most fields are shorter than the vector width, the setup cost of a wide comparison stops paying for itself, and the code spends most of its time in the same byte-by-byte fallback the scalar version was already running.
That's the actual takeaway I walked away with, more than any single speedup number: SIMD helps here in direct proportion to how much uninterrupted scanning the parser gets to do per field, not as a blanket "SIMD makes parsing faster" rule. Knowing which CSVs benefit — wide files with short fields versus narrow files with long ones — turned out to be more useful than the headline 1.8x number by itself.
What's next
There's a third piece of this internship I haven't covered here: a query language, syntactically close to SQL, that sits on top of the parser so you can filter and get data insights from CSV data declaratively. That's a big enough topic on its own — I'll write it up next.
Sources
- CsvToolkit Website — The official csvtoolkit website, includes documentation
- FastCSV-C — the C core, including the SIMD optimization branch
- FastCSV-ext — the PHP extension
