Skip to content

Halve sequential read-path allocations#303

Merged
chrisrichards merged 5 commits into
mainfrom
feature/read-path-performance
Jul 6, 2026
Merged

Halve sequential read-path allocations#303
chrisrichards merged 5 commits into
mainfrom
feature/read-path-performance

Conversation

@chrisrichards

Copy link
Copy Markdown
Member

Implements the three read-path optimizations identified while decomposing the ~2× sequential-read gap to Sylvan.Data.XBase (measured on tl_2019_01_place.dbf, 587 rows × 16 columns: the padded-then-trimmed double string per character field, intermediate ASCII strings for numeric parsing, and double boxing in IsDBNull + typed getters).

Changes

Character fields — trim before decoding (DbfValueString)
Padding is trimmed from the raw bytes and the right-sized result decoded once, instead of decoding the full field width and allocating a second trimmed string. Byte-level trimming applies only when 0x00/0x20 can never be part of a multi-byte sequence (single-byte encodings and UTF-8); other encodings keep the decode-then-trim path. Trim order matches the previous implementation exactly (NULs off both ends first, then spaces per StringTrimmingOption), pinned by parity tests against the old algorithm across cp1252/UTF-8/UTF-16.

Numeric and date fields — parse from the buffer (AsciiFieldParser)
DbfValueDecimal/DbfValueInt/DbfValueInt64/DbfValueFloat/DbfValueDate parse via stackalloc char spans with the same NumberStyles/format arguments as before, eliminating one string allocation per numeric field per row. Bytes above 0x7f decode to '?' exactly as Encoding.ASCII.GetString did, so malformed content fails to parse the same way (covered by tests, including overflow, exponents, * fill, and high-byte content).

Null checks and typed getters — no boxing (IDbfValue.IsNull, DbfRecord)
IsDBNull previously boxed every value it inspected via GetValue(), and typed getters boxed the same value a second time inside GetValue<T>. IDbfValue gains an IsNull property (note: minor source-breaking addition for external IDbfValue implementors), and the typed getters read through DbfValue<T?> directly. DbfQueryDataReader.IsDBNull routes live rows through the same path.

One behavioral fix rides along: GetBoolean on a null cell now throws SqlNullValueException like every other typed getter (previously NullReferenceException from unboxing null).

Measurements

Release build, min-of-5 interleaved Stopwatch rounds, full read of every field of tl_2019_01_place.dbf (Apple M3 Max; same-session before/after):

Scenario v2.1.0 this PR Sylvan 0.1.3
benchmark loop (Read + IsDBNull + field access) 588 µs / 675 KB 538 µs / 327 KB 297 µs / 330 KB
Read() only (full-row parse) 455 µs / 620 KB 413 µs / 299 KB
Read + IsDBNull every field 497 µs / 648 KB 443 µs / 299 KB (null checks now allocation-free)
fully typed getters 514 µs / 299 KB

Allocations on the comparative benchmark drop 675 KB → 327 KB (−52%), reaching parity with Sylvan; wall-clock improves ~9% (1.93× → 1.81× vs Sylvan). The remaining time gap is structural — eager whole-row parsing with per-field virtual dispatch vs Sylvan's lazy decode-on-access — and is tracked separately (#296 covers the selective-column side). A typed consumer now reads rows with zero allocation beyond the parsed strings themselves.

All 480 tests pass; 41 new tests pin trimming parity, span-parse parity, IsNull, and typed-getter/IsDBNull consistency across three fixtures.

🤖 Generated with Claude Code

chrisrichards and others added 5 commits July 6, 2026 16:37
The repository fixtures are a handful of rows, so index benchmarks
need generated data: BenchmarkTableGenerator writes a 50,000-row DBF
plus a matching compound index (two tags: unique integer IDs and
duplicated character codes) using the simplest structure the
library's strict CDX reader accepts - uncompressed leaf entries,
max-key interior entries, and a two-tag directory. Generated files
are verified for index/scan result equivalence through the public
API before anything is measured.

IndexQueryBenchmarks pairs the same SQL with UseIndexes on and off:
equality seek 459x faster (50us vs 23ms), range scan 233x, character
seek 141x, top-10 descending order 20x, filtered COUNT(*) 24x, and
the whole-table COUNT(*) status scan cuts allocations from 16.8MB to
10KB. Results recorded in benchmarks.md.

The benchmarks project now references the published DbfDataReader
2.1.0 package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
….0 results

Recreates the Sylvan / NDbf / DbfDataReader comparison in the style
of MarkPflug/Benchmarks (reading every field of every record of the
tl_2019_01_place census fixture), with the same method names as the
historical v0.5.x tables. The DbfDataReader package version is now
an MSBuild property (-p:DbfDataReaderVersion=x.y.z) so any release
can be benchmarked; the 2.x-only index benchmarks are excluded from
compilation for 1.x versions. A project README documents the
commands.

benchmarks.md gains v1.1.0 and v2.1.0 comparison sections: the
sequential read path is unchanged between the two (~1.15x NDbf,
identical allocations), confirming the SQL and index features are
additive, and both improve markedly on the v0.5.x-era ratios.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cuts per-row allocations on the sequential read path by half, from three
sources measured on the tl_2019_01_place benchmark fixture:

- Character fields: trim padding from the raw bytes before decoding, so
  each field allocates one right-sized string instead of a full-width
  string plus a trimmed copy. Byte-level trimming only applies to
  single-byte and UTF-8 encodings, where 0x00/0x20 can never be part of
  a multi-byte sequence; other encodings keep the decode-then-trim path.
- Numeric and date fields: parse straight from the record buffer via
  stackalloc char spans (AsciiFieldParser) instead of allocating an
  intermediate ASCII string per field. Bytes above 0x7f decode to '?'
  exactly as Encoding.ASCII.GetString did, preserving parse results.
- Null checks and typed getters: IDbfValue gains IsNull, so IsDBNull no
  longer boxes the value it inspects, and the typed getters read through
  DbfValue<T?> directly instead of boxing in GetValue() and unboxing.
  GetBoolean on a null cell now throws SqlNullValueException like every
  other typed getter (previously NullReferenceException).

Full-row read of the 587-row/16-column fixture: 620 KB -> 299 KB
allocated, 455 us -> 413 us. The benchmark loop (read + IsDBNull + field
access) drops from 675 KB to 327 KB, and a fully typed consumer reads
rows with zero allocation beyond the parsed strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dates parse with the invariant culture (the field is fixed-format ASCII
digits, so the current culture's calendar should never influence it),
and GetBoxedValue is static.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Jul 6, 2026

Copy link
Copy Markdown

@chrisrichards chrisrichards merged commit a05c2b9 into main Jul 6, 2026
3 checks passed
@chrisrichards chrisrichards deleted the feature/read-path-performance branch July 6, 2026 16:49
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.

1 participant