Skip to content

Parallel native writes, lighter parallel paths, faster first open, 11 bug fixes - #148

Merged
GabrielMarquezMatte merged 38 commits into
masterfrom
develop
Oct 20, 2026
Merged

GabrielMarquezMatte merged 38 commits into
masterfrom
develop

Conversation

@GabrielMarquezMatte

Copy link
Copy Markdown
Owner

What does this change?

Exposes parallel writes through the C ABI and the bindings, trims memory and JIT work on the parallel and cold-start paths, fixes 11 bugs found in a whole-repo review, and adds struct + InternStrings variants to the CSV benchmarks. The managed public API is unchanged; the C ABI only appends fields, so this fits a minor release.

Parallel writes from the native bindings

  • xl_write_options gains degree_of_parallelism and batch_rows, appended to the struct. The 7.0 struct size (32) and a NULL options pointer are still accepted and decode as a serial write.
  • xl_write_typed / xl_write_typed_to_memory hand XLSX and XLSB data rows to the core's parallel record writer when the degree is above 1. XLS and CSV stay serial.
  • C++ (xl::WriteOptions), Rust (WriteOptions builders) and Python (WriteOptions) expose both options, with docs in each README.

Performance

  • Parallel write memory. Each batch was copied into a rented 2 MB deflate window. Batches are now encoded straight from their own buffer, with byte-identical output. Peak working set at 1M rows x 4 columns: +12 MB to +6 MB at 4 workers, +22 MB to +13 MB at 8. It is also slightly faster (XLSX at 8 workers: 40 to 35 ms, about 4.8x the serial write).

  • One worker writes serially. With DegreeOfParallelism = 1 (or 0 on a single processor) the parallel entry points use the serial record write, which was 3-5% faster than batching with one worker. A throw still ends the sheet, as documented.

  • Parallel typed CSV parse. A worker keeps one Utf8StringCache across its chunks instead of allocating one per 64 KB chunk, and RangedFileStream.ReadAsync uses PoolingAsyncValueTaskMethodBuilder. The chunk handle stays overlapped on Windows. 4.3M rows at dop 16: 438 to 352 ms by default, 237 to 206 ms with InternStrings; struct rows with InternStrings allocate 14 MB instead of 40 MB.

  • First open of a process. Cuts what the JIT has to compile on the open path, without changing the managed inflate or CRC:

    • no loop combined with stackalloc in Huffman table building or the CRC finish (that combination is compiled fully optimized on first call);
    • no Span<byte>.Fill, whose Vector<T> memset is never precompiled;
    • ArrayPool<byte> instead of ArrayPool<uint> for the decoder tables;
    • duplicate ZIP entry names checked by pairs up to 64 entries.

    Cold open of a 200-row XLSX: 24.3 to 21.1 ms. Repeated small opens are ~1% faster and allocate 3520 B instead of 3712 B; large reads are unchanged.

Bug fixes

  • native: xl_open_write_handle threw out of an UnmanagedCallersOnly export, terminating the host process, when the file could not be created. It now returns XL_ERROR.
  • csv: enumerating a parallel typed parse a second time hung forever and kept the file open.
  • csv: parallel parse failures reported a row number shifted by the blank lines before them.
  • xls: compound files with 4096-byte sectors (version 4) read the Workbook stream from the wrong offset.
  • xls: a stream whose last sector is not padded threw EndOfStreamException when read from a file, though it read fine from memory.
  • xls writer: a sheet spilling past 65,536 rows could produce two sheets with the same name.
  • xlsx: t="d" dates ignored the 1904 date system (2020-01-01 read as 2024-01-02), came back a day late before 1900-03-01, and were shifted into the machine's time zone when they had an offset.
  • writer: a number format with control characters or U+FFFE/U+FFFF produced an unreadable styles.xml; AddStyle now rejects it.
  • zip: with PrefetchDecompression, an entry inflating past its declared size skipped the CRC check.
  • cfb: a mini stream chain ending early returned zeros instead of an error.

Behavior changes worth knowing:

  • AddStyle throws ArgumentException for such number formats.
  • A t="d" date before the workbook epoch has no serial. It is now kept as text with CellType.Date, so TryGetDateTime works but TryGetDouble does not.
  • A t="d" value with a UTC offset keeps its clock time and drops the offset, as a Z value already did.

Benchmarks

New rows next to the existing class baselines, which stay as the default case:

  • CsvParallelParseBenchmark and LargeSheetParallelBenchmark parse CSV into a struct with InternStrings.
  • CsvParallelVsSepBenchmark adds a struct pair for both libraries, with string pooling on each side (InternStrings; Sep's SepToString.PoolPerCol() / PoolPerColThreadSafe()), so tuned is compared with tuned.
  • ParseBenchmark gains an InternStrings variant.

docs/performance/benchmarks.md is updated from a BenchmarkDotNet run (Short job; Sep's parallel struct rows rerun with Medium because the Short run spread too much). Highlights:

  • 4.3M conversion-heavy rows at dop 16: 396.0 ms and 680 MB as a class, 181.0 ms and 13.6 MB with struct + InternStrings, with no garbage collections. The struct rows do not slow down past dop 8 the way the class rows do.
  • Against Sep in parallel, with both sides tuned: ExcelReader 158.0 vs 263.1 ms on narrow ints, 121.8 vs 413.5 ms on the conversion-heavy corpus.
  • The doc notes that interning pays off because the corpus repeats 4 regions and 6 countries; columns of mostly distinct strings gain only from dropping the row object.

The write, cold-start and allocation figures in the Performance section come from console harnesses (interleaved runs on the Ryzen 7 5700X), not BenchmarkDotNet.

Checklist

  • dotnet build ExcelReader.slnx --configuration Release builds clean (warnings are errors)
  • dotnet test tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release passes
  • If this changes the public API: PublicAPI.Unshipped.txt updated (no managed API change; the C ABI struct is append-only)
  • Tests added/updated for the behavior change
  • One focused change — unrelated fixes are in a separate PR

Test plan

  • One regression test per bug fix, for example ReadsAVersion4CompoundFileWith4096ByteSectors, EnumeratingTheSameResultTwiceYieldsEveryRowBothTimes, IsoDateReadsBackTheSameDayInEitherDateSystem, ContinuationSheetSkipsANameAlreadyTaken and PrefetchChecksTheDeclaredBytesOfAnEntryThatInflatesPastItsSize.
  • FastestSegmentMatchesTheStreamingEncoder: the windowless segment encoder is byte-identical to the streaming one.
  • Native parallel writes are covered in the C++, Rust and Python test suites.
  • Performance was compared with old and new builds interleaved: cold start over 40 launches, steady state over 20 warm iterations on XLSX/XLSB at 1M rows and on 2,000 small-file opens.
  • BenchmarkDotNet: CsvParallelParseBenchmark, LargeSheetParallelBenchmark and CsvParallelVsSepBenchmark, and check-benchmark-groups.sh passes for the new methods.

GabrielMarquezMatte and others added 24 commits October 16, 2026 12:18
…tions

The struct grows by two appended fields. The 7.0 size (32) and a NULL
options pointer are still accepted and decode as serial, so existing
callers keep today's behavior. Negative values are rejected.
When xl_write_options asks for more than one degree of parallelism,
xl_write_typed and xl_write_typed_to_memory hand the data rows to the
core's parallel record writer. Styles and the header row are written
as before. XLS and CSV keep the serial loop.
xl::WriteOptions gains degree_of_parallelism (default 1, serial) and
batch_rows (default 0, library default).
WriteOptions gains degree_of_parallelism and batch_rows builders. Unset
means a serial write and the library's default batch size.
WriteOptions gains degree_of_parallelism and batch_rows. None means a
serial write and the library's default batch size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a degree of one, batching only added the cutting and sync flushes:
par1 ran 3-5% slower than the serial write. The parallel entry points now
hand a degree of one (or zero on a single processor) to the serial record
write, and still end the sheet when it throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each 64 KB chunk built its own string cache when InternStrings was on, and
every read of a file chunk went through RandomAccess.ReadAsync, allocating
a state machine box and an overlapped source. A worker now keeps one cache
across its chunks, and chunk reads are blocking positional reads on a
handle opened without FileOptions.Asynchronous.

4.3M rows, dop 16: 385 to 332 ms by default, 282 to 220 ms with
InternStrings, and no GCs left for struct rows with InternStrings.
9455837 reopened the chunk handle for synchronous I/O to drop the
per-read allocations. Windows serializes all I/O on a synchronous file
object, so with a cold cache the workers would have read one at a time.
The handle is overlapped again, and RangedFileStream.ReadAsync uses a
pooling builder instead of allocating a state machine box per read.

4.3M rows, dop 16, struct rows with InternStrings: 40 MB allocated before
9455837, 14 MB now.
Opening the first workbook of a process is dominated by JIT compilation.
Cut what the open path makes the JIT do:

- Huffman table building and the CRC finish no longer mix a loop with a
  stackalloc, which made the JIT skip tier 0 and compile them fully
  optimized on first call.
- Byte fills in inflate write 8 bytes at a time instead of calling
  Span<byte>.Fill, whose Vector<T> memset is never precompiled.
- The decoder rents its tables from ArrayPool<byte>, which ships
  precompiled, instead of instantiating ArrayPool<uint>.
- Duplicate ZIP entry names are found by comparing pairs for up to 64
  entries; the sort, with its comparer, moves to its own method and only
  larger directories reach it.

Cold open of a 200-row XLSX: 24.3 to 21.1 ms. Repeated small opens are
~1% faster and allocate 3520 B instead of 3712 B; large reads unchanged.
…the file

The FileStream was opened before the try block, and the export has no
handler of its own, so a missing directory or a locked file threw out of
an UnmanagedCallersOnly method and terminated the host process. The open
now sits inside the try and reports XL_ERROR with the IO message.
The chunk plan's cursor only moves forward and was shared by every
enumeration. A second await foreach found no chunks left, its workers
exited, and the merge waited for a chunk nobody would produce, holding
the reader's file open. Each enumeration now restarts the plan.
… sectors

WorkbookStream placed sector n at 512 + n * sectorSize, which only matches
the compound file layout when sectors are 512 bytes. A version 4 file has
4096-byte sectors and a header that fills a whole sector, so sector n
starts at (n + 1) * sectorSize, as CfbContainer already computes; the old
offset read from 3584 bytes too early.
An ISO date cell was stored as its OADate, which is the 1900 serial only
from 1900-03-01 on and never the 1904 one. A 1904 workbook read
2020-01-01 back as 2024-01-02, and 1900-01-01 came back a day late. The
serial now comes from ExcelEpoch for the workbook's date system; a date
before the epoch, which has no serial, keeps its text so TryGetDateTime
parses it directly.

A value with a UTC offset was converted to the machine's local time; it
now keeps the clock time as written, as a "Z" value already did.
A sheet that spills past 65,536 rows continues in "<name> (n)", but that
name was never claimed in the workbook's set of sheet names. A later
AddSheet could take it, and a sheet already named that way was not
avoided, so the file ended up with two sheets of the same name, which
Excel reports as damaged. The continuation now claims its name and moves
to the next number when it is taken.
A number format reached styles.xml through SecurityElement.Escape, which
leaves control characters and U+FFFE/U+FFFF in place, so a format such
as "0\u0001" produced a styles part that does not parse. XML 1.0 cannot
carry those characters even as references, so AddStyle now refuses them,
as AddSheet already does for sheet names.
The merge numbered a failing row from the models it had yielded, so
every blank record before the failure, which yields nothing, moved the
reported row up by one. Chunks now report how many records they read,
blank ones included, and the merge numbers from that.
With PrefetchDecompression the producer compared the CRC only when the
running total landed exactly on the declared size. Reads come in 64 KB
chunks, so an entry whose deflate data produced more than declared
skipped the check, and a consumer that stopped at the declared size took
damaged data. The CRC now covers exactly the declared bytes and is
compared when they are all in.
ReadMiniStream stopped at the end of the chain and returned the rest of
the stream as zeros, while the regular FAT path throws for the same
damage. It now throws InvalidDataException too.
Loading Workbook sectors from a stream always asked for whole sectors,
so a file that ends inside the stream's last sector threw
EndOfStreamException, while the same bytes opened from memory read
fine. The read now stops at the end of the stream.
@codecov-commenter

codecov-commenter commented Oct 19, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 95.13678% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.30%. Comparing base (d09b2f6) to head (35a7556).

Files with missing lines Patch % Lines
...Reader.Core/Reader/Xlsx/XlsxWorkbook.Enumerator.cs 94.11% 3 Missing and 3 partials ⚠️
...eader.Core/Writer/Parallel/ParallelRecordWriter.cs 50.00% 5 Missing and 1 partial ⚠️
...ExcelReader.Native/Reading/ReadApi.Rows.ForEach.cs 80.95% 3 Missing and 1 partial ⚠️
rust/excelreader/src/aggregate.rs 97.22% 2 Missing ⚠️
src/ExcelReader.Core/ExcelEpoch.cs 66.66% 1 Missing and 1 partial ⚠️
src/ExcelReader.Core/Writer/Xls/XlsSheetWriter.cs 66.66% 0 Missing and 2 partials ⚠️
...ExcelReader.Native/Csv/CsvAggregateApi.Workbook.cs 93.33% 1 Missing and 1 partial ⚠️
rust/excelreader/src/foreach.rs 97.56% 1 Missing ⚠️
rust/excelreader/src/rows.rs 98.88% 1 Missing ⚠️
...r.Core/Parser/ParallelCsv/ParallelCsvEnumerable.cs 91.66% 0 Missing and 1 partial ⚠️
... and 5 more
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #148      +/-   ##
==========================================
+ Coverage   91.06%   91.30%   +0.23%     
==========================================
  Files         252      276      +24     
  Lines       17501    21232    +3731     
  Branches     3162     3233      +71     
==========================================
+ Hits        15938    19385    +3447     
- Misses       1072     1352     +280     
- Partials      491      495       +4     
Flag Coverage Δ
csharp 91.31% <93.18%> (?)
python 89.35% <100.00%> (?)
rust 92.21% <98.35%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

.NET 10.0.13 fixes the NativeAOT crash that killed partitioned
aggregation on macOS arm64, so the native library is built with the
SDK that ships it and the Rust tests run on macOS again.
… comparing number formatting

Apple's libc++ may not ship floating-point from_chars, which every
translation unit including the header compiles through CellView. Guard
on __cpp_lib_to_chars and parse/format through the classic locale
otherwise; a second test target builds that path.

The for_each_row parity tests compared text() on both sides, so a
number-only cell checked the binding's formatting against the
library's. Compare the stored text instead; a generated XLSB with
1e20-scale numbers pins it.
…hars

Apple's libc++ leaves __cpp_lib_to_chars undefined, so macOS always took
the fallback, which printed the first %g precision that round-tripped:
1000 came out as "1e+03" and the rows test failed against to_chars.
Build the fewest round-tripping digits in scientific form, then pick
fixed unless scientific is shorter, printing integers exactly, as
to_chars does.
@GabrielMarquezMatte
GabrielMarquezMatte merged commit 0d952b7 into master Oct 20, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants