Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ errors to stderr, so `convert` is safe to pipe.
- Each ZIP entry of an XLSX or XLSB package is checked against its CRC-32 as it is read, so a damaged file throws `InvalidDataException` instead of returning wrong cells. `ExcelReaderOptions.VerifyZipChecksums = false` turns the check off.
- The XLSX writer emits a compact workbook with strings, numbers, booleans, dates, and blank cells; shared strings are opt-in.
- The XLSB writer emits BIFF12 workbook parts inside the standard XLSB ZIP package; shared strings are opt-in.
- `WriteRecordsParallelAsync` and `WriteTableParallelAsync` format and compress batches of records on several cores and write them in order, for XLSX and XLSB. On a Ryzen 7 5700X a 1,000,000-row sheet writes in 50 ms as XLSX with 4 batches in flight and 36 ms with 8, against 187 ms serially; XLSB takes 41 ms and 28 ms against 124 ms. Memory stays at about 3 MB per batch in flight. See [Write records in parallel](docs/guide/writing.md#write-records-in-parallel-xlsxxlsb) and the [benchmarks](docs/performance/benchmarks.md#parallel-record-writes).
- `WriteRecordsParallelAsync` and `WriteTableParallelAsync` format and compress batches of records on several cores and write them in order, for XLSX and XLSB. On a Ryzen 7 5700X a 1,000,000-row sheet writes in 46 ms as XLSX with 4 batches in flight and 31 ms with 8, against 172 ms serially; XLSB takes 31 ms and 21 ms against 115 ms. Memory stays at about 3 MB per batch in flight. See [Write records in parallel](docs/guide/writing.md#write-records-in-parallel-xlsxxlsb) and the [benchmarks](docs/performance/benchmarks.md#parallel-record-writes).
- The XLS writer buffers records in memory and assembles the OLE container at `EndAsync`; choose it when write throughput matters more than peak allocation.
- `Excel.EncryptPackage`/`EncryptPackageAsync` wrap a written XLSX/XLSB package in agile ECMA-376 encryption; see [Encrypted workbooks](docs/guide/encryption.md).

Expand Down
58 changes: 29 additions & 29 deletions cpp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,15 +413,15 @@ on Windows 10 (22H2), 16 logical CPUs @ 3.39 GHz, MSVC 19.51 (Release), Google B

| Benchmark | RealExcel.xlsb (100 rows) | 65K_Records_Data.xlsb (65,535 rows) |
|---|---:|---:|
| `open` (from memory) | 24.0 µs | 1.17 ms |
| `parse` (4 or 6 bound columns) | 47.8 µs | 24.7 ms |
| `parse_batches`, batches of 1,000 / 10,000 rows | - | 23.8 ms / 23.8 ms |
| `infer_schema` (sample 100 / 1,000 rows) | 75.6 µs | 579.1 µs |
| `open` (from memory) | 23.4 µs | 1.17 ms |
| `parse` (4 or 6 bound columns) | 44.8 µs | 23.3 ms |
| `parse_batches`, batches of 1,000 / 10,000 rows | - | 22.4 ms / 22.9 ms |
| `infer_schema` (sample 100 / 1,000 rows) | 68.8 µs | 572.7 µs |

`open` here is the byte-span overload, which copies the caller's buffer before returning (the caller may free
it afterwards), so it scales with file size: 1.17 ms is mostly copying the 3.7 MB fixture. Opening by
path reads only the package directory and stays nearly flat — see the Rust suite below.
`parse` scales linearly with rows × columns, at roughly 380 ns/row here. Draining the same
`parse` scales linearly with rows × columns, at roughly 360 ns/row here. Draining the same
sheet through the batched typed reader costs the same as one whole-sheet `parse` while holding
only one batch in memory at a time. `infer_schema` scales with its sample size, not the file's total
row count.
Expand All @@ -436,12 +436,12 @@ sides decode every cell into an owned value (`std::string` for text, not the zer

| Library | Mean |
|---|---:|
| ExcelReader (`parse<FullRow>`) | 62.9 ms |
| DuckDB (`read_xlsx`, summing every column in SQL) | 410.8 ms |
| xlsxio (`xlsxioread_sheet_next_cell_*`) | 512.9 ms |
| xlnt (`worksheet::rows()` + `cell::to_string()`/`value<double>()`) | 2,394.1 ms |
| ExcelReader (`parse<FullRow>`) | 61.3 ms |
| DuckDB (`read_xlsx`, summing every column in SQL) | 413.8 ms |
| xlsxio (`xlsxioread_sheet_next_cell_*`) | 497.4 ms |
| xlnt (`worksheet::rows()` + `cell::to_string()`/`value<double>()`) | 2,335.0 ms |

ExcelReader is ~6.5x faster than DuckDB, ~8.2x faster than xlsxio and ~38x faster than xlnt on this
ExcelReader is ~6.7x faster than DuckDB, ~8.1x faster than xlsxio and ~38x faster than xlnt on this
workload. xlsxio is a
lean, purpose-built C streaming reader — the same abstraction level as ExcelReader's own native
core — so it was the strongest of the two competitors tested, though still well behind.
Expand Down Expand Up @@ -481,11 +481,11 @@ Measured on the same machine as above, GCC 16.2.0 (MSYS2 UCRT64, `-O3 -mavx2` fo

| Workload | ExcelReader | zsv `compat` | zsv `fast` |
|---|---:|---:|---:|
| Typed, 14 columns into `FullRow` | 27.2 ms | 23.4 ms | 20.4 ms |
| Cells, byte count | 11.8 ms | 6.5 ms | 3.6 ms |
| Typed, 14 columns into `FullRow` | 27.4 ms | 22.6 ms | 20.0 ms |
| Cells, byte count | 11.6 ms | 5.8 ms | 3.5 ms |

**zsv is faster on both.** Typed, it is ~1.2x (`compat`) and ~1.3x (`fast`) faster than ExcelReader.
Counting cells, it is ~1.8x and ~3.3x faster.
**zsv is faster on both.** Typed, it is ~1.2x (`compat`) and ~1.4x (`fast`) faster than ExcelReader.
Counting cells, it is ~2.0x and ~3.3x faster.

The cells gap is the C ABI, not the CSV parser. `xl_rows_next_view` copies every cell's value into a
native row the cursor owns, one call per row, and that alone costs ~10.6 ms here (calling it without
Expand Down Expand Up @@ -522,11 +522,11 @@ is handed buffers that are already columnar, while `BM_WriteSheet` starts from a

| Benchmark | Time | Rows/s |
|---|---:|---:|
| `BM_WriteColumns` (pre-transposed) | 45.8 ms | 1.43 M/s |
| `BM_WriteSheet` (from `std::vector<Row>`) | 52.2 ms | 1.27 M/s |
| `BM_WriteColumns` (pre-transposed) | 33.2 ms | 1.98 M/s |
| `BM_WriteSheet` (from `std::vector<Row>`) | 40.3 ms | 1.63 M/s |

The transpose costs ~14% here. It is not free, but it is not the dominant cost of producing the
file — see the comparison below, where the same two cases over 14 columns land within ~12%.
The transpose costs ~21% here. It is not free, but it is not the dominant cost of producing the
file — see the comparison below, where the same two cases over 14 columns land within ~14–16%.

`excelreader_cpp_write_compare_benchmarks` (under `-DEXCELREADER_BUILD_BENCHMARKS_COMPARE=ON`) puts
that against xlnt, xlsxio and [DuckDB](https://github.com/duckdb/duckdb)'s `excel` extension, all
Expand All @@ -543,12 +543,12 @@ competitor.

| Library | Wall | CPU |
|---|---:|---:|
| ExcelReader (`xl::write_columns`, pre-transposed) | 85.4–86.5 ms | 85.1–87.1 ms |
| ExcelReader (`xl::write_sheet<FullRow>`) | 95.5–96.5 ms | 96.0–98.2 ms |
| DuckDB (`COPY ... TO ... WITH (FORMAT xlsx)`) | 828.9–833.2 ms | 828.1 ms |
| libxlsxwriter (`worksheet_write_string`/`_number`) | 1,193.9 ms | 1,203.1 ms |
| xlsxio (`xlsxiowrite_add_cell_*`) | 2,433.2 ms | 953.1 ms |
| xlnt (`worksheet::cell().value()` + `save()`) | 5,436.3 ms | 5,437.5 ms |
| ExcelReader (`xl::write_columns`, pre-transposed) | 64.2–66.9 ms | 63.9–68.2 ms |
| ExcelReader (`xl::write_sheet<FullRow>`) | 74.8–76.0 ms | 74.7–76.4 ms |
| DuckDB (`COPY ... TO ... WITH (FORMAT xlsx)`) | 831.4–831.6 ms | 828.1 ms |
| libxlsxwriter (`worksheet_write_string`/`_number`) | 1,206.7 ms | 1,187.5 ms |
| xlsxio (`xlsxiowrite_add_cell_*`) | 2,405.1 ms | 1,125.0 ms |
| xlnt (`worksheet::cell().value()` + `save()`) | 5,525.3 ms | 5,515.6 ms |

Same machine as above; Google Benchmark's own iteration counts, no `--benchmark_repetitions` (each
iteration writes a whole 65,535-row file, so the slower cases run once or a handful of times). xlnt
Expand All @@ -558,8 +558,8 @@ DuckDB rows appear in both, and the small ranges above are those two independent
sampling within one run.

`write_sheet` — the matched-work number, since it starts from the same `std::vector<FullRow>` every
competitor is handed — is ~8.6–8.7x faster than DuckDB, ~12.4x faster than libxlsxwriter, ~25x
faster than xlsxio, and ~57x faster than xlnt on wall time.
competitor is handed — is ~11x faster than DuckDB, ~16x faster than libxlsxwriter, ~32x
faster than xlsxio, and ~74x faster than xlnt on wall time.

### Known issue: xlsxio + libxlsxwriter cannot share a binary

Expand Down Expand Up @@ -596,10 +596,10 @@ Caveats, none of them optional when quoting these:

- **xlsxio's CPU time is not stable across runs.** It read 843.8–906.3 ms before the fix (some
runs missing the workbook.xml entry), 1,171.9 ms and 687.5 ms in the two runs after it,
1,015.6 ms and 843.8 ms in the next two and 953.1 ms in the latest, while its wall time stayed at ~2.4 s throughout. Its wall time is dominated by I/O wait —
1,015.6 ms and 843.8 ms in the next two, 953.1 ms in the one after and 1,125.0 ms in the latest, while its wall time stayed at ~2.4 s throughout. Its wall time is dominated by I/O wait —
CPU is well under half of wall, against every other case here being CPU-bound (wall ≈ CPU) — so
quote the wall-time ratio, which is what a caller waits. Against this run's CPU time the gap from
`write_sheet` to xlsxio is ~9.9x, not ~25x.
`write_sheet` to xlsxio is ~15x, not ~32x.
- **ExcelReader does slightly more work here**, not less: it attaches a number format to the two
date columns so Excel shows a date, while every competitor case writes those as bare serial
numbers. That difference favours the competitors.
Expand All @@ -611,6 +611,6 @@ Caveats, none of them optional when quoting these:
a full analytical query engine doing far more than any Excel-writing library here; this measures
one narrow slice of it, not "DuckDB" as a whole.

The two ExcelReader cases land within ~12–22% of each other. The transpose's share grew as producing
The two ExcelReader cases land within ~14–16% of each other. The transpose's share grew as producing
the file got cheaper, but it is still a minority of the cost. Reach for
`write_columns` when your data is already columnar, but `write_sheet` is not the slow path.
4 changes: 2 additions & 2 deletions docs/guide/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ The in-memory path (`Excel.Open(ReadOnlyMemory<byte>)` and friends) has already
by the time it returns, so it always verifies regardless of this setting. Standard encryption has no
HMAC field, so the setting does not apply to it.

Opening an encrypted workbook costs a fixed ~26 ms for key derivation, whatever the file size. After
that, reading is ~10% slower than reading the same workbook unencrypted — see
Opening an encrypted workbook costs a fixed ~25 ms for key derivation, whatever the file size. After
that, reading is ~3% slower than reading the same workbook unencrypted — see
[Encrypted workbooks](../performance/benchmarks.md#encrypted-workbooks) in the benchmarks.

Writing an encrypted workbook is a second step: build the package with any writer, then wrap it with
Expand Down
22 changes: 11 additions & 11 deletions docs/guide/reading.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ A callback that writes to a database or sends messages must be idempotent on CSV

[`ExcelParseException.Row`](xref:ExcelReader.Core.Parser.ExcelParseException.Row) on `ParseAsync` counts the rows emitted so far. On `ForEachAsync` and `AggregateAsync` it is the exact row when the read is sequential and `0` when it was partitioned.

See the [parallel sheet benchmarks](../performance/benchmarks.md#parallel-sheet-reading) and the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. On a real-data XLSX the sheet stage drops from 29 ms (prefetched, sequential) to about 18 ms, and on XLSB from 16 ms to about 13 ms; the floor is inflate, which runs on one thread. CSV has no such floor: its `AggregateAsync` at dop 16 is ~2.8x faster than the typed path while allocating ~410x less, with zero garbage collections.
See the [parallel sheet benchmarks](../performance/benchmarks.md#parallel-sheet-reading) and the [parallel CSV benchmarks](../performance/benchmarks.md#parallel-csv) for what this buys. On a real-data XLSX the sheet stage drops from 28 ms (prefetched, sequential) to about 17 ms, and on XLSB from 17 ms to about 13 ms; the floor is inflate, which runs on one thread. CSV has no such floor: its `AggregateAsync` at dop 4 is ~1.8x faster than the typed path while allocating ~450x less, with zero garbage collections.

Properties need setters. A get-only property is skipped during binding and silently receives nothing.

Expand Down Expand Up @@ -355,16 +355,16 @@ Measured across both read benchmarks (see [Real data reads](../performance/bench

| Workload | Default | `PrefetchDecompression = true` | Gain |
|---|---:|---:|---:|
| XLSX, real data | 49.7 ms | 27.8 ms | 44% |
| XLSM, real data | 50.3 ms | 28.1 ms | 44% |
| XLSB, real data | 27.7 ms | 16.9 ms | 39% |
| XLSX, string-heavy | 41.2 ms | 27.4 ms | 34% |
| XLSB, string-heavy | 39.5 ms | 26.0 ms | 34% |

The gain tracks how much of a read is decompression rather than parsing, so it is largest
on XLSX and XLSM with numeric data (where inflate dominates). Allocations rise from the producer task and the
pooled decompression buffers — on the real-data corpus, roughly 8 KB to 25 KB for XLSX
and 7 KB to 16 KB for XLSB — and neither path triggers a garbage collection.
| XLSX, real data | 41.2 ms | 27.8 ms | 32% |
| XLSM, real data | 43.5 ms | 28.4 ms | 35% |
| XLSB, real data | 25.8 ms | 16.7 ms | 35% |
| XLSX, string-heavy | 33.1 ms | 23.0 ms | 31% |
| XLSB, string-heavy | 32.6 ms | 19.7 ms | 40% |

The gain tracks how much of a read is decompression rather than parsing, so it is smallest
on XLSX real data (about a third) and largest on string-heavy XLSB (40%). Allocations rise from the producer task and the
pooled decompression buffers — on the real-data corpus, roughly 7 KB to 24 KB for XLSX
and 7 KB to 15 KB for XLSB — and neither path triggers a garbage collection.

Do **not** enable it for concurrent server workloads: a caller already reading many files
in parallel is CPU-saturated, and an extra background thread per read only doubles thread
Expand Down
19 changes: 10 additions & 9 deletions docs/guide/writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ Writing a 50,000-row workbook (`WriteBenchmark`, both figures from one run):

| Workload | Default | `PrefetchWrite = true` | Gain |
|---|---:|---:|---:|
| XLSX | 8.337 ms | 5.175 ms | 38% |
| XLSB | 5.572 ms | 3.419 ms | 39% |
| XLSX | 8.527 ms | 5.245 ms | 38% |
| XLSB | 5.786 ms | 3.234 ms | 44% |

Allocations are unchanged (4.02 MB either way) — the background writer hands over buffers the row
writer already owns rather than copying them.
Expand Down Expand Up @@ -324,16 +324,17 @@ defaults each batch in flight holds about 3 MB (its formatted rows, the compress
compressed result), whatever the size of the sheet.

Writing 1,000,000 rows of four columns (text, integer, date, number) to a `MemoryStream` at
`Fastest`, on a Ryzen 7 5700X, median of 9 runs:
`Fastest`, on a Ryzen 7 5700X (`ParallelRecordWriteBenchmark`):

| Write | XLSX | XLSB |
|---|---:|---:|
| Serial | 187.3 ms | 123.6 ms |
| Serial, `PrefetchWrite` | 117.8 ms | 67.6 ms |
| Parallel, 1 batch in flight | 172.7 ms | 120.6 ms |
| Parallel, 2 | 92.1 ms | 63.2 ms |
| Parallel, 4 | 49.9 ms | 40.9 ms |
| Parallel, 8 | 35.7 ms | 27.7 ms |
| Serial | 171.57 ms | 115.39 ms |
| Serial, `PrefetchWrite` | 104.92 ms | 60.02 ms |
| Parallel, 1 batch in flight | 165.22 ms | 107.81 ms |
| Parallel, 2 | 89.69 ms | 57.94 ms |
| Parallel, 4 | 46.29 ms | 30.62 ms |
| Parallel, 8 | 30.56 ms | 21.04 ms |
| Parallel, 16 | 27.93 ms | 18.25 ms |

The file comes out 0.18% (XLSX) or 0.04% (XLSB) larger, because each batch is compressed on its own.
As with `PrefetchWrite`, this is for single-file batch work: a server already writing many files at
Expand Down
Loading
Loading