Reader performance: where the time goes, and what bounds it

Measured 2026-09-15/16 on a 13th Gen Intel Core i7-1365U (12 logical / 10 physical cores, P+E), AVX2, no AVX-512, .NET 10. Corpus: tests/ExcelReader.Benchmarks/Data/65K_Records_Data.* — 65,536 rows, 14 columns, numeric and date heavy, with only ~5 KB of shared strings. The string-heavy section uses StringHeavyWorkbookGenerator's 65,536-row fixture instead.

This is not the machine the published tables come from. Those, in benchmarks.md, are a Ryzen 7 5700X desktop. A throttling mobile part with P and E cores is exactly the hardware ARCHITECTURE.md describes discarding an earlier parallel-CSV measurement over, so nothing here should be compared against a published number by absolute milliseconds — only ratios measured within one run are meaningful, and anything destined for benchmarks.md has to be re-run on the documented machine. The exceptions are "Second round" and "Third round" below, which were measured on the Ryzen.

The harness was a throwaway console app, not committed. Every table below comes from a single interleaved round-robin run, where each variant runs once per round so thermal drift hits all of them equally. This machine drifts a lot — the same sequential scan measured between 21 ms and 36 ms across runs — so read the ratios, not the absolute milliseconds. min across rounds is used throughout as the cleanest estimate of true cost.

The model

With PrefetchDecompression off, inflate and parse are serialized, so a read costs I + P. With it on they overlap, so a read costs max(I, P) plus handoff overhead. Measuring the inflate on its own gives I, and the two reads then solve for P. Predicted and measured agreed to within ~6% on XLSX, which is what makes the rest of this document trustworthy.

XLSX — parse-bound, barely

I — inflate xl/worksheets/sheet1.xml, 6.7 MB → 34.4 MB 34.4 ms
P — parse 40.6 ms
read, prefetched 44.9 ms
floor, max(I, P) 40.6 ms

Parse decomposed by adding one layer at a time (each without prefetch, so the times stay additive):

layer delta share of P
L1 scan + emit into accumulator 34.5 ms 85.0%
L2 + enumerate cells 0.5 ms 1.3%
L3 + read Cell.Type 0.2 ms 0.5%
L4 + take the zero-copy text span ~0 ms ~0%
L5 + consumer's TryParse/TryGetDateTime 5.8 ms 14.4%

Within L1, scanning is ~24.5 ms and emitting into the accumulator ~10.0 ms.

The whole Row/Cell ref-struct surface costs ~0.7 ms of 40.6 (1.7%). Enumeration, construction and span access are effectively free. There is nothing to win there, and no performance reason to revisit that design.

XLSB — inflate-bound, 4% ceiling

I — inflate xl/worksheets/sheet1.bin, 3.74 MB → 15.9 MB 18.6–18.9 ms
P — parse 17.0 ms
read, prefetched 19.4–19.6 ms

P/I = 0.90: the parser is already ~10% faster than the decompressor feeding it. An infinitely fast XLSB parser would move the read from 19.5 ms to 18.7 ms.

Do not optimize the XLSB parser. XlsbReader.Enumerator is a single forward pass over TLV records; there is no structural waste left in it, and none of the time is its.

XLS — no floor, but the format caps the workload

Uncompressed OLE/CFB, so there is no inflate stage at all.

read, stream 11.0 ms
read, in-memory 10.5 ms
of which scan + emit 8.0 ms
container overhead (stream vs memory) 0.4 ms (3–4%)

BIFF8 is the one format where parallel parsing would convert 1:1 — no inflate wall, and it carries a native row index (INDEX → DBCELL → row-block offsets) over seekable uncompressed data, so partitioning would need no boundary guessing. It is still not worth building: BIFF8 caps at 65,536 rows × 256 columns, this corpus is already at the row cap, and it reads in 11 ms. There is no such thing as a slow XLS.

The pattern across the ZIP formats

Read time tracks inflated payload size, not parser quality.

inflated payload read, prefetched
XLSX 34.4 MB ~45 ms
XLSB 15.9 MB ~19.5 ms

2.16x the payload, 2.3x the time. XLSB is not faster because its parser is better; it is faster because there are fewer bytes to inflate. For a caller who controls the producer, writing XLSB is a larger win than anything available inside the readers.

String-heavy XLSX — the shared-string prologue dominates

Different corpus, different balance. sharedStrings.xml is 2.79 MB → 7.5 MB inflated, 29% of the payload, and it is loaded to completion before the first row is enumerated.

stage without prefetch with prefetch
prologue — inflate + parse sharedStrings.xml 34.4 ms 19.8 ms
sheet stage 49.7 ms 28.5 ms
total 84.1 ms 48.4 ms

The prologue is 41% of the prefetched read (54% for XLSB on the same corpus). The two stages are strictly serial today and read two independent ZIP entries — two separate deflate streams, which can inflate concurrently.

Upper bound if they were fully overlapped: 48.4 − min(19.8, 28.5) = 28.5 ms, 1.69x. See "shared-string prefetch" below for why no cheap version reaches it.

Within the stages: the SST stage is inflate-bound (18.6 inflate vs 15.8 parse), so speeding up the shared-string parser gains nothing. The sheet stage is parse-bound with 8.9 ms above its inflate floor — the one place in this document where ordinary parser work still converts 1:1. That 8.9 ms has not been decomposed.

On the Ryzen the SST stage was not inflate-bound: its parse cost as much as its inflate. "Third round" below decomposes both stages there and removes most of the SST parse.

CSV — the parser is at the floor; the cost is in the value layer

CSV is the only format with neither an inflate floor nor a format size cap, so every millisecond is parse and every millisecond saved converts at any file size. That made it the best remaining candidate. It turned out the parser has nothing left to give.

Generated 50,000-row corpus (2.32 MB), reading three columns:

stage
framing + emit + span 2.32 ms
raw IndexOfAny byte sweep, no framing at all 2.24 ms

1.03x of a bare byte sweep. CsvControlScanner already does the right thing — one Vector256 pass producing a mask of delimiter/quote/CR/LF, kept stateful across refills through Reset/ Continue, so the vector setup amortizes over the whole buffer. (This is the same structural-scan idea that lost badly in XLSX. It wins here because CSV fields are longer and the scanner is stateful rather than restarted per short hop.)

That comparison used a weak baseline. A sweep that restarts IndexOfAny at every hit pays the vector setup 200,000 times, so 2.24 ms is not a floor — it is a fourth implementation of the same scan, and a bad one. The layered decomposition below re-measures against a real floor. The conclusion survives, for a different reason: framing is not 97% of a floor, it is 13% of the read.

So the cost is downstream of framing:

conversion per call
TryParse<int> 6.2 ns
GetString() 17.6 ns
TryGetDateTime on a text date see below

TryGetDateTime did not work on text dates at all

TryGetDateTime resolves the cell as an Excel serial number — it called TryGetDouble first. On an ISO date string that is a double.TryParse that walks 27 characters and fails, so the method returned false after ~69 ns of wasted work. Measured on the corpus: 0 of 50,000 date cells succeeded.

There was no other efficient path. TryParse<T> is constrained to IUtf8SpanParsable<T>, which DateTime does not implement, so the only working option was GetString() followed by DateTime.Parse — 163.7 ns per date, 26x the int parse. A date written by this library's own CsvWriter could not be read back by its own CsvReader; a test asserted that limitation rather than the round-trip its name promised.

FastDate (src/ExcelReader.Core/Reader/Internal/FastDate.cs) now parses ISO-8601 straight from UTF-8: yyyy-MM-dd, optionally T or a space, a time, and up to seven fractional digits. A trailing zone designator is rejected rather than guessed at.

before after
text date cells resolved 0 of 50,000 50,000 of 50,000
cost per date 163.7 ns (via GetString) 40.4 ns

4.05x, and the API stopped lying. Order matters: the text-date attempt runs first, guarded by _hasNumber, because reaching it through TryGetDouble costs a full failing double parse on every date cell — measured at 85.4 ns with the wrong order against 40.4 ns with the right one. The guard keeps the XLSX serial-date path untouched; its consumer-conversion layer measured 6.3 ms before and after, inside the run-to-run band.

Where the CSV read actually goes

A second harness split the read into layers that each add one stage to the same pass, so adjacent rows subtract. Two groups, interleaved separately because the typed variants allocate a model per row and their GC cost would otherwise land on their neighbours. Same 50,000-row corpus, same machine, one run.

Framing prototypes, in memory, no reader machinery:

layer
L0 Vector256 masking only, popcount the hits 0.102 ms
L1 + drain bits, reload the byte to classify it 0.384 ms
L2 + drain bits, separate masks per class, no reload 0.389 ms 0.99x
L3 + emit a 16-byte field descriptor 0.562 ms
L4 + emit a 32-byte CellDesc-shaped descriptor 0.666 ms
IndexOfAny restarted per hit (the old baseline) 1.748 ms

All four field-emitting layers were gated on an identical checksum.

The shipped reader, same corpus, stream source:

stage delta share
rows, no cell access 1.476 ms 30%
+ four cell spans 1.676 ms +0.200 4%
+ int column 2.174 ms +0.498 10%
+ date column 3.906 ms +1.732 35%
+ double column 4.942 ms +1.036 21%

The scan is 2% of the read and the date conversion is 35%. Masking 2.3 MB costs 0.102 ms; everything above L0 is per-field bookkeeping, not scanning. Cell materialization is 1 ns per cell. Framing at 0.666 ms against the reader's 1.476 ms rows-only figure leaves ~0.8 ms of enumerator overhead — MoveNext, _acc.Reset, refill and compaction bookkeeping — which is the largest undecomposed block left in CSV after the conversions.

Unexplained: spans-only is faster from a stream than from an already-resident buffer (1.676 vs 2.201 ms), but full conversion is faster from the buffer (4.942 vs 4.190 ms). Both directions reproduced across four runs. No explanation; recorded rather than guessed at.

The enumerator overhead: register pressure, not memory traffic — 1.16x on wide rows

Wide corpus (50,000 rows × 32 short text fields, 9.8 MB). The reader enumerating rows with no cell access ran at 2.25x a standalone framing prototype doing the same scan and writing the same descriptors: 2.8 ns per field above framing. That overhead is per field, not per row — fitting the narrow and wide corpora together gives ~5 ns fixed per row and ~2.6 ns per field.

The JIT disassembly of TryParseSimpleRecord showed, per field, a load and store of the scanner's _mask, a load and store of _col, two loads and a store of the accumulator's Count, a load and store of _lastCol, and _cells reloaded twice — all loop-carried through memory, where the prototype keeps them in registers. Three attempts, each measured:

attempt result
copy the scanner struct into a local for the loop slower, 2.25x → 2.52x
accumulator state and column in locals, committed once per record no change
drain the vector mask in a call-free leaf function, one call per record 1.16x on wide

The second attempt explains the first two. Its disassembly showed the locals spilled to the stack: the loop still contained calls on cold paths (GrowCells, ThrowColumnLimit, NextScalar, SkipByte), so every variable live across the loop needed a callee-saved register, and x64 Windows has eight. The store-forwarding chain did not go away; it moved from the heap to the stack. The prototype has no calls in its loop, so it gets the volatile registers.

CsvReader.Enumerator.DrainFields is that loop with no calls in it. It takes vector masks from CsvControlScanner.TryTakeMask, emits delimiter-terminated fields, and closes the record on LF or a CRLF inside one chunk. It hands back to the existing per-hit path on a quote, a bare CR, a CRLF split across chunks, a full descriptor array, the column limit, or a buffer tail shorter than a vector.

A first version drained only delimiters and returned to the per-hit path for every record end. It won 1.14x on wide rows and lost ~12% on the narrow benchmark: a 47-byte record spans one and a half chunks, so it paid two non-inlined calls per record and still took the slow path for the newline. Closing the record inside the leaf and loading the next chunk from inside it made the narrow case neutral.

In-process A/B, separate binaries for the old and new source, alternated three times, min of 12:

before after
wide, rows only 5.54–5.90 ms 4.38–4.70 ms (~1.28x)
wide, 32 cell spans (ExcelReaderWide shape) 7.97–8.47 ms 6.94–7.48 ms (~1.16x)
narrow, 4 cell spans 1.50–1.66 ms 1.53–1.65 ms (neutral)

BenchmarkDotNet, same machine, one run each: ExcelReaderWide 8.939 → 8.128 ms (the new run's standard deviation was 0.91 ms, so read that as direction only), ExcelReader 3.774 → 3.609 ms, ExcelReaderAsync 3.506 → 3.356 ms.

Correctness gate: 3,000 seeded random inputs weighted toward quotes, bare CR, CRLF and rows wider than the initial descriptor array, each read both from memory and through a stream that returns 1–96 bytes per read so refills land at arbitrary offsets. 5,496,494 rows, byte-identical digests between the old and new builds. The gate was checked by mutation: shifting the CRLF record end by one byte changed both the row count and the digest.

Against a One Billion Row Challenge workload

A scaled-down 1BRC: 10,000,000 rows of station;temperature, 413 stations, min/max/sum/count per station. Every variant shares one open-addressing aggregation table, so the differences are reading and parsing. All variants produced identical checksums, station counts and row counts. 12 threads on the i7-1365U (2 performance + 8 efficiency cores).

Re-measured 2026-09-22, after CsvStructuralScanner (de49a45) and the DrainFields work. Min of five rounds, three separate processes, all three within a few percent of each other:

per row MB/s linear extrapolation to 1B
hand-written parser, 1 thread 43.5 ns 420 ~44 s
hand-written parser, partitioned by newline 7.1 ns 2,600 ~7 s
ExcelReader rows, 1 thread 60.0 ns 305 ~60 s
ExcelReader rows, partitioned by the caller 11.2 ns 1,630 ~11 s
CsvParallel.AggregateAsync, dop 1 66.2 ns 275 ~66 s
CsvParallel.AggregateAsync, dop 12 11.4 ns 1,600 ~11 s

The corpus is a rebuild, not the original bytes: 183 MB at 19.2 bytes per row against the first run's 222 MB at 22, because the station names are synthetic and shorter. What makes the two tables comparable is the control: the hand-written parser measured 42.3 ns then and 43.5 ns now. With the baseline standing still, the reader's movement is the library's, not the corpus's.

2026-09-21 2026-09-22
ExcelReader rows, 1 thread 81.3 ns 60.0 ns 1.36x
ExcelReader rows, partitioned 18.7 ns 11.2 ns 1.67x
the library's own parallel path 241.7 ns (CsvParallel.ParseAsync<T>) 11.4 ns (CsvParallel.AggregateAsync) —

The hand-written baseline is deliberately naive (IndexOf for the separator, a digit loop, FNV over the key bytes) and is not a leaderboard entry; the winning entries add SWAR temperature parsing, branch-free key hashing and memory-mapped input, and run on server hardware. So these rows position the library against a plain specialized parser on the same machine, not against the leaderboard.

  • The general reader costs 1.38x a naive specialized parser, about 16.5 ns per row of generality on this shape (Row/Cell construction, the 32-byte CellDesc, generic TryParse<double>, double-to-fixed-point conversion). It was ~2x and ~39 ns before the structural scanner. The same 1.38x holds partitioned, 11.2 ns against 7.1.
  • Partitioning converts: 5.4x on 12 mixed threads for raw rows, 5.8x for the aggregation API, 6.1x for the hand-written parser.
  • CsvParallel.AggregateAsync is now at parity with hand-partitioning — 11.4 ns against 11.2 for a caller that splits the buffer itself. The earlier reading, that the library's own parallel path ran 13x slower than the caller-partitioned raw path, was measured against CsvParallel.ParseAsync<T>, which allocates a model object and a string per row. The aggregation overload does not, and closes the gap. Its cost over the plain single-threaded reader is 1.10x (66.2 ns against 60.0), which is the delegate plus partition bookkeeping.
  • So the missing public API for partitioned raw rows costs nothing on an aggregation workload. It would still matter for a consumer that must see each Row itself rather than fold it.

Cost of handing each Row to user code instead of an inline loop, same workload, one thread, interleaved, 5,000,000 rows, identical checksums:

per row
inline loop 62.7 ns 1.00x
delegate (ref TState state, Row row) 68.3 ns 1.09x
struct implementing a static-abstract processor interface, constrained generic 76.8 ns 1.23x

That measurement chose the engine behind CsvParallel.AggregateAsync: a CsvRowAction<TState>(ref TState state, Row row) delegate, one accumulator per partition, and a combine folded left to right in file order. It reuses the typed path's speculative partitioning. The public surface has two shapes over that one engine: an ICsvAccumulator<TSelf> type (Add(Row), Merge(TSelf), parameterless constructor), and a CsvAggregation<TState> object holding Seed, Accumulate and Combine. Both take a CsvParallelOptions carrying DegreeOfParallelism, Reader and HeaderRow. A partition whose guessed start turns out wrong is read again from its confirmed start with a fresh accumulator, and the first attempt's accumulator and any exception its callbacks threw are discarded. That is why the callback may only mutate the accumulator it is handed. Partitions are sized at a quarter of a worker's share, clamped to 1–64 MB, so accumulator creation and merging stay per-partition costs, not per-64-KB ones.

Same 1BRC sample, 12 threads, interleaved, identical checksums (the machine was warmer than for the first table, so compare within this table only):

per row
caller-partitioned raw rows, inline loop 25.7 ns, 25.0 ns
three loose delegates (the first version of this API) 31.5 ns, 28.5 ns
CsvParallel.ParseAsync<T> 209.5 ns

About 1.15x the hand-partitioned loop — the delegate plus partition bookkeeping — and 7x faster than the typed parallel path. Unlike the hand-partitioned loop, it is correct for quoted fields that span lines.

The two public shapes, measured after the surface was settled, same sample, interleaved, identical checksums:

12 threads 1 thread
caller-partitioned raw rows, inline loop 24.1 ns, 26.0 ns —
CsvParallel.AggregateAsync(data, CsvAggregation<T>) 26.9 ns, 29.0 ns 84.2 ns, 75.3 ns
CsvParallel.AggregateAsync<TAccumulator>(data) 34.4 ns, 37.5 ns 117.6 ns, 95.1 ns

The accumulator overload costs ~1.3x the aggregation overload — more than the 1.15–1.23x a direct interface call measured, because it goes through the same delegate engine and so pays the delegate call and the interface call on every record. That is the price of keeping one engine; a second engine specialized on the accumulator type would remove the delegate hop. The one-thread fallback also runs above the inline loop's ~62 ns, since it enumerates through MoveNextAsync.

A prototype (not in the library) put a typed record between Row and the accumulator: a ref struct Reading : ICsvRecord<Reading> with static abstract bool TryParse(Row, out TSelf) holding the station as a ReadOnlySpan<byte>, and an accumulator whose Add takes the record, with the record parameter declared allows ref struct. It compiles — a span taken from the Row may flow into the out record — and through the same delegate engine, one thread, interleaved, identical checksums:

per row
CsvAggregation, Row 68.2 ns, 68.5 ns 1.00x
ICsvAccumulator<TSelf>, Add(Row) 99.5 ns, 93.8 ns 1.37–1.46x
record accumulator, TryParse into a ref struct, Add(Reading) 67.5 ns, 69.6 ns 0.99–1.02x

Adding a layer made it faster than the shipped interface. The likely reason — inferred, not confirmed in the disassembly — is that TryParse is a static method on a value type, so the JIT specializes and inlines the Row work into the delegate, and the interface call then carries only a span and a double instead of the whole Row.

The shipped overloads repeat this comparison directly: ExcelParser.FromAttributes and ExcelParser.Generated bind the header once and call one column parser per column into a ref struct model, the same shape the prototype used. A scaled-down 1BRC sample (station;temperature, 413 stations, one decimal temperature, seeded), 5,000,000 rows, one thread, interleaved, two runs, identical checksums:

per row (two runs) ratio vs CsvAggregation
CsvAggregation, Row 45.4 ns, 44.6 ns 1.00x
record, static TryParse 45.5 ns, 45.2 ns 1.00–1.01x
reflection ExcelParser.FromAttributes 65.9 ns, 66.4 ns 1.45–1.49x
generated ExcelParser.Generated 63.3 ns, 62.9 ns 1.39–1.41x

Both mapped paths cost more than the record and CsvAggregation shapes, and the generated map is consistently a little cheaper than the reflected one — the same ordering the prototype found. The per-column cost over the hand-written TryParse here is roughly 9–10 ns per column; with two columns this says nothing yet about wide models, where that cost grows with every mapped column.

The constrained-generic struct, the textbook zero-cost shape, came out slower than the delegate. The multithreaded version of this comparison was unusable: the same inline partitioned loop measured 16.9 ns per row when run fourth and 23.8 ns when run seventh, from heat alone.

The date parser: 1.49x from one vectorized pass over the round-trip form

The date column is 35% of the CSV read, so it was decomposed next. Two candidate cost centres had never been separated: the digit loops, and building the DateTime.

variant
current FastDate 1.00x
digits only, no DateTime built at all 1.19–1.23x
one leap check + month-table ticks 0.83–0.86x
branchless civil-days ticks (Hinnant) 0.76–0.81x
+ SWAR 4-digit year on civil-days 0.82–0.86x
one vectorized pass over the 27-byte form 1.45–1.49x
+ separators validated in the same vectors 1.44–1.50x

Construction was not the problem. DateTime.DaysInMonth followed by new DateTime(y, m, d) runs two independent leap-year computations, which looked like the obvious waste; removing both accounts for 19% at most, and every attempt to replace them with hand-written date arithmetic was slower. The BCL's constructor is not worth reimplementing.

88% of the cost was the digits. yyyy-MM-ddTHH:mm:ss.fffffff is 27 bytes holding 21 digits at fixed offsets. Two overlapping 16-byte loads cover it (bytes 0..15 and 11..26); one unsigned compare per load validates every digit position at once against a constant mask; one shuffle per load packs the digits so they fold in pairs instead of one dependent multiply-add per digit. The seven-digit fraction loop, which the shape makes free, was the largest single piece.

Anything that is not exactly 27 bytes falls through to the existing parser. Two extensions were measured and not built:

  • Validating the separators inside the vectors instead of with five scalar compares: no difference. Those bytes are in L1 by definition and the branches are perfectly predicted.
  • Using the same vectorized date extraction for the 19-byte yyyy-MM-ddTHH:mm:ss form: no difference (7.2 ns either way). Without the fraction there is not enough digit work to pay for the load and shuffle.

The masked validation has one trap worth recording. A first version ORed the digit mask and the separator mask without restricting each to its own positions, which accepts a digit sitting in a separator slot — 2024503-15T10:20:30.1234567 would have parsed as 2024-01-01. FastDateTests carries one case per separator position for this.

The double parser: nothing found, and the obvious hypothesis was wrong

variant
current FastDouble 12.3–15.4 ns 1.00x
split at the dot, two tight digit loops 0.81–0.90x
BCL double.TryParse 0.27–0.31x

FastDouble's loop carries six branches per byte, and the corpus's value column varies in length, so mispredicted loop exits looked like the cost. Running the same parser over values of uniform length tests that directly — and it came out slower, not faster: 18.7 ns on uniform 8-byte values against 12.3 ns on the real mixed corpus. Cost tracks digit count at roughly 2.4 ns per digit, which is the signature of the serial mantissa * 10 + d dependency chain, not of misprediction.

SWAR would break that chain, and this is the one place it would genuinely pay. It needs an 8-byte load, and the value column averages 5.9 bytes, so the fast path would have to read past the end of the field. Inside the reader those spans always point into a pooled buffer with slack, but FastDouble would then depend on an invariant nothing enforces. Not built, for 2 ns.

This corpus had no values above 15 digits, so it never measured the path FastDouble rejected. The real-data corpus is full of them; "Third round" below covers what they cost and the fix.

Parallel CSV converts — 3.46x, and it already ships

CsvParallel.ParseAsync<T> partitions by byte range with CsvBoundaryResolver confirming row starts. Typed models, allocating, own interleave:

dop 1 (falls through to the sequential path) 14.202 ms
dop 6 5.068 ms (2.80x)
dop 12 4.107 ms (3.46x)

This was the one place in the library where parallelism converted, and it converted because CSV has no inflate floor. Since v7 SheetParallel also reads XLSX and XLSB on several threads (see "Parallel intra-sheet parsing" below), with a smaller gain because inflate is still sequential. Parallel reading is only available on the typed path: Row and Cell are ref structs and cannot cross a thread boundary, so raw row enumeration hits the same wall. CsvParallel is named SheetParallel in v7; the entries in this file keep the names they were measured under.

Approaches measured and rejected

SIMD structural index (simdjson stage 1) — 0.86–0.97x, rejected

A prototype replaced the scanner's repeated short Span.IndexOf calls with a bitmap of <, > and quote positions (one bit per byte, AVX2), added quote-pair attribute extraction, and dropped the </row pre-scan. Both scanners produced identical checksums over 917,504 cells.

It was slower, across three independent interleaved runs. Its locate-only core lost 11.07 ms to 12.59 ms even with index construction (2.12 ms) measured separately.

Span.IndexOf(byte) is already vectorized, and XLSX hop distances are short (~37 bytes per cell), so a scan almost always resolves inside the first vector — one load, one compare, one tzcnt. The bitmap adds 37.5% more memory traffic and a dependent mask load to save a branch that was already cheap. AVX-512 would halve index construction but not the memory traffic, so it would not flip the verdict.

Parallel intra-sheet parsing — 4.6x on the scan, 1.29x on the reader, not built

Partitioning an already-inflated sheet by byte range works and is exactly correct: checksums at degree of parallelism 1/2/4/8/12/16 were byte-identical to the sequential scan. A raw < cannot appear in XML text content, so the first name-boundary-checked <row at or after a chunk start is unambiguously a row start — no confirm-then-reparse cascade like the parallel CSV path needs, and rows carry no cross-row state.

The scan went from 32.7 ms to 7.1 ms at dop 12 (4.59x). The reader did not move. P only barely exceeds I, so parallelising the scan takes P to 26.0 ms and the prefetched floor becomes max(38.8, 26.0) = 38.8 ms — 1.29x. Parallelising the entire parse gives the identical 1.29x, because inflate is then all that is left.

It would also only ever serve the typed-parsing layer: Row/Cell are ref struct and cannot cross a thread boundary.

Update 2026-10-10: built. InflateStream moved the inflate floor, so the prefetched XLSX read became parse-bound and the reasoning above no longer holds. A spike (Ryzen 7 5700X, real-data 65K corpus, min of 30 interleaved rounds, sheet stage only, outputs identical to sequential) measured:

XLSX (6.7 → 34.4 MB) XLSB (3.7 → 15.9 MB)
read today, no prefetch / prefetch 44.6 / 29.2 ms 26.5 / 15.8 ms
inflate only, whole buffer 14.9 ms 9.5 ms
parse only, dop 1 / 2 / 4 / 8 29.0 / 14.8 / 7.8 / 4.2 ms 15.4 / 7.8 / 4.0 / 2.3 ms
A: 1 inflate thread + N parse workers (N ≥ 2) 16.0 ms (1.8x) 12.5 ms (1.25x)
B oracle: parallel inflate + parse, 8 threads 7.7 ms 3.6 ms

A is what shipped: SheetParallel over XLSX and XLSB, one producer that inflates and cuts row-aligned chunks, dop - 1 parse workers, an ordered merge (see ARCHITECTURE.md). The spike's consumer was light and its buffers were pre-touched, so those numbers are the shape of the gain, not a promise. Measured on the built pipeline (JIT harness, same corpus and method, light consumer, 256 KB chunks):

XLSX XLSB
prefetched sequential read 28.35 ms 15.65 ms
pipeline, dop 3 to 16 17.6 ms (1.61x) 13.3 ms (1.18x)
typed ParseAsync, sequential / dop 4 55.4 / 18.9 ms 37.6 / 14.4 ms
xl_parse_typed over the published NativeAOT DLL, dop 1 / dop 0 54.1 / 20.3 ms (2.6x) 28.2 / 15.2 ms (1.85x)

Under the JIT the sheet stage stops scaling at dop 3, one producer plus two workers: dop 2 is a plain overlap of inflate and parse and gains nothing. The chunk size defaults to 256 KB, down from 1 MB: it removes ~0.5 ms of serial tail, and 4 MB adds ~3.7 ms. XLSB stops at ~13 ms against a 9.6 ms inflate floor because the producer walks every BIFF12 record (~2.2 ms) and InflateStream.Read adds a copy (~0.7 ms). Only parallel inflate moves that. The BenchmarkDotNet tables for this are in benchmarks.md; they were taken on a loaded machine and are pending an idle rerun.

Faster inflate — measured separately, no gain

libdeflate was measured against System.IO.Compression on this workload before this investigation: no faster, and slower in places. libdeflate is whole-buffer only — no streaming API — so it forces the entire sheet to be materialized before parsing starts and destroys the inflate/parse overlap PrefetchStream exists to provide.

The inflate floor does not move by swapping in a whole-buffer compression library. That is what this measurement showed, and it was read too broadly: the decoder itself was never at the floor. Measured on its own libdeflate is about twice as fast as DeflateStream on these entries; what cost it the win end to end was having no streaming API. "Fourth round" below measures decoders in isolation and replaces DeflateStream with a managed streaming one.

Lazy number parsing — a trade, not a win

EmitScalarValueFast runs FastDouble.TryParseLossless on every number cell at emit time whether or not the consumer asks for the value. Guarding both call sites (temporarily) to defer it:

eager lazy
nobody reads a number 35.3 ms 29.4 ms saves 5.8 ms
consumer reads every number 42.4 ms 44.8 ms costs 2.4 ms

Both paths produced a byte-identical accumulator, so the lazy fallback is correct. Parsing at emit time, while the value bytes are still hot in L1, is cheaper than parsing them later from Cell.TryParse. Deferring skips no work for a consumer that reads numbers, and loses the locality. Worth having only as an opt-in for consumers that skip numeric columns entirely — never as a default.

Shared-string prefetch — measured at ~1.1x, not built

The consumer needs the shared-string table at row 1, and in a pull model the consumer's thread is the thread parsing the sheet, so it blocks there and the sheet parse stops with it.

version gain
load the SST on a background task at open ~1%
+ resolve the shared index lazily at consumption ~1%
overlap the two inflates only negative (49.7 ms vs 48.4)
speed up the shared-string parser 0% (that stage is inflate-bound)
incremental SST with a published watermark 1.11x measured
decouple the sheet parse from the consumer 1.69x (upper bound, not measured)

Overlapping the inflates alone loses because the sheet stage is already parse-bound at 28.5 ms against 20.4 ms of inflate — its inflate is already hidden, so pre-inflating buys nothing and costs scheduling.

The watermark variant, and why it falls short

Shared-string indices are assigned in first-use order, so a sheet written row by row references them in roughly ascending order. That suggests parsing the SST incrementally on its own thread, publishing a monotonic "resolved up to index N" watermark, and letting the sheet parse — still on the consumer's thread — block only when it needs an index the SST has not reached. No producer/consumer split.

The ordering property is real. Measured over StringHeavy.xlsx (190,105 unique strings), the highest index a row needs, by row decile:

row decile 0% 10% 20% 30% 50% 70% 90%
fraction of the table needed 20% 34% 46% 56% 72% 87% 100%

Row 1 alone needs only index 10. A prototype confirmed the consequence: across ~590,000 shared cells, the sheet stalled exactly once — after that the SST parser stayed ahead for the whole file.

But that single stall costs 10.9 ms of the SST's 14.5 ms, hiding only 3.6 ms. The sheet reaches row 1 in microseconds and already demands a fifth of the table. A stall happens whenever table fraction / row fraction exceeds the ratio between the two stages' total costs; in the first decile that is 3.4 against a ratio of 2.32. The prologue is shortened, not removed.

Serial 46.5 ms → watermark 42.0 ms, 1.11x. On the numeric corpus (5.9 KB table) it measured 0.98x — threading overhead with nothing to hide.

In the real reader it would be worse. The prototype's stage ratio was favourable: its sheet scan is 33.6 ms against an SST parse of 14.5 ms that excludes inflate, a ratio of 2.32. The reader's real ratio is 29.3 / 19.8 = 1.48, because its SST stage is inflate-bound at 18.6 ms. A lower ratio means more stalling, not less. Scaling the measured stall gives ~14.9 ms of the 19.8 ms stage hidden away, about 4.9 ms off a 48.4 ms read — ~1.1x again, from both directions.

Not worth building: ~1.1x on string-heavy workbooks only, in exchange for a background thread inside the reader, the MaxSharedStringBytes limit being enforced on it, exception propagation across it, and a public API change. The 1.69x ceiling remains real but sits behind the producer/consumer split, which is the same constraint that caps parallel parsing — and the watermark, which looked like the shortcut to it, delivers less than a sixth.

Split character-class masks in the CSV scanner — 0.99x, rejected

CsvControlScanner ORs four Vector256.Equals results into one mask, and the record loop then reloads buf[stop] to find out which class the hit belonged to. Keeping the four masks separate and testing the bit against them removes that second load. It measured 0.389 ms against 0.384 ms — no difference. Three extra ExtractMostSignificantBits cost exactly what an L1 hit costs, and the byte was in L1 by definition, having just been loaded into the vector register.

A CSV-specific narrow cell descriptor — 0.104 ms, not built

CellDesc is 32 bytes because it carries Number, HasNumber and SharedIndex for the binary formats. CSV never sets any of them. A 16-byte descriptor measured 0.562 ms against 0.666 ms over 200,000 fields — real, and 2% of the read. Not worth a second descriptor type and a second ToCell path through Row and Cell.

Restructuring FastDouble around the decimal point — 0.81x, rejected

FastDouble runs six branches per byte: dot seen, digit range, leading zero, digit counter, overflow guard, scale increment. Locating the dot once with IndexOf and running two tight digit loops removes all of that from the per-byte path. It measured slower: 17.0 ns against 13.9 ns. The corpus's value column averages 5.9 bytes, so the IndexOf call and the slicing cost more than the branches they eliminate. FastDouble is already 3.4x double.TryParse (13.9 ns vs 47.5 ns).

What landed

Two changes, both verified against the full suite (1535 tests):

  • ParseRowInWindow replacing EnsureRowBuffered + ParseRow. The old design located </row first and then walked the same bytes again looking for cells, traversing every row twice. Cells and the row end now come off one forward walk; the contiguity guarantee moved to a slow path that re-parses after a refill, which is reached only when a row genuinely straddles the buffer window. Worth 2.2 ms on the scan in isolation (1.10x).
  • XlsxXml.ColumnIndex unrolled to XFD, the last valid column, with longer refs falling back to the original loop. 1.44x in a direct A/B over the 983,040 real r="..." values from the corpus (4.40 ns → 3.05 ns per ref), agreeing on every one.

Integrated effect: ~1.8 ms, about 5% of L1. The components measure 3.4 ms in isolation; the gap is expected, because the </row pre-scan was also warming the cache for the walk that followed it. Microbenchmarks overstate.

A third change, from the CSV investigation: FastDate, a UTF-8 ISO-8601 date parser, wired into Cell.TryGetDateTime as the first attempt for non-numeric cells. Text dates went from unreadable (0 of 50,000) to 40.4 ns each, against 163.7 ns for the GetString() + DateTime.Parse workaround that was previously the only option. It serves CSV, text dates in XLS, and XLSX t="d".

A fourth: FastDate.TryParseRoundTrip, a vectorized path for the 27-byte yyyy-MM-ddTHH:mm:ss.fffffff form — the shape this library's own CsvWriter emits. 1.45–1.49x over the scalar parser, gated by a 50,000-value differential and by FastDateTests cases covering a digit in each separator slot. Anything else falls through to the scalar parser unchanged.

Second round (2026-09-21, Ryzen 7 5700X)

Measured on the published tables' machine, BenchmarkDotNet --job Medium, one run before and one after, each whole benchmark class. Baseline was the previous commit built from a git archive. Full suite at 1759/1759 afterwards.

  • FastDate reached the typed path. ColumnParserFactory.TryParseDateTimeText tried Utf8Parser.TryParse(…, 'O') before the culture parse, and TryParseDateOnlyText went straight to the culture parse. Both now try FastDate first and keep the culture parse as the fallback. CsvParserTests.TextDatesBindExactlyAsTheCultureParserWould pins nine shapes to the old result.
  • XLSX t="d" cells use FastDate. XlsxReader.Enumerator.TryParseIsoDate tries it first and keeps its char-copy DateTime.TryParse for shapes FastDate rejects. Years below 100 are rejected as before. No benchmark reads t="d" cells, so this one is unmeasured; it is the same parser the CSV rows below measure.
  • IRowWriter.WriteUtf8(ReadOnlySpan<byte>), a default interface method that decodes to a string. CsvRowWriter and XlsxRowWriter override it to copy the bytes straight through when they are valid UTF-8 and need no escaping, and fall back to Write(string) otherwise — always for XLSX shared strings. The C ABI and the Arrow writer now call it instead of building a string per cell. WriteUtf8ParityTests checks 14 values byte-for-byte against Write(string) on all three paths.
  • Styled rows format the style id with Utf8Formatter into the row buffer instead of an interpolated string per row.
before after
CsvParseBenchmark.ExcelParserSync (typed, 50k rows) 7.081 ms 5.979 ms 1.18x
CsvParallelParseBenchmark.ConversionHeavy, dop 1 1,372.6 ms 981.9 ms 1.40x
CsvParallelParseBenchmark.ConversionHeavy, dop 16 331.6 ms 271.8 ms 1.22x
CsvParallelParseBenchmark.ConversionHeavyAggregate, dop 1 1,217.1 ms 845.9 ms 1.44x
CsvParallelParseBenchmark.ConversionHeavyAggregate, dop 8 240.0 ms 160.8 ms 1.49x
WritePathBenchmark.NativeStrings_Csv 10.14 ms / 21.36 MB 4.881 ms / 384 B 2.08x
WritePathBenchmark.NativeStrings_Xlsx 27.16 ms / 21.38 MB 21.053 ms / 18 KB 1.29x
WritePathBenchmark.ArrowStrings_Xlsx 31.49 ms / 21.38 MB 26.087 ms / 18 KB 1.21x
WritePathBenchmark.StyledRows_Xlsx 10.07 ms / 7.65 MB 7.814 ms / 18 KB 1.29x

Controls: NarrowInt, which has no dates, and Sylvan both stayed inside run-to-run noise (Sylvan 11.97 → 12.78 ms, in the unfavourable direction). Allocation on the typed paths did not change; the model object per row is what they allocate. The published tables in benchmarks.md come from a third, separate run after these changes.

Writing doubles (2026-09-21, Ryzen 7 5700X)

WriteBenchmark had SpreadCheetah ~8% ahead of the XLSX writer. With compression off the writer still took 13.6 ms of its 17.5, so the cost was generating XML, not deflate. Per cell, above an empty row: string ~30 ns, int ~18 ns, double ~92 ns, date ~118 ns. Both of the last two end in Utf8Formatter.TryFormat(double), the general shortest-round-trip formatter.

CellFormatter.TryFormatDouble tries scales 10⁰–10⁴ and takes the first where round(v·10ᵈ) / 10ᵈ == v exactly, for 0 < |v| < 1e9. That integer, with a decimal point inserted, is the shortest form, so the output is byte-identical. Anything else, including -0, falls through to Utf8Formatter. The XLSX writer (numbers and date serials) and the CSV writer use it.

per value Utf8Formatter TryFormatDouble
short decimals (1.5, 45293.25) 61.6 ns 15.1 ns 4.07x
integral 55.2 ns 12.5 ns 4.40x
serials with a time of day 99.5 ns 98.9 ns 1.01x

Correctness gate: 6.1M values (short decimals, random doubles across magnitudes, random bit patterns, edges and their neighbours), zero mismatches. DoubleFormatTests keeps 600k of them.

End to end, A/B harness (one executable built against each ExcelReader.Core.dll, alternated three times, min of 25):

50k rows before after
XLSX 17.50 ms 12.68 ms 1.38x
XLSX, shared strings 17.08 ms 12.11 ms 1.41x
CSV 6.71 ms 4.23 ms 1.59x
XLSB (control) 7.65 ms 7.63 ms —
SpreadCheetah (control) 15.66 ms 15.52 ms —

BenchmarkDotNet afterwards: XLSX writing 12.261 ms against SpreadCheetah's 15.548 ms, CSV writing 4.624 ms against Sep's 7.075 ms. XLSB writes doubles in binary and is unaffected.

Not extended to serials with a time of day: days + seconds/86400 has a factor of 3³ in the denominator, so its shortest form is ~17 digits and no scale reaches it. Only a full shortest-float algorithm (Ryu-class) would, for ~85 ns per such cell. Not built.

Write-side decomposition of XLSB and XLS — nothing to take

Same per-column-type method as above, 50k rows:

XLSB, Fastest XLSB, no compression XLS
all four columns 8.37 ms 3.98 ms 5.19 ms
empty rows 1.79 ms 0.94 ms 0.53 ms

XLSB is deflate-bound: more than half its time is compression, and serialization costs 10–17 ns per cell. prefetchWrite already moves that deflate off the caller's thread. XLS costs 5–7 ns per cell.

RK encoding for non-integral doubles — measured, reverted. The XLSB writer emits BrtCellRk only for integers. Adding the other two RK forms (truncated IEEE, integer/100), each accepted only when Biff12.Rk decodes it back to the same bits, turned 1.5 and 45293.25 into 4-byte cells: the raw sheet shrank 8% (3.75 → 3.45 MB) but the compressed one 1.3% (706 → 697 KB), and write time moved ~1%, inside noise. Deflate was already squeezing those 8-byte doubles. Not worth the diff or the Excel compatibility check an XLSB writer change needs.

The native bindings: NativeAOT loses dynamic PGO

The C++ and Rust bindings read 65K_Records_Data.xlsx in ~110 ms, against ~65 ms for the .NET reader. One harness ran the same xl_open_memory + xl_parse_typed exports (14 typed columns) three ways, plus the equivalent Core-only read:

ms
Core read, JIT 70.9
ABI, JIT (delegate* unmanaged over the managed assembly) 84.1
ABI, NativeAOT (P/Invoke into the published library) 109.3
ABI, NativeAOT, IlcInstructionSet=x86-64-v3 107.3
ABI, NativeAOT, IlcPgoOptimize (framework profile) 109.4
ABI, JIT, DOTNET_TieredPGO=0 106.6

The C++ binding's published 110.4 ms matches the AOT row, so its std::string conversion is negligible. The ABI layer costs ~13 ms. The rest, ~25 ms, is dynamic PGO: with it off, the JIT lands exactly on NativeAOT. The instruction set is not the cause.

Where PGO acts, DOTNET_TieredPGO=0 against the default, JIT:

PGO no PGO
inflate only 23.1 ms 23.2 ms
rows only 61.8 ms 79.7 ms
+ 14 cell spans +1.3 ms +9.5 ms

The XLSX scan. MethodJitInliningFailed events (runtime keyword 0x1000, read with an in-process EventListener) showed what PGO inlines into the per-cell path that static heuristics refuse, as "too many il bytes" or "unprofitable": ReadCellOpenTagSpan, ScanCellAttributes, IsXmlSpace (a call per attribute byte), XlsxXml.ColumnIndex, IsCellStart, FastDouble.TryParse and CellAccumulator.Add. [MethodImpl(AggressiveInlining)] on those seven:

before after
rows only, JIT without PGO 79.6 ms 66.3 ms
ABI, JIT without PGO 107.4 ms 94.0 ms
ABI, NativeAOT 109.9 ms 97.2 ms (1.13x)
rows only, JIT with PGO 60.0–62.2 ms 60.8–62.8 ms (alternated three times; noise)

Remaining gap to the PGO'd JIT is ~13 ms. Forcing Row's column lookup inline changed nothing, so what is left in the cell accessor is block layout and register allocation, which attributes do not reach. A trace-derived .mibc fed to ILC (MibcFile items become --mibc) is the tool for that; it needs dotnet-pgo, which is not on nuget.org.

Re-running the binding suites against a library published from this tree: the C++ full XLSX read went from 110.4 to 98.0 ms and Rust's from 123.5 to 108.5 ms, and the C++ write_sheet from 136.6 to 100.1–105.3 ms, the last mostly from TryFormatDouble.

Second pass: XLSB, the ABI layer, and a static profile

Same harness, now over all three formats (14 typed columns, 65K rows), min ms:

Core, JIT ABI, JIT ABI, NativeAOT
XLSB 29.2 45.3 52.2
CSV 12.2 26.9 33.7

XLSB record reader. Biff12RecordReader.TryReadRecord runs once per cell and was refused inline without PGO. Forcing it inline moved the Core XLSB read from 33.3 to 27.1 ms without PGO, faster than the PGO'd JIT's 28.8. No change with PGO.

The ABI layer costs more than the whole CSV read. A CPU-sampled trace (dotnet-trace, dotnet-sampled-thread-time) and a counter around BuildTable split its ~15 ms on CSV into 0.6 ms copying the caller's buffer in xl_open_memory, 2.5 ms copying the column chunks into the native table, and ~12 ms of per-cell work. By column type, ABI minus Core: string ~33 ns/cell, date ~24, double ~24, int ~8. Three changes, worth 2.5–3 ms with PGO and ~1 ms without:

  • string cells append their UTF-8 bytes directly instead of TryFormat into a scratch buffer and then copying;
  • date/time/bool columns call the ColumnParserFactory readers directly instead of through the ExcelCellReaders delegate fields;
  • the validity bitmap is not touched until a column's first null, which backfills every earlier row; a column with no nulls ships no bitmap, so writing a bit per cell was wasted.

Building the table in native memory directly would remove the 2.5 ms copy but needs realloc growth (which copies too) and a change to what xl_free_table frees. Not done.

Static PGO profile. tests/ExcelReader.NativePgoTrainer runs the typed parse over the three fixtures; a trace of it becomes src/ExcelReader.Native/pgo/excelreader.mibc, which ILC now consumes on every publish. ABI through NativeAOT, min ms:

no profile full profile profile without XLSB methods JIT with PGO
XLSX 95.8 85.5 84.9 (1.13x) ~83
XLSB 42.9 44.1 38.4 (1.12x) ~41.4
CSV 32.1 25.8 25.6 (1.26x) ~24.4

The profile's XLSB counts made the XLSB parse slower; dropping those methods from it made XLSB faster than both the unprofiled build and the JIT. The shipped profile excludes them.

Typed mapping, not changed. A generated [ExcelSerializable] map parses the 50K-row CSV in 5.44 ms against 3.99 ms for a hand-written loop doing the same conversions. The generator's fused lambdas still invoke ExcelCellReaders.String/DateTimeAuto, which are delegate fields, so string and date cells pay two indirect calls. Emitting direct calls through a hidden public helper class measured ~3%, inside noise. Not worth new public surface.

A prototype of the bigger change, the generator emitting one static whole-row parse over header-resolved column indices, showed the rest of the gap is not the mapping either. Stepping from the hand-written loop to the shipped path, min ms of one clean run:

step ms delta
hand-written loop 4.05
+ empty-cell checks and a column-index indirection 4.35 +0.30
+ the whole-row parse as a separate static method 4.49 +0.13
+ handing each model to a per-row callback 5.04 +0.55
+ handing it out through an IEnumerable<T> iterator 5.40 +0.36
shipped generated map 5.45 +0.05

The per-column delegates and binding loop cost ~0.05 ms over a whole-row parse. The 1.36x is the API's shape, one model per row delivered to the caller, plus the empty-cell checks any correct mapper needs. A generator rewrite would recover ~1–5%. Not built.

Third round (2026-09-25, Ryzen 7 5700X)

This round covered two things: string-heavy XLSX first, then the 17-digit doubles that fill the real-data corpus. The iterations ran in the A/B console harness: one executable built against each ExcelReader.Core.dll, alternated three times. The before/after tables are BenchmarkDotNet --job Medium, comparing the previous commit (9cc3be4) against the result. Full suite at 2391/2391 afterwards.

String-heavy XLSX: the shared-string parse was half the prologue

XlsxSharedStringHotPathBenchmark splits the read into stages. Its stored variants re-zip the same workbook without compression, so each stored row minus its compressed twin is that stage's inflate.

stage before after
whole read 54.16 ms 42.04 ms 1.29x
whole read, prefetch 34.27 ms 28.31 ms 1.21x
shared-string table (open + first row) 23.07 ms 13.63 ms 1.69x
whole read, stored 32.32 ms 19.97 ms 1.62x
shared-string table, stored 11.53 ms 3.10 ms 3.72x

Before, the table cost ~11.5 ms of inflate and ~11.5 ms of parse, so it was not inflate-bound on this machine. ParseSharedBody located every <si> with a sequence of token searches, even though nearly every entry has the plain shape <si><t>text</t></si>. Allocation did not change in any row.

Three changes landed, each with parity tests in XlsxXmlDialectTests:

  • Plain shared strings (c22b90e). The fast path matches <si><t>, runs one IndexOfAny('<', '&'), requires </t></si> right after and copies the text. Anything else — entities, xml:space, rich runs, rPh, CDATA, <t/> — takes the existing path. In the harness the table went from 24.2 to 14.2 ms and the whole read from 55.4 to 45.3 ms.
  • The shared index parsed together with the cell close (4f7e620). One digit loop that must end at </v></c>, instead of an IndexOf('<') followed by Utf8Parser.TryParse per cell. In the harness, prefetch went from 35.7 to 33.4 ms and the stored sheet stage dropped 7.6%. Over the 524,288 indices in isolation, the digit loop costs 1.36 ms against 2.37 ms for Utf8Parser. A standalone parser swap with no fused close check did not show that gain end to end.
  • The canonical <c> attribute scanner inlined (1902511). The Tier1 disassembly showed its out-parameters (tag end, column, style, kind) going through the stack on every cell. In the harness this took prefetch 7.6%, the real-data read 5%, typed parsing 7% and real-data typed parsing 4% faster.

Measured and not kept:

attempt result
hoist the </row check into a NoInlining helper to free registers 1–2% slower
NoInlining on ParseCellSpan 5–8% slower
AggressiveInlining on the shared-index emitter neutral
split the cell parser into hot and cold halves JIT 3% faster, NativeAOT 0.6% slower
a smaller second CellDesc ceiling 1.3 ms: removing CellAccumulator.Add entirely (measurement-only build) saves that much of the ~15.6 ms sheet parse
faster WorkbookLookups.SharedAt ceiling 0.5 ms, measured the same way; the cost is the memory access
zero-copy PrefetchStream handoff ceiling ≤2.1 ms (prefetched sheet stage against stored); the copy itself is ~0.3 ms with a hot cache

What remains of the 42 ms default read is ~22 ms of inflate (~10.5 ms for the table, ~11.5 ms for the sheet) and ~17 ms of worksheet scan, with no isolable hotspot left in it. Only PrefetchDecompression reduces the inflate share, by overlapping it with the parse.

17-digit doubles: Eisel-Lemire

8.4% of the real-data corpus's numeric cells (49,799 of 589,815) have 17 significant digits, such as 35.840000000000003, the form Excel writes for many computed values. FastDouble stopped at 15 digits, so each of those fell through to double.TryParse, at ~106 ns against ~36 ns now.

c0fe107 accepts up to 19 digits. When the mantissa is ≤ 2⁵³ and the power of ten is within ±22, it keeps the exact multiply/divide. Otherwise it runs a port of fast_float's compute_float, using 128-bit truncated powers of five for 10⁻⁶⁴–10⁶⁴. Values outside that table, subnormals and overflow still go to double.TryParse.

Correctness gate: ~16M inputs compared bit for bit against double.Parse, with zero mismatches. The inputs were the corpus values, random bit patterns formatted as R, G17 and E18, Excel-like magnitudes, random decimals, the edge cases, and 300,000 strings within one unit in the 19th digit of an exact halfway point between two doubles. FastDoubleTests keeps a 200,000-value random round trip and the 17–19 digit cases.

The first version lost precision. It also parsed the long values at emit time, which set HasNumber, and then TryParse<long> and TryParse<decimal> converted from the double: long.MaxValue became 2⁶³ and failed, and 35.840000000000003m became 35.84m. CellVariantTests.TryParseLongSucceeds caught it. XLSX emit now stops at 15 digits (FastDouble.TryParseLossless), where the double keeps every digit the text had; the 19-digit parser serves TryGetDouble and TryParse<double>. The emit-time version was faster (1.11x on the real-data read against 1.07x), and recovering those ~3 ms needs a separate "approximate double" flag on Cell.

A/B harness, medians of three alternated rounds:

before after
real-data XLSX, cell-by-cell 58.3 ms 54.4 ms 1.07x
same, stored 36.1 ms 32.5 ms 1.11x
real-data XLSX, typed parse 69.6 ms 65.8 ms 1.06x
xl_parse_typed over the ABI, JIT 71.5 ms 68.2 ms 1.05x

The same investigation found a culture bug. Cell.TryParse<T>(provider: null) treated null as the invariant culture in the fast path but as the current culture in the fallback, so under pt-BR the fallback read 35.840000000000003 as 3.58e16. Before Eisel-Lemire every 17-digit cell took that fallback. The fallback that re-parses a stored double's formatted text used the caller's provider on text that is always invariant, so a pt-BR TryParse<Half> read 3.5 as 35. Since b8bcafa, null means the invariant culture everywhere, and that re-parse is always invariant. CellNullProviderTests runs under pt-BR.

NativeAOT, again

After this round, xl_parse_typed over the real-data XLSX file costs 74.1 ms under the JIT and 84.0 ms under NativeAOT; XLSB is at parity (41.4 against 42.3 ms). The JIT with DOTNET_TieredPGO=0 lands at 84.7 ms, so the gap is still dynamic PGO. Turning off guarded devirtualization changes nothing. The static profile does not recover it. The .mibc reaches ILC (--mibc is in the response file) and holds edge counts for ParseRowInWindow, yet removing it or regenerating it from this tree moves XLSX by ≤0.5% (85.1 against 85.4 ms). IlcInstructionSet=x86-64-v3 is worth ~1%.

A trap for whoever regenerates the profile: the ILC publish is incremental and ignores a changed .mibc, producing a byte-identical library. Delete src/ExcelReader.Native/obj/Release/net10.0/<rid>/native before publishing.

The C++ binding's full XLSX read moved from 93.2 to ~85.4 ms with no native change, from the XLSX changes above.

NativeAOT XLSX gap, re-checked (2026-10-09, Ryzen 7 5700X)

The gap quoted under "NativeAOT, again" (JIT 74.1 ms, NativeAOT 84.0 ms) predates InflateStream, the regenerated profile and the native column buffers, so it was re-measured first. The python binding reads 65K_Records_Data.xlsx with parse_typed in 58.0–58.5 ms from the shipped library. The same ReadApi.OpenMemory + TypedApi.ParseTyped calls in a scratch console, JIT against NativeAOT built with the library's settings (OptimizationPreference=Speed, x86-64-v3, the .mibc), min of 40 rounds:

stage JIT NativeAOT
inflate sheet1.xml (InflateStream) 16.0 ms 16.0 ms
rows only, no prefetch 39.1 ms 43.5 ms
rows + TryGetDouble on every number 42.8 ms 47.9 ms
xl_parse_typed 54.0 ms 60.7 ms

So the gap is ~12% in the harness and ~7% against the shipped library. Inflate is at parity, so the decode loop is not part of it. About two thirds is the worksheet scan and a third is the typed conversions after it.

A first harness left out OptimizationPreference=Speed and read 66.5 ms for NativeAOT: ILC's default blended mode inlines much less. Check a harness's ILC settings against the project's before trusting a gap measured in it.

The disassembly, with --codegenopt:JitDisasm=ParseRowInWindow and JitStdOutFile given to ILC as IlcArg items through a CustomAfterMicrosoftCommonTargets file: ILC's ParseRowInWindow is 846 bytes with 15 inlinees and a call to ParseCellSpan. The JIT's Tier1 listing is 3,906 bytes with 202 inlinees and no such call; it pulled ParseCellSpan and its callees into the row loop. (Earlier notes found NoInlining on ParseCellSpan costs the JIT 5–8%, so the inlined shape is the fast one.) [MethodImpl(MethodImplOptions.AggressiveInlining)] on ParseCellSpan makes ILC do the same:

NativeAOT before after JIT before after
rows only 43.5 ms 41.8 ms 39.2 ms 39.5 ms
xl_parse_typed 60.7 ms 58.9 ms 54.0 ms 54.0 ms

That is 3–4% in the harness and neutral under the JIT. It does not reach the shipped library: the real ExcelReader.Native.dll built with and without the attribute, alternated three times through the python binding, measured 57.9–58.5 ms and 58.4–58.7 ms. The C++ BM_ExcelReader_Xlsx_ParseOnly agreed (55.9–56.8 ms against 56.6–56.8). The real library's ParseCellSpan was a call in its baseline too, so the attribute applies there; the Core-level harness just does not predict the end-to-end number, most likely because the C ABI layer and the typed column writers dominate what the scan saves. Not kept.

What is left is spread thin: ~0.7 ms in the consumer's double parse (TryGetDouble over every number costs 4.4 ms under ILC and 3.7 ms under the JIT, with listings of about the same size, 662 and 641 bytes), ~2.5 ms in the typed conversions, and the rest in the scan. No hot spot is visible in either listing.

Fourth round (2026-10-08, i7-1365U): a managed inflate decoder

Measured on the laptop, so only the ratios mean anything; the published tables need the Ryzen run. Harnesses were throwaway console apps: interleaved rounds, minimum of 30 to 60, outputs compared byte for byte.

Inflate only, single thread pinned to one core, against DeflateStream (.NET 10):

decoder XLSX sheet1.xml, 6.7 → 34.4 MB XLSB sheet1.bin, 3.7 → 15.9 MB
libdeflate 1.19, whole buffer 2.14–2.18x 1.64–1.67x
ISA-L 2.30, whole buffer 1.48–1.49x 1.29–1.33x
ISA-L 2.30, streaming, 64 KB 1.39–1.42x 1.23–1.25x
InflateStream 1.56–1.61x 1.35–1.41x

InflateStream is a C# port of libdeflate's decoder design made resumable. It reaches roughly three quarters of libdeflate's speed on XLSX and four fifths on XLSB, and passes ISA-L without a native dependency. An earlier throwaway version that decoded a whole buffer in one call measured 1.74–1.78x on XLSX, so streaming costs under a tenth.

End to end in the reader, in-process A/B over the real-data corpus, identical checksums. The InflateStream column is one run of 30 rounds; the ISA-L column spans four runs:

read InflateStream ISA-L behind the same seam
XLSX, no prefetch 1.25–1.26x 1.19–1.21x
XLSB, no prefetch 1.13–1.15x 1.10–1.14x
XLSX, prefetch 1.11–1.12x 1.09–1.15x
XLSB, prefetch 1.05–1.06x 1.03–1.07x

This is the I + P model again. Without prefetch a faster I converts directly. With prefetch the read is max(I, P), both decoders land together, and what is left is the parse.

InflateBenchmark, BenchmarkDotNet --job Short, in-process, same laptop:

entry DeflateStream InflateStream ratio
XLSX sheet 34.91 ms, 541 B 21.71 ms, 332 B 0.62
XLSB sheet 19.40 ms, 404 B 13.68 ms, 270 B 0.71
string-heavy sharedStrings.xml 16.10 ms, 280 B 10.32 ms, 270 B 0.64

Measured and not kept:

attempt result
AggressiveOptimization on the decode loop no change, so the loop does not depend on dynamic PGO
Vector128 match copy for offsets of 16 and up no change on XLSX, slightly slower on XLSB

The decode loop's Tier1 disassembly (2026-10-09, Ryzen 7 5700X)

DOTNET_JitDisasm=FastLoop on a scratch harness that inflates the XLSX sheet 150 times. The (Tier1) listing is 955 bytes, and the loop keeps every live value in a register: bitbuf, bitsleft, both cursors, the limits, the table base and the mask are all in registers, with eight callee-saved pushes in the prologue and no stack traffic inside the loop. The spill problem the CSV scanner had is not here. Two earlier listings are Instrumented Tier0 and Tier1-OSR; the OSR one is the method's first long call, so read the last listing.

Where the time goes, from timestamps around the pieces, one XLSX sheet, 16.6 ms in total:

piece ms
FastLoop (1,042 calls) 14.6 88%
417 dynamic block headers and table builds 0.7 4%
copy to the caller and slide the window (isolated) 0.8 5%

So the streaming overhead the "Fourth round" blamed for part of the gap is ~5%, and the table builds are not it either. The gap to libdeflate is inside the loop, at ~2.4 GB/s of output.

What the listing does show is small. Each decoded entry pays a zero-extending mov r12d, r15d before sub rsi, r12, because bitsleft is a long and the entry a uint. Making bitsleft a uint, as libdeflate has it, removes that and measured 15.87–15.99 ms → 15.63–15.77 ms on XLSX (~1.5%) and nothing on XLSB. Not applied. The literal path takes four jumps per run of literals because the JIT laid the second and third literal out of line, and several mov r13, rbx / shrx rbx, r13, r15 pairs keep the pre-shift saved value alive for the match path whether or not it is used. None of that is something source changes reach reliably; what is left is the JIT's block layout and register copies. Not worth chasing.

A behavior change came with it. DeflateStream does not reject a stream that ends before its final block: it returns what it decoded and reports end of stream. A sheet entry cut short therefore used to yield a silent prefix of its rows. InflateStream throws InvalidDataException.

Known open items

  • Parallel inflate is the open lead (spec B, not built). It replaces the pipeline's producer only; the queue, workers and merge stay. The oracle ceilings at 8 threads, parallel inflate plus parse, are 7.7 ms for XLSX and 3.6 ms for XLSB, against 17.6 and 13.3 ms for the shipped pipeline. A deflate block finder costs 0.4–1 ms per split point, with no false positives in 14 hits. The marker finding: XLSX back-references stop depending on the unknown window after about 1 MB, but XLSB ones never do, so about 65–70% of XLSB output bytes depend on the unknown window. The cost of the 16-bit marker decode loop is unmeasured, and it decides whether XLSB gains much.

  • InflateStream reaches about three quarters of libdeflate's inflate speed on XLSX. The Tier1 disassembly shows no spills and 88% of the time in FastLoop itself (see "The decode loop's Tier1 disassembly"); the remainder is JIT codegen. A uint bitsleft is worth ~1.5% if anyone wants it.

  • "Fourth round" is from the i7-1365U. The Ryzen 7 5700X re-run (2026-10-09, --job Medium) is in benchmarks.md: InflateStream is 0.70x / 0.78x / 0.63x of DeflateStream on the XLSX sheet, XLSB sheet and string-heavy sharedStrings.xml, against 0.62 / 0.71 / 0.64 on the laptop. The non-prefetched reads moved with it (real-data XLSX 49.7 → 43.8 ms, XLSB 27.7 → 25.7 ms; string-heavy XLSX 41.2 → 33.2 ms) and the prefetched ones did not (27.8 → 29.5 ms, 16.9 → 17.4 ms), as the max(I, P) model predicts. The native C ABI table in benchmarks.md has not been re-run since.

  • NativeAOT XLSX is ~7–10% behind the JIT, down from the ~13% in "NativeAOT, again"; the diff of the JIT's Tier1 ParseRowInWindow against ILC's is done (see "NativeAOT XLSX gap, re-checked"). The one difference found does not move the shipped library.

  • 17-digit numbers parsed twice in XLSX: prototyped 2026-10-09 (Ryzen 7 5700X), not kept. Emit rejects them to keep the text exact and the consumer parses them again. The prototype parsed up to 19 digits at emit, had FastDouble report whether the result keeps every digit (15 or fewer), and carried that as a NumberApproximate flag through CellAccumulator, CellDesc and Cell. TryGetDouble and TryParse<double> used the number; every other T ignored an approximate one and read the text, so long and decimal stayed exact. The flag costs nothing in size: CellDesc stays 32 bytes (the new bool fills the last padding byte) and Cell has padding to spare, so "a wider Cell" was wrong. Real-data XLSX, 49,799 17-digit cells of 589,815, min of 35 rounds, base and new ExcelReader.Core.dll alternated three times in separate processes, identical checksums:

    consumer before after
    TryParse<double> on every number 43.3–43.6 ms 42.1–43.1 ms (~2%)
    TryGetDouble on every number 42.4–43.0 ms 42.2–42.4 ms (~1%)
    TryParse<decimal> 53.5–54.5 ms 54.3–54.8 ms (~+1%)
    no number parsed 41.5–41.9 ms 42.1–43.0 ms (~+1.5%)

    A trade, like lazy number parsing: the double consumers win ~1 ms, not the ~3 ms estimated, because the 36 ns parse moves to emit and the consumers that never ask for a double (decimal, long, text) now pay it for nothing. Not worth a flag threaded through three structs and a Cell semantics rule. Reopen only if double columns dominate real workloads.

  • PrefetchStream handoff: ~2.7 ms on the i7, ≤2.1 ms on the Ryzen string-heavy sheet, of which the copy itself is ~0.3 ms. Removing it means replacing the Stream seam with a buffer-exchange protocol — and that seam is where the decompressed-byte limit counters sit, so it is a trust boundary, not just a copy.

  • CSV enumerator overhead, re-measured 2026-10-09 (Ryzen 7 5700X). The earlier reading is superseded: it predates CsvStructuralScanner and its per-record diagnosis no longer holds. Scratch harness, min of 50 interleaved rounds, 50,000 rows, the scanner driven directly for the framing floor (drain the field-end bits, emit a descriptor per field):

    wide, 32 fields (9.8 MB) narrow, 4 fields (1.2 MB)
    scanner only, popcount 0.79 ms 0.10 ms
    scan + drain + emit a 32-byte descriptor 2.93 ms 0.38 ms
    reader, rows only 5.37 ms (1.83x) 0.88 ms (2.3x)
    reader + row[i].Value.Length on every cell 7.72 ms 1.10 ms

    The overhead above framing is per field, not per record: fitting the two shapes gives ~1.4 ns per field and ~4 ns per row. Per field, DrainFields also loads buf[stop] to find the record end, buf[start] to test for a quote, and builds a CellDesc with a branch on empty; the prototype does none of that. The indexer adds ~1.4 ns per cell on top, about 45% of rows-only, not "as much again". Hoisting _delimiter and _quote into locals made it slower (5.37 → 5.6–5.7 ms), the register pressure result from the CSV enumerator section again. Telling quoted from unquoted fields from a scanner quote-start mask instead of the buf[start] load is the one idea left; it is worth at most ~0.5 ns of a ~3.3 ns field, which is a few percent of any read that converts values.

  • Reading a CSV from an already-resident buffer is slower than from a stream for spans only, and faster for full conversion. Reproduced in both directions across four runs, unexplained.

Resolved: the CSV gap against Sylvan was the benchmark not using this library's date API

CsvReadBenchmark's accumulator parsed the date column with the BCL's Utf8Parser.TryParse(..., 'O') rather than Cell.TryGetDateTime. Every competitor in that table uses its own library's accessor — GetDateTime, GetField<DateTime> — so ExcelReader was the only entrant not being measured through its own API. It had no choice: before FastDate, TryGetDateTime could not read a text date at all.

Measured at 63.6 ns per date for Utf8Parser against 42.0 ns for TryGetDateTime on FastDate. Switching the benchmark to the library's own accessor, then tuning the parser, under BenchmarkDotNet:

CsvReadBenchmark.ExcelReader
as published, date via Utf8Parser.TryParse(…, 'O') 4.786 ms
date via Cell.TryGetDateTime (FastDate) 4.149 ms
+ FastDate tuned (below) 3.966 ms
+ vectorized round-trip path in FastDate 3.381 ms

Those four figures come from four separate BenchmarkDotNet runs over a session during which the machine warmed and cooled, so the 4.786 → 3.381 span is not a clean single-run comparison. The drift-resistant statements are the isolated 1.45–1.49x on a date that is 35% of the read, which predicts about 1.13x end to end, and the same-run competitor ratio below.

Same run, same corpus, 50,000 rows:

mean ratio allocated
ExcelReader 3.381 ms 1.00 368 B
ExcelReaderAsync 3.309 ms 0.98 440 B
Sylvan 4.282 ms 1.27 1,688,737 B
Sep 7.115 ms 2.10 4,024 B
CsvHelper 26.027 ms 7.70 15,073,424 B

None of this goes to the README until it is re-run on the Ryzen 7 5700X.

17.1% on the same method. Sylvan, the control, did not move across the first two runs (4.460 → 4.489 ms) — a third run reported 6.185 ms with a 1.56 ms standard deviation and is discarded as an outlier. Against its stable figure ExcelReader now leads by roughly 12%, at 368 B against 1,688,737 B allocated.

Caveats: six iterations on a throttling mobile CPU give wide error bars (±0.64 ms on ExcelReader), so the lead over Sylvan sits near the margin. The 17.1% before/after on one method is the sounder number, being the same code path measured three times. Everything here needs re-running on the README's machine before publication.

Tuning the parser: two of three changes were worth it

Measured over the corpus's 50,000 real date strings, all variants agreeing on every value:

per date
original (scale /= 10 per fraction digit) 31.3 ns 1.00x
fraction scale from a lookup table 27.2 ns 1.15x
+ two-digit fields unrolled 23.1 ns 1.35x
+ SWAR four-digit year 21.7 ns 1.44x

The first two shipped. The seven scale /= 10 steps were a chain of dependent integer divisions, and the five two-digit fields did not need a loop at all.

The SWAR year did not ship. It buys 1.07x on top — 1.4 ns — in exchange for masked-validation bit arithmetic, and a first attempt at exactly that code had a bug: (a | b) <= 9 does not test two digits, since 2 | 8 is 10. The differential check against the other variants caught it. Six percent is not worth that surface in a date parser.

Still open

  • A first attempt to attribute this gap in an interleaved in-process harness found no gap at all (7.120 ms vs 7.180 ms). That harness was the wrong tool: Sylvan allocates 1.69 MB per operation and triggers 265 Gen0 collections per 1000 ops, so its GC cost lands on whichever variant happens to be running. Interleaving compares variants with matching allocation profiles; against an allocating competitor it needs BenchmarkDotNet's process isolation.
  • FastDate is now ~23 ns on a full round-trip timestamp. Whether Sylvan's date path is still ahead was never measured directly — it was inferred by subtracting totals, which is not evidence.