Status: closed; retained as archived baseline and caller guidance Date: 2026-05-20 Closed: 2026-05-31 Last updated: 2026-10-15
This note is closed. It records the read-performance baseline for
AccessReader, the caller guidance that falls out of the measurements, and the
future API ideas that would need fresh workload evidence before becoming active
work. The previous implementation phases are complete, and there are no open
action items or active implementation candidates tracked here. Future
read-performance work should start from fresh profiling, release-quality
BenchmarkDotNet results, or a concrete workload report, not from reopening
already-settled optimization threads. The evidence-gated ideas near the end are
not a backlog.
Closed on 2026-05-31 after implementation, focused measurement, and the
RowDecodePlan consolidation closeout from the completed architecture
simplification sweep (working log closed and removed from the repository
2026-06-30; see git history for full detail). Keep this file
as the durable evidence record for the completed read-performance pass. If a
future workload feels slow, use the measurement commands and workload-shape
guidance below to produce new evidence before changing the core reader.
BenchmarkDotNet.Artifacts/results/JetDatabaseWriter.Benchmarks.Reader.AccessReaderRowDecodeBenchmarks-report-github.mdBenchmarkDotNet.Artifacts/results/JetDatabaseWriter.Benchmarks.AccessReaderOpenBenchmarks-report-github.mdbenchmarks/JetDatabaseWriter.Benchmarks/Reader/AccessReaderOwnedPageDiscoveryBenchmarks.csbenchmarks/JetDatabaseWriter.Benchmarks/Reader/AccessReaderTableScanReadAheadBenchmarks.csbenchmarks/JetDatabaseWriter.Benchmarks/Reader/AccessReaderReadAheadEligibilityBenchmarks.csbenchmarks/JetDatabaseWriter.Benchmarks/Reader/AccessReaderLargeLongValueBenchmarks.csDisabled (page reads through the reader’s I/O gate) and Auto: benchmarks/JetDatabaseWriter.Benchmarks/Reader/AccessReaderConcurrentScanBenchmarks.csQuery<T>().Include(...) over a related customer/order pair: benchmarks/JetDatabaseWriter.Benchmarks/Queries/QueryIncludeBenchmarks.csQuery<T>() (materializer cache lookup and predicate compilation, translation, the in-memory tail, index-ordered reads and inferred range seeks): benchmarks/JetDatabaseWriter.Benchmarks/Queries/QueryBenchmarks.csbenchmarks/JetDatabaseWriter.Benchmarks/Queries/QueryAggregateAllocationBenchmarks.cs compares Query_SumAsync_Large with a direct Rows<T> sum over the same entities. Total allocations include one materialized entity per row on both paths; compare the difference between those paths to check that the query aggregate adds constant overhead and no growing row buffer.GetAttachmentsAsync, Rows(), Rows<T>() with ComplexCellValue.ReadAttachments), writer-authored and Access-authored: benchmarks/JetDatabaseWriter.Benchmarks/Reader/ComplexColumnReadBenchmarks.csSeekRowsAsync, FromIndex, inferred Rows<T>(predicate)) against a client-side scan: benchmarks/JetDatabaseWriter.Benchmarks/Indexes/PublicSeekBenchmarks.csbenchmarks/JetDatabaseWriter.Benchmarks/Reader/AccessReaderColdScanBenchmarks.csbenchmarks/JetDatabaseWriter.Benchmarks/Infrastructure/SyntheticDatabases.cssrc/JetDatabaseWriter/Tables/TableReader.cs (table scans), src/JetDatabaseWriter/ValueDecoding/RowDecoder.cs (row decode), and src/JetDatabaseWriter/Pages/ReaderPageCache.cs (page and row-directory frame cache)src/JetDatabaseWriter/Pages/Paging/PageFile.cs (the writer’s Pager.cs beside it); owned pages and row directories: src/JetDatabaseWriter/Pages/OwnedDataPages.cs and DataPageRows.cs; text decode helpers: src/JetDatabaseWriter/Schema/JetTypeInfo.cssrc/JetDatabaseWriter/ValueDecoding/LongValueDecoder.cs plus shared LVAL chain traversal in src/JetDatabaseWriter/LongValues/LongValueStore.csThe benchmark suite has 214 expanded cases. Keep one canonical workload for each operation, shape and cache regime, and retain separate cases when they isolate a specific decoder, public API, startup cost or contention behavior.
AccessReaderTableScanReadAheadBenchmarks owns default untyped numeric, text and
wide scans, including warm scans and cold-open first scans. RowDecode adds
typed/string/DataTable materializers, nondefault caches and isolated long-value
branches. The legacy reader class retains the explicit Access-authored numeric
typed/untyped pair and catalog, metadata, statistics and bounded-read APIs.QueryAggregateAllocationBenchmarks, with
direct streaming controls at both 1,000 and 25,000 rows.WriterPageIoTests; the
original timing comparison is retained below. Use paired revision measurements
for future frame-cache changes.Cold OS-cache scans, read-ahead first-row latency, small-cache eligibility, concurrency scaling, encryption and each isolated MEMO/OLE branch remain distinct workloads. Case-count reduction alone does not establish a wall-clock speedup; compare hosted runs with the same filters and BenchmarkDotNet job.
Treat this as the stable read-path architecture unless new profiling or release-quality benchmark results justify reopening a specific area.
Rows(), Rows<T>(), and ReadTableAsync() decode through the typed
crack path. CrackRowTypedAsync calls TryCrackRowSync /
TryCrackRowSyncIntoBuffer, which delegate layout preflight and typed
fixed/variable slice decoding to RowDecodePlan, fill object?[] buffers
directly, and return a sync-completed ValueTask on rows that do not require
long-value resolution.Memo and Ole slots emit RowDecodePlan.LongValueRef during sync
cracking. The async wrapper resolves only the rows and cells that actually
contain long values.RowsAsStrings() keeps its compatibility semantics but now uses
RowDecodePlan.CreateStrings / TryDecodeStringRowAsync for row-layout
preflight and per-column string materialization instead of a separate row
parser.RowMapper<T>.Build caches an expression-tree
Func<object?[], T> that typed reads use when no direct page-to-POCO decoder
applies (and that index and linked-table reads always use); type mismatches flow through
Mapping/ValueCoercer. Include resolves the same compiled mapper once for
each related table after filtering matching keys, reusing the bounded shape
cache and propagating setter exceptions directly. The
ToRow / Accessor API remains available for writer-side mapping.
One LRU cache retains at most 64 immutable row shapes per entity type across
read, write and direct-decoder delegates. Shapes include ordered names, source
types, physical column layout and AutoNumber flags, so equivalent definitions
reuse delegates across opens while schema edits select a new shape. User
property getters run after the cache lock is released.IndexCursor.EnumerateRowLocationsForCriteriaAsync streams matches one leaf
at a time, letting Take and abandoned seeks stop further leaf reads. The
cursor detects repeated leaf pages and bounds its hops; writer maintenance
retains the collecting APIs.TableReader.EnumerateTableScanPagesAsync, including its read-ahead cancellation
and abandoned-read cleanup. Warm descriptor/count/miss reads need no page reads
while their entries remain cached; warm seeks also depend on their index/data
pages remaining in the configured page cache. Linked Access operations open a
fresh approved source for each request, so resolver authorization and source
lifetime remain explicit rather than being hidden by a host-side data cache.DirectRowDecoderBuilder.TryBuild<T> can emit a direct page-to-POCO delegate
for primitive projections. The compiled delegate still asks RowDecodePlan
to parse row layout and resolve column slices, so direct and fallback decode
paths share the same row-layout rules. It falls back to the typed crack path
for bound calculated columns, Memo / Ole LVAL columns, Binary,
Complex / Attachment, hyperlinks and type-mismatched targets. Complex
columns the type does not bind do not stop it, and neither path reads their
flat tables. Numeric direct-decodes for decimal targets using NumericScale.%TEMP%\JetBench\ by
SyntheticDatabases.cs: Numeric has 25K rows / 9 columns, TextHeavy has
25K rows / 6 columns, Wide has 10K rows / 40 columns, and Memos has 5K
rows with an integer plus MEMO payload. LargeLongValues has 30 rows whose
three 1.5M-character MEMOs (about 370 LVAL pages each) and three 2 MB OLE
values (about 490 pages each) are each longer than the default 256-page cache;
every other long-value fixture fits in the cache, which is how a cache-eviction
bug that dropped the rows after a large MEMO went unmeasured. The numeric
database also has an AccdbAgileCfb-encrypted copy. The relational
database has 1,000 Customers and 10,000 Orders (primary keys, a non-unique
OrderDate index, and the FK_Orders_Customers relationship), the query
database has a 6-row QuerySmall table and a 25K-row QueryLarge table
(integer primary key and a non-unique Score index), and the attachment
database has 150 Documents rows with one 16 KB attachment each. The
relational and attachment databases are smaller than planned. Both were cut
while a full index rebuild that spread an index over more than one INLINE
usage-map window threw NotSupportedException, which creating the
relationship over 20,000 orders that already have two indexes did; the
rebuild now writes a REFERENCE usage map, so the relational database can
grow (PLAN.md). The attachment database stays small because
AddAttachmentAsync rebuilds the hidden flat table’s indexes on every call,
so filling it grows quadratically.OpenAsync floor is settled at roughly 1.1 ms / 41 KB. Do not spend
optimization time on lazy catalog loading or catalog span rewrites without new
measurements that contradict that floor.Focused BenchmarkDotNet ShortRun jobs were run after the implementation slices
on Windows 11, .NET SDK 10.0.300, .NET 10.0.8, BenchmarkDotNet 0.15.8, Intel
Core Ultra 7 268V. Treat these as engineering closeout measurements rather than
release-quality full-run numbers. A later 2026-05-30 ShortRun after the
RowsAsStrings() and direct-decoder RowDecodePlan consolidation stayed
neutral or better on the affected hot paths; the detailed ShortRun closeout
numbers lived in the now-removed architecture simplification working log (see
git history) and are not reproduced here.
| Area | ShortRun result | Decision |
|---|---|---|
| LVAL/MEMO decode | Decode_Memo_Untyped is 99.4 ms / 31.2 MB; Decode_Memo_Typed is 130.3 ms / 31.1 MB; Decode_Memo_DataTable is 179.1 ms / 31.7 MB. |
Allocation is materially lower than the historical 146-147 MB MEMO rows. No more LVAL allocation work is justified without a new profile. |
| Text decode | Decode_Text_Untyped is 14.3 ms / 4.6 MB, Decode_Text_Typed is 17.7 ms / 4.3 MB, and Decode_Text_AsStrings is 12.8 ms / 4.8 MB. |
The string.Create and Latin-1 changes achieved the intended allocation reduction. No further text decode change is pending. |
| DataTable strategies | Public numeric ReadTableAsync is 21.9 ms / 10.9 MB; Rows.Add(object?[]) and LoadDataRow are about 21.4 ms but allocate 13.2 MB. Text alternatives are close: public 15.7 ms, Rows.Add(object?[]) 13.7 ms, LoadDataRow 14.9 ms. |
Keep production on the current NewRow path with BeginLoadData and MinimumCapacity; alternatives are not enough better to trade away conservative semantics. |
| Owned-page discovery | Recognized per-table usage maps are about 2.3 ms for cold first-row/full-scan; forced whole-file fallback is about 15.7-15.8 ms on the same large-file shape. | Recognized maps avoid the O(total file pages) cold-start path. Keep the whole-file scan as a safety fallback for unfamiliar or invalid maps. |
| Table-scan read-ahead | Warm full scans improve when page-read optimization is enabled: numeric 10.5 ms to 8.7 ms, text 8.8 ms to 6.9 ms, wide 19.0 ms to 16.8 ms. Cold first-row latency does not improve. | Keep the one-page read-ahead as an automatic but narrowly guarded throughput benefit with opt-out; do not add tunable depth or LVAL-heavy read-ahead now. |
| Read-ahead eligibility (2026-10-02, Arm64, .NET 10.0.12, in-process ShortRun, two interleaved before/after runs) | Warm Auto scans of the 25K-row numeric table: page cache disabled 9.9-10.3 ms before, 8.6-8.7 ms after read-ahead was allowed; 2-page cache 10.4-10.5 ms before, 8.7-8.9 ms after; 256-page cache unchanged at 8.8-9.0 ms. A 5,000-row MEMO table (62-73 ms) and a 2,000-row single-page OLE table (22.5-26 ms) moved by less than the run-to-run noise when read-ahead was allowed, at every cache size. |
Allow read-ahead at any page-cache size, including none. Keep MEMO, OLE, complex and attachment tables sequential: the cache-ownership hazard behind that exclusion is gone, but it measured no gain. |
| Long values longer than the cache (2026-10-03, Arm64, .NET 10.0.12, in-process ShortRun on a shared machine) | AccessReaderLargeLongValueBenchmarks: a warm Rows() scan of the 30 LargeLongValues rows took 45-54 ms and allocated 30 MB at page-cache sizes 0, 8 and 256, in both Disabled and Auto. Rows<T>() was within the noise of Rows(), and opening a fresh reader for the scan added 22-30 ms. Every case returned all 30 rows with every value at full length. |
No change. Setup checks the row count and value lengths, so a return of the evicted-page bug shows as an NA row rather than a fast result. Auto and Disabled were within this run’s noise of each other on these values. |
| Include (same run) | QueryIncludeBenchmarks over 1,000 customers and 10,000 orders: the collection include took 52.7 ms and 18 MB, the same include filtered, ordered and capped at five orders per customer 51.9 ms, and the reference include (each order’s customer) 9.3 ms and 4 MB. |
The collection include costs 5-6 times the reference include over the same rows; filtering per parent runs after the load, so it saves nothing. Profile the collection path before changing it. |
| Attachment reads (same run) | ComplexColumnReadBenchmarks: 150 writer-authored 16 KB attachments took 14.7 ms through GetAttachmentsAsync, 18.0 ms through a Rows() scan and 21.0 ms through Rows<T>() plus ComplexCellValue.ReadAttachments. The 16 Access-authored Northwind category images took 8.4, 10.5 and 10.7 ms. |
No change. Reading attachments through the parent row costs 23-43% more than reading the flat table directly. |
| Public seeks (same run) | PublicSeekBenchmarks over the 10,000 orders: 1,000 primary-key lookups took 38.7 ms (15 MB) through SeekRowsAsync and 31.9 ms through FromIndex(...).WhereEquals, against 2.9 ms for one Rows<T>() scan that keeps the same 1,000 keys. A 250-row WhereBetween on the OrderDate index took 0.47 ms. The same 1,000 lookups as Rows<T>(o => o.OrderId == key) took 525 ms and 58 MB; each call compiles its predicate (Expression.Compile) and lists the table’s indexes before it seeks. |
A seek costs about 32-39 µs and 15 KB, so one full scan of this table beats about 75 single-key seeks. The inferred Rows<T>(predicate) seek costs about 0.5 ms more per call than an explicit one; profile its per-call setup before recommending it in loops. |
| Page I/O handle (2026-10-03, Arm64, .NET 10.0.12, A/B of Release builds in interleaved processes on a shared machine) | Opening path-opened readers with a synchronous handle and no access hint, instead of an overlapped handle with the RandomAccess or SequentialScan hint, took a warm page read from 15-19 µs to 8-10 µs, warm MEMO scans from 97-124 ms to 67-76 ms and OLE scans from 42-58 ms to 36-46 ms, and the open and first scan of an uncached copy of the OLE table from 2.3-3.2 s to 0.18-0.66 s. |
Keep FileOptions.None for path-opened readers in every mode; see “Page I/O handle” below. Re-measure on x64 and slower storage before changing it. |
| Inline page reads (same day and method) | Reading pages on the calling pool thread instead of handing each read to another pool thread took a warm page read from 6-10 µs to 2.6-4.5 µs, warm MEMO scans from 76-90 ms to 43-58 ms and OLE scans from 40-43 ms to 20-30 ms, and was faster on the numeric table in every run. | Keep inline reads for path-opened readers on pool threads without a synchronization context; see “Inline reads on thread-pool threads”. |
| Concurrent scans (2026-10-03, after the two changes above; Arm64, .NET 10.0.12, in-process ShortRun on a shared machine) | AccessReaderConcurrentScanBenchmarks: N simultaneous Rows() scans of the 25K-row numeric table, whose 405 data pages are more than the default 256-page cache holds, plain and AES-encrypted. On one shared reader in Auto, whose RandomAccess page reads bypass the reader’s I/O gate: 7.0-7.5 ms at N=1, 8.9-10.2 ms at 2, 10.0-12.9 ms at 4 and 20.2-20.6 ms at 8. In Disabled, where every page read the cache misses waits for the gate: 7.6-7.7, 8.7-9.7, 13.6-15.3 and 23.0-24.7 ms. One reader per scan, in either mode: 6.9-7.8, 10.1-12.3, 19.4-24.9 and 34.2-37.4 ms, allocating up to 25% more. The AES-encrypted copy was within this run’s noise of the plain file. An earlier run, before the two changes above, used Auto only, so none of its page reads took the gate. |
A shared reader scales better on this shape in both modes: scans running in near lockstep hit pages another scan has just cached, while separate readers each read and decode every page. On a shared reader Disabled was 12-36% slower than Auto at 4 and 8 scans, against 3-9% at one scan, so the I/O gate costs something under contention, but a shared reader in Disabled still beat separate readers in either mode. The AES transform lock did not show. |
Hosted comparison 37286446401
ran baseline 4a2bfa1c6011409081b9806c7299c91b2bf83f8f then
fa0d38e2d79b489e6626aa459cffd173376ca1fb on the same Windows host (AMD EPYC 9V45, x86-64-v4) with
.NET SDK 10.0.401, .NET 10.0.12 and the default adaptive BenchmarkDotNet job.
The head reused the baseline-generated numeric fixture, so both read the same
25,000-row image. Error below is half the 99.9% confidence interval.
| Workload | Baseline mean ± Error | Head mean ± Error | Head / baseline | Allocated, baseline → head |
|---|---|---|---|---|
| Open Northwind | 261.31 ± 4.47 µs | 268.30 ± 5.33 µs | 1.03 | 54.79 → 55.03 KB |
| Numeric untyped | 4.92 ± 0.093 ms | 4.91 ± 0.095 ms | 1.00 | 7.89 → 7.89 MB |
| Numeric untyped, two passes | 8.28 ± 0.152 ms | 8.49 ± 0.152 ms | 1.02 | 11.82 → 11.82 MB |
This focused repeat found no reader regression outside the reported uncertainty.
An earlier comparison 37282934029
of the same baseline against 47426a7d8e5ad5673ee1f774110497ad6565739e
measured 9.613 ± 0.162 ms against 11.21 ± 0.222 ms for the one-pass numeric
scan (1.17), while open and two-pass ratios were 0.97 and 1.01. That difference
was larger than the first run’s errors but did not reproduce; no reader-path
optimization was made between the measurements. Keep both artifacts rather than
interpreting either host’s absolute throughput as a universal performance floor.
The eight-case paired run
compares 6de9c067 with 24ea953d, using the default adaptive job on one hosted
Windows runner. The focused repeat
compares 10b21a2a with the same baseline; its production and benchmark code is
identical to 6de9c067. Ratios are head mean divided by baseline mean.
| Case | First ratio | Focused repeat |
|---|---|---|
| Numeric untyped decode | 1.03 | 0.96 |
| Text untyped decode | 1.02 | — |
| Wide untyped decode | 1.03 | 1.01 |
| MEMO untyped decode | 0.99 | — |
| MEMO typed decode | 0.99 | — |
| Single insert | 0.89 | — |
| UpdateRows | 1.10 | 0.99 |
| DeleteRows | 1.03 | — |
Allocations stayed flat or fell slightly. The first run’s numeric, wide and
update results exceeded the 2% reader or 5% writer targets; the focused repeat
met them. In that repeat, numeric reads measured 7.26 ± 0.10 ms against
7.55 ± 0.14 ms, wide reads 12.47 ± 0.19 ms against 12.39 ± 0.15 ms, and updates
8.55 ± 0.16 ms against 8.61 ± 0.41 ms. Errors are BenchmarkDotNet’s half-widths
of its 99.9% confidence intervals. The slower first results were not reproduced,
so they do not justify an additional hot-path change. These comparisons do not
resolve the older typed-MEMO comparison against dfd16210 in PLAN.md.
Hosted run 37286441249
measured fa0d38e2d79b489e6626aa459cffd173376ca1fb with the default adaptive
BenchmarkDotNet 0.15.8 job on Windows 11, AMD EPYC 7763, .NET SDK 10.0.401 and
.NET 10.0.12. All 28 results are valid. This is a same-revision comparison of
PageCacheSize=0 and 256, with direct writes and automatic statement
transactions; the class did not exist at the branch point.
Each iteration copies a baseline image, opens the writer and resets counters
before measurement. The second Northwind insert also performs an unmeasured
warm-up insert on that same writer. The measured operation includes its commit;
disposal is outside measurement. The backing stream is a MemoryStream, so
these are operation and page-traffic baselines, not disk durability timings.
Northwind cases insert into a writer-created PageIo table in the large
Access-authored image, not an existing Access-authored user table.
Counts below are physical reads (distinct pages) for the final measured iteration. Writes and flushes are identical at both cache capacities. Bulk means one call inserting 999 rows; the other new-ACCDB cases operate on one row or add one column.
| Workload | Mode | Reads, cache 0 | Reads, cache 256 | Writes | Flushes |
|---|---|---|---|---|---|
| Bulk 999 | Direct | 3,114 (20) | 10 (10) | 2,026 | 2,026 |
| Single insert | Direct | 33 (9) | 9 (9) | 4 | 4 |
| Update | Direct | 71 (35) | 43 (35) | 4 | 4 |
| Delete | Direct | 28 (9) | 9 (9) | 4 | 4 |
| AddColumn | Direct | 180 (20) | 16 (16) | 53 | 53 |
| Northwind first insert | Direct | 421 (102) | 101 (101) | 7 | 7 |
| Northwind second insert | Direct | 11 (4) | 0 (0) | 4 | 4 |
| Bulk 999 | Auto-commit | 1,041 (10) | 10 (10) | 13 | 14 |
| Single insert | Auto-commit | 30 (9) | 9 (9) | 4 | 5 |
| Update | Auto-commit | 67 (35) | 42 (35) | 3 | 4 |
| Delete | Auto-commit | 25 (9) | 9 (9) | 4 | 5 |
| AddColumn | Auto-commit | 78 (16) | 16 (16) | 18 | 19 |
| Northwind first insert | Auto-commit | 416 (101) | 101 (101) | 4 | 5 |
| Northwind second insert | Auto-commit | 8 (4) | 4 (4) | 4 | 5 |
The cache cuts repeated physical reads, while write-through preserves write and flush counts. Whole-file scans intentionally bypass frames, so update counts can exceed their distinct-page count. Transaction transitions invalidate frames, which explains the remaining reads on a warmed auto-commit writer.
Selected timings are mean ± BenchmarkDotNet Error (half the 99.9% confidence
interval); complete timings and allocations are in the run’s benchmark-results
artifact. Many single-operation distributions were multimodal, so small timing
differences are not evidence of improvement.
| Workload / mode | Cache 0 | Cache 256 | Allocated, 0 → 256 |
|---|---|---|---|
| Bulk 999 / Direct | 20.887 ± 0.363 ms | 10.792 ± 1.521 ms | 6,148.63 → 14,255.09 KB |
| Bulk 999 / Auto-commit | 9.447 ± 1.403 ms | 12.062 ± 1.508 ms | 6,050.93 → 14,321.30 KB |
| AddColumn / Auto-commit | 2.883 ± 0.094 ms | 2.201 ± 0.038 ms | 1,080.16 → 1,431.87 KB |
| Northwind first / Direct | 7.173 ± 0.143 ms | 5.948 ± 0.084 ms | 25,592.63 → 26,030.47 KB |
| Northwind second / Direct | 200.2 ± 11.5 µs | 196.9 ± 9.8 µs | 41.95 → 58.16 KB |
| Northwind second / Auto-commit | 248.3 ± 9.3 µs | 285.2 ± 8.5 µs | 59.16 → 107.81 KB |
The additional frame copies increase allocations, particularly during bulk writes. This baseline supports the physical-read reduction; it does not show a uniform throughput or allocation improvement. Capacity zero remains available for workloads where frame maintenance outweighs repeated-read savings.
Typed table scans select a compiled plan from the bound JET column types and
CLR property mappings. The scalar decoder remains first; a hybrid plan adds
asynchronous resolution of bound OLE byte[] properties through the existing
exact long-value reader. Each row is complete before it is returned. The plan
has no table names, application models, payload signatures or definition
parsers. Other mappings keep the general projection decoder.
Parity regressions cover Jet3, Jet4 and ACE on both library target frameworks: nullable scalars, Currency, DateTime, GUID, bounded Text, Binary, lossless numeric widening, null/empty/inline/single-page/chained OLE, overflow and deleted rows, strict/lenient corrupt payloads, diagnostics, real I/O failures, resource budgets, cancellation and abandoned enumeration. Custom setters and Numeric retain the general mapper’s assignment and failure semantics. These are library-generated fixtures and targeted corruptions, not new native Access interoperability evidence.
The retained-snapshot benchmarks are workload evidence for this general engine path. They use 20,017 rows, bounded scalar metadata and an unbound 4 KiB Memo column. Sparse definitions contain 37-byte filters every seventh row and 120-byte sort definitions every tenth row. The large case retains a 4 KiB sort definition in every row plus the same sparse filters: 82,095,452 payload bytes (78.29 MiB). Setup verifies all metadata, exact payload bytes, complete unique row identities and matching stored row order before timing. A separate cold stream trace records physical page reads without instrumenting the timed scan.
Controlled hosted run 37586625782
completed successfully with the default adaptive BenchmarkDotNet job. Baseline
ee1fbcdd448e4771467919c6018c5014ca4865ca used the public projection fallback;
head f96c7cb5343b8b8a48beec185f0737eefc15da96 selected the candidate hybrid
plan. Both retained the same data. The six phases ran baseline, head, head,
baseline, head, head on one Windows 11 hosted runner (AMD EPYC 9V74, two physical
cores/four logical processors, .NET SDK 10.0.401, .NET 10.0.12, BenchmarkDotNet
0.15.8). Each phase used fresh benchmark processes. Every result had a valid
mean and allocation measurement.
Times below are milliseconds, mean ± BenchmarkDotNet Error (half the 99.9% confidence interval). Ratios are hybrid / fallback.
| Workload | Order | Fallback | Hybrid | Ratio | Allocated MiB, fallback → hybrid |
|---|---|---|---|---|---|
| Sparse definitions | Baseline first | 36.878 ± 0.691 | 26.982 ± 0.334 | 0.73 | 16.47 → 14.99 |
| Sparse definitions | Head first | 34.091 ± 0.631 | 27.101 ± 0.508 | 0.79 | 16.47 → 14.99 |
| Large sort definitions | Baseline first | 279.730 ± 5.340 | 273.628 ± 5.103 | 0.98 | 483.67 → 481.64 |
| Large sort definitions | Head first | 290.823 ± 5.780 | 283.529 ± 5.630 | 0.97 | 483.67 → 481.64 |
| Scalar-only numeric control | Baseline first | 8.873 ± 0.123 | 8.519 ± 0.128 | 0.96 | 4.87 → 4.87 |
| Scalar-only numeric control | Head first | 8.757 ± 0.090 | 8.675 ± 0.071 | 0.99 | 4.87 → 4.87 |
| Identical-head control | First | Second | Second / first |
|---|---|---|---|
| Sparse definitions | 27.501 ± 0.274 | 27.605 ± 0.370 | 1.00 |
| Large sort definitions | 271.761 ± 5.250 | 286.403 ± 5.870 | 1.05 |
| Scalar-only numeric | 8.766 ± 0.154 | 8.691 ± 0.057 | 0.99 |
The sparse gain is 20.5–26.8%, beyond both error intervals and the identical-head variation, with about 9% lower allocation. Large definitions show no regression; their 2–3% apparent gain is smaller than the 5.4% identical-head variation and is not claimed as a speedup. Scalar-only allocation is unchanged, and its small timing differences do not establish an improvement. These controls bound this run’s interpretation; they do not resolve all historical hosted-run noise.
Cold stream measurements were identical for fallback and hybrid:
| Workload | Retained definition bytes | Page reads | Bytes read | Unique pages |
|---|---|---|---|---|
| Sparse definitions | 346,060 | 2,948 | 12,075,008 | 2,483 |
| Large sort definitions | 82,095,452 | 41,089 | 168,300,544 | 40,570 |
Both paths returned all 20,017 rows and left the Notes column unbound. The optimization reduces scalar mapping work; it does not reduce the bytes required to materialize the bound definitions or perform another payload scan.
The MediaLibrary owner can adopt the reader revision and repeat alternating Release full-snapshot benchmarks with exact metadata/payload parity on large synthetic catalogs. Its bundled sample remains read-only. Engine measurements do not establish application-wide speedups; the pinned consumer requirement and decoder research define that separate adoption check.
The saved baseline artifact was run on Windows 11, .NET SDK 10.0.203, BenchmarkDotNet 0.15.8, Intel Core Ultra 7 268V. It remains useful as a rough before/after comparison, but the focused refresh above is the current decision point.
| Benchmark | Fixture | Mean | Allocated | Notes |
|---|---|---|---|---|
Decode_Memo_DataTable |
5,000 rows | 178.610 ms | 147.47 MB | Slowest measured baseline path. |
Decode_Memo_Untyped |
5,000 rows | 159.447 ms | 146.66 MB | Streaming was dominated by LVAL payload work. |
Decode_Memo_Typed |
5,000 rows | 157.329 ms | 146.63 MB | POCO mapping was not the bottleneck for MEMO rows. |
Decode_Text_DataTable |
25,000 rows | 56.684 ms | 21.18 MB | DataTable materialization roughly doubled text streaming time. |
Decode_Numeric_DataTable |
25,000 rows | 27.468 ms | 13.20 MB | DataTable overhead was visible even on fixed-width rows. |
Decode_Text_Untyped |
25,000 rows | 22.954 ms | 16.35 MB | Text allocation dominated ordinary text scans. |
Decode_Text_Typed |
25,000 rows | 24.778 ms | 15.63 MB | Similar to untyped because strings still had to be allocated. |
Decode_Wide_Untyped |
10,000 rows | 20.177 ms | 23.08 MB | Decoded all 40 columns. |
Decode_Wide_Typed_NarrowProjection |
10,000 rows | 12.917 ms | 1.75 MB | Projection optimization was already paying off. |
Decode_Numeric_Untyped |
25,000 rows | 9.886 ms | 8.26 MB | Fixed-width streaming baseline. |
Decode_Numeric_Typed |
25,000 rows | 12.028 ms | 3.54 MB | Lower allocation, modestly higher mean. |
OpenAsync is not the main large-read bottleneck in the current data:
Open_Northwind is 1.254 ms / 40.81 KB, and synthetic open benchmarks are in
the same range.
MEMO/OLE remains the largest special-case read cost, but the avoidable
allocation work has already been reduced. Raw-copy callers can stream the
declared payload to a staging stream without a payload-sized array. Materialized
external reads grow their buffer only after validated bytes arrive, so an
unreadable first row does not allocate its declared payload. Inline reads copy
their slice directly. Traversal uses inline cycle detection before allocating a
HashSet<uint>, reuses cached live-row bounds when locating LVAL rows, and avoids
async slow-path setup on cached LVAL page hits.
Remaining cost is mostly inherent: non-inline values require additional page
reads, text MEMO values must allocate the returned string, and OLE callers
must receive a stable byte[]. Reopen this area only with a new profile that
points to an avoidable LVAL-specific cost.
Primary code path:
LongValueDecoder.ReadLongValueAsyncLongValueDecoder.ReadLongValueRawBytesAsyncLongValueDecoder.CopyLongValueRawBytesAsyncLongValueDecoder.LocateLvalRowAsyncLongValueDecoder.DecodeLongValueLongValueStore.CopyPayloadAsyncDataTable remains materially more expensive than streaming APIs because it
forces full materialization, allocates DataRow instances, and assigns cells
through the general DataRow machinery. The public path now uses
BeginLoadData() / EndLoadData(), sets MinimumCapacity when bounded by table
row count or maxRows, and tracks loaded row count directly.
Keep the current NewRow path. Benchmark alternatives such as
Rows.Add(object?[]) and LoadDataRow are not enough better to justify changing
row-state and null-handling semantics.
Primary code path:
AccessReader.ReadTableAsyncDataTable.NewRow()DataRowDataTable.Rows.Add(newRow)Text-heavy scans still allocate final string instances, but transient decode
allocation is no longer the obvious target. Byte-array-backed Jet4 compressed
text decode uses string.Create for decompression paths, and modern target
frameworks use Encoding.Latin1.GetString for the all-compressed fast path.
Reopen this area only if a new profile shows an avoidable cost beyond required string materialization.
Primary code path:
JetFormat.DecodeJet4TextJetFormat.DecompressJet4JetFormat.CreateFromCompressedJetFormat.DecompressJet4SlowJetFormat.DecodeUtf16LEWide untyped scans still decode every column into a fresh object?[] per row.
The current optimization is to use Rows<T>() when callers can express a narrow
primitive projection; DirectRowDecoderBuilder.TryBuild<T> can then bypass much
of the object-array work for supported shapes.
Object-array consumers should still expect to pay for all requested columns. There is no pending change here without a new API shape or fresh profiling.
A few nanoseconds per column show on a 40-column row. The row decoder and
direct decoder receive JetFormat itself, and the decode loops read the
row-trailer field sizes once per row. This avoids a dependent load through
DatabaseFile for every format access; forwarding each access through the
composite had made Decode_Wide_Untyped 4-8% slower.
Primary code path:
TableReader.EnumerateTypedRowsAsyncRowDecoder.TryCrackRowSyncIntoBufferRowDecodePlan.ResolveColumnSliceForDirectDecodeDirectRowDecoderBuilder.TryBuildGetOwnedDataPagesAsync now attempts to use recognized per-table INLINE and
REFERENCE owned-page maps before falling back to the whole-file owner index.
Recognized maps scale with table pages instead of total database pages. The
fallback remains intentional for corrupt or unfamiliar usage-map shapes.
The full-file fallback uses uncached page reads and returns pooled pages immediately, so its classification pass does not churn the normal reader LRU before the actual table scan.
The reader memoizes each table’s owned pages whichever source gave them, so
it reads and validates a table’s map once even when it rejects the map.
Validating a map again means reading the TDEF page, the map and every page the
map lists before the index’s answer is taken: for the 25,000-row text table,
whose writer-built map listed only the 512 pages of its INLINE window when
this was measured, that cost 0.92-1.33 ms and 181 KB per warm FirstRow
call. The writer now promotes such a map to REFERENCE, so a table it builds
keeps every data page in its map and the map is no longer rejected.
Primary code path:
OwnedDataPages.GetOwnedDataPagesAsyncOwnedDataPages.BuildOwnedDataPageIndexAsyncReaderPageCache.ReadPageAsyncPageReadOptimizationMode controls whether path-opened readers of the net11.0
build read pages with positional RandomAccess reads and whether eligible
simple table scans use a conservative one-page read-ahead path. It does not
change how the file is opened (see “Page I/O handle” below). The default Auto
mode enables random-access reads for path-opened readers, but table-scan
read-ahead is more selective than
explicit Enabled: it requires a file-backed stream, no active transaction
journal, and enough table pages to benefit after the first page. Disabled
preserves the seek/read path and suppresses table-scan read-ahead. The read-
ahead path preserves row order and reuses the normal page cache.
Eligibility: no attached transaction journal, more than one data page for
explicit Enabled or at least three data pages for Auto, and no
MEMO/OLE/complex/attachment columns. Any page-cache size qualifies, including a
disabled cache. Caches under three pages and disabled caches used to be
excluded because the cache returned evicted buffers to the shared pool while a
scan still read them. Scoped leases now retain immutable frames through each
consumer; eviction cannot overwrite its page. The retained 2026-10-02 benchmark
measured a 12-17% eligibility gain on small or disabled caches with the earlier
GC-retained representation. The leased-frame representation still needs paired
hosted qualification (I7); that result is not a measurement of the new ownership
implementation. The long-value exclusion remains a workload policy: that
benchmark showed no gain on
MEMO or OLE tables, whose long-value page reads dwarf the one data page the
prefetch overlaps, so those scans stay sequential. In Auto, the first page is
yielded before prefetch begins to avoid adding speculative I/O to first-row
latency.
The two page reads in flight can complete, and decrypt, on two threads at once.
Native Jet3/Jet4 RC4 decryption keep no state between pages; the cached
AES-ECB transforms in PageDecryptionKeys are built and used under a private
lock (see concurrency-and-lock-ordering.md). When a path-opened reader’s scan
runs on a thread-pool thread, its prefetch completes inline instead; see
“Inline reads on thread-pool threads” below.
The same benchmark showed a separate cost: on the MEMO and OLE tables, Auto,
whose path-opened readers use positionless RandomAccess page reads, was about
20-30% slower than Disabled, which reads through the buffered FileStream,
with or without read-ahead. Its investigation (2026-10-03, same machine) found
that RandomAccess itself was not slower.
ReadPageRandomAccessAsync (then on DatabaseFile, now on PageFile) read FileStream.SafeFileHandle for
every page, and that getter is not a field read: it flushes the stream’s buffer
and seeks the OS file pointer to the stream’s position (a SetFilePointerEx
call) before it returns the handle, which cost 1.7-2.0 µs per call in
isolation. A long-value scan reads about one page per row (2,021 reads for the
2,000 OLE rows), so the getter showed there and not on the numeric table’s 405
reads, which read-ahead overlaps with decode. The handle is now read once, by
EnableRandomAccessPageReadsIfSupported, and every page read reuses it. In
that investigation’s 15-21 interleaved rounds of warm scans, Auto went from
111-138% of Disabled on the MEMO and OLE tables to 91-108%. A later replay of
the OLE scan’s 2,021 page reads, on the same machine while other builds ran,
could not separate the builds before and after this change: 15-19 µs per read
either way, in both modes. Both modes paid a larger cost, the file handle’s
type; see “Page I/O handle” below.
Keep this as automatic with opt-out. Do not add tunable depth or LVAL-heavy read-ahead without a fresh profile that shows a gain.
Primary code path:
AccessReaderOptions.PageReadOptimizationModeAccessReader.CreateStreamPageFile.EnableRandomAccessPageReadsIfSupportedPageFile.ReadPageAsyncTableReader.EnumerateTableScanPagesAsyncTableReader: Rows(), Rows<T>(), RowsAsStrings,
ReadTableAsync, ReadTableAsync<T>, ReadTableAsStringsAsync,
ReadFirstTableAsStringsAsync, and GetRealRowCountAsyncA path-opened reader opens its file with FileOptions.None in every
PageReadOptimizationMode: a synchronous handle, without
FILE_FLAG_OVERLAPPED, and with no RandomAccess or SequentialScan hint.
Before 2026-10-03 it opened an overlapped (FileOptions.Asynchronous) handle
with the RandomAccess hint in Auto and Enabled and the SequentialScan
hint in Disabled. Page reads stay asynchronous to the caller:
RandomAccess.ReadAsync (net11.0 build, Auto and Enabled) and
FileStream.ReadAsync (Disabled, and every mode of the netstandard2.1
build) run a synchronous read on a thread-pool thread, which is how .NET reads
every file on Unix.
On Windows an overlapped read completes through the I/O completion port and a
thread-pool callback even when the OS cache already holds the page; .NET does
not skip the completion port for reads that succeed at once, and 0-1% of the
overlapped reads were complete when awaited. The 2026-10-03 investigation
replayed a warm scan’s 4 KB page reads at 14.8-19.1 µs each on the overlapped
handle, 3.8-4.9 µs as RandomAccess.ReadAsync on a synchronous one and
2.8-3.6 µs as a blocking RandomAccess.Read. On files the OS had not cached,
the hints and the overlapped handle also defeated the OS read-ahead: 9,800
sequential 4 KB reads took 965-1,040 ms on the old handles, 654 ms on a
synchronous handle with SequentialScan, 584 ms with RandomAccess and 47 ms
with no hint. Why SequentialScan was slow there is not understood.
A/B of Release builds before and after the change (2026-10-03, Arm64, NVMe,
.NET 10.0.12, the benchmark databases, path-opened readers with default
options except where noted). Ranges are the medians of four interleaved
processes on a machine shared with other builds, and for the uncached copies
every run of three processes, Auto and Disabled together unless split:
| Case | Overlapped handle with a hint | Synchronous handle, no hint |
|---|---|---|
The OLE scan’s 2,021 page reads replayed through the page reader (PageFile.ReadPageAsync today), no page cache |
15.0-19.0 µs per read | 7.9-9.8 µs |
Warm Rows() of the 2,000-row OLE table |
42-58 ms | 36-46 ms |
Warm Rows() of the 5,000-row MEMO table |
97-124 ms | 67-76 ms |
Warm Rows() of the 25,000-row numeric table, Auto |
10.2-19.9 ms | 8.9-14.0 ms |
Open and first Rows() of an uncached copy, OLE table (includes the 25,228-page whole-file owned-page pass) |
2.3-3.2 s | 0.18-0.66 s |
Open and first Rows() of an uncached copy, numeric table |
64-404 ms | 54-176 ms (within the noise) |
Each page read now holds a pool thread while the OS reads, as FileStream
does by default; an overlapped read held none while the device worked. On
Windows the I/O manager serializes I/O on a synchronous file object, so the
read-ahead pair and concurrent scans on one reader queue their reads in the
kernel instead of overlapping them; they still overlap reads with decode. All
of this was measured on one Arm64 machine with local NVMe storage. Re-run
AccessReaderColdScanBenchmarks on CI runners with x64, a hard disk or a network
share before changing the options again; with many concurrent scans on slow
storage, open more readers. The writer still opens an overlapped handle with the
RandomAccess hint, which a separate, measured change would revisit.
A caller that opens the FileStream itself for
AccessReader.OpenAsync(Stream) gets the same benefit by opening it without
FileOptions.Asynchronous and without an access hint.
A path-opened reader also reads a page on the calling thread when that thread
is a thread-pool thread with no SynchronizationContext and the default
TaskScheduler (PageFile.ReadsInlineOnThreadPool). That is where most
reads start: the library awaits with ConfigureAwait(false), and console,
ASP.NET Core and worker-service callers have no context. Handing a read that
takes a few microseconds to another pool thread, and waiting for it, cost more
than the read. A caller with a context, such as a UI thread, still has every
read offloaded and never blocks on the disk. Readers opened on a caller’s
stream, and the writer, keep offloaded reads too, because their handles may be
overlapped, where a blocking read measured slower.
The trade-offs:
A/B of Release builds with offloaded reads and with inline reads, by the same
method as above (medians of four interleaved processes, Auto and Disabled
together):
| Case | Offloaded reads | Inline on pool threads |
|---|---|---|
The OLE scan’s 2,021 page reads replayed through the page reader (PageFile.ReadPageAsync today), no page cache |
6.1-9.8 µs per read | 2.6-4.5 µs |
Warm Rows() of the 2,000-row OLE table |
40-43 ms | 20-30 ms |
Warm Rows() of the 5,000-row MEMO table |
76-90 ms | 43-58 ms |
Warm Rows() of the 25,000-row numeric table |
12.9-21.8 ms | 9.1-18.4 ms, faster in every run |
Open and first Rows() of an uncached copy, OLE table (every run of three processes) |
0.18-1.1 s | 0.17-0.53 s |
The overlapped handle with a hint, in the same runs: 48-94 ms on the OLE table, 121-280 ms on the MEMO table, 14-74 ms on the numeric table and 2.2-27 s for the uncached OLE copy.
A warm table read reads nothing outside the page cache, so it does not matter
where it starts. The reader keeps each table’s structural TDEF image and immutable
layout after the first read (TableDefReader, which also serves index listings
and seeks from that image) and its owned pages, even
when it rejects the table’s usage map (OwnedDataPages), and the page cache
holds its data and index pages. So Rows() on a table without long-value,
complex or calculated columns returns the first row on the caller’s thread
without a read, and a repeated index listing or seek reads nothing either.
A read that cannot run inline is handed to the pool, and the caller waits to
be woken. One such read per call is enough for a warm FirstRow benchmark,
whose BenchmarkDotNet main thread is not a pool thread, to measure the
hand-off and the wake rather than the read. With one raw TDEF read per
Rows() call, the numeric table’s warm first row measured 6, 23-29 and
31-40 µs per call on three runners, and the 40-column table’s 29-60 µs, in
every read mode.
Primary code path:
AccessReader.CreateStreamAccessReader.OpenAsync(string, ...), which sets ReadsInlineOnThreadPoolPageFile.ReadPageAsyncPageFile.ReadPageRandomAccessAsync and ReadPageRandomAccessPageFile.ReadPageFromStreamTableDefReader.ReadTableDefAsync and OwnedDataPages.GetOwnedDataPagesAsync, which keep a warm read off the fileStart by checking the workload shape before changing the core reader:
OpenAsync. Current measurements put the open
floor around 1.1 ms / 41 KB, so large-read work should usually focus on row
scan shape, materialization, projection, long values, or filtering.DataTable
materialization, and owned-page discovery.Rows<T>() projectionsUse Rows<T>() with a DTO that binds only the columns needed by the caller.
ReadTableAsync<T>() materializes the same scan into a list, so it picks the
same decoder. The reader can emit a direct page-to-POCO decoder for primitive projections,
which avoids per-row object?[] allocation and primitive boxing. This is the
best available path for wide tables when the caller does not need every column.
The scalar direct path applies to supported natural CLR types and lossless
numeric widenings, including Binary. A hybrid plan also accepts bound OLE
byte[] properties: it assigns scalars directly and resolves exact long values
before yielding, without an object-array row or scalar boxing. It requires
auto-property setters and keeps Numeric, variable-slot primitives,
calculated, bound MEMO, Complex/Attachment and Hyperlink mappings on the
projection-aware fallback. Both typed table-scan APIs select this plan; index
seek decoding is unchanged. On a table with Attachment or
multi-value columns, both paths leave the complex columns the DTO does not bind
alone: their flat tables and attachment data are neither loaded nor decoded, so
a DTO of scalar columns reads only the table’s own data pages. Index seeks and
Rows<T>(predicate) read through an index do the same.
DataTable in hot pathsReadTableAsync, ReadAllTablesAsync, and string-typed DataTable APIs
are convenience and compatibility APIs. They fully materialize rows, allocate
DataRow instances, and assign every cell through DataRow machinery. Keep
them for UI binding, previews, exports, and compatibility layers; use streaming
for bulk processing.
Use GetRealRowCountAsync for accurate row counts instead of
Rows(...).CountAsync() when cell values are irrelevant. It still scans data
pages and checks each row’s layout, so it counts the same rows the reads
return, but it skips full row decode and long-value resolution.
Use SeekRowsAsync or FromIndex(...) (WhereEquals, WhereKeyPrefix,
WhereBetween, WhereRange) for indexed lookups instead of
Rows(...).Where(...) when the predicate matches an available Jet4/ACE index.
LINQ filters run after rows are decoded; an index seek starts from the B-tree.
Each seek has a fixed cost, though: on the 10,000-row PublicSeekBenchmarks
table, 1,000 single-key seeks took 32-39 ms while one full scan that kept the
same keys took 2.9 ms. For more than a few dozen keys, scan once and filter
against a HashSet. Rows<T>(predicate) infers the index but recompiles and
replans on every call (about 0.5 ms each), so prefer the explicit APIs in loops.
For tables with expensive long values, first scan only key, filter, and status
columns through a narrow DTO. Resolve MEMO/OLE payloads only for the rows that
survive the first pass. Public full-row APIs must return stable string and
byte[] values, so they cannot make those payloads lazy without a new API
shape.
For repeated full scans over simple tables, try:
var options = new AccessReaderOptions
{
PageCacheSize = 2048,
PageReadOptimizationMode = PageReadOptimizationMode.Enabled,
};
The default PageReadOptimizationMode.Auto enables the guarded read-ahead path
for file-backed scans with at least three data pages. Use Enabled only when a
caller wants to force the less conservative path after previously disabling it.
The path works at any page-cache size and skips tables with MEMO/OLE/complex/
attachment columns. It is most useful for warm full-scan throughput, not cold
first-row latency.
These are not active work items. They are possible API or benchmark directions only if a future workload provides evidence that the existing caller guidance is insufficient.
A public object-array projection API such as Rows(tableName, columns) or a
similar column-selection surface could expose projection benefits to callers
that cannot or do not want to define DTOs. Internally, RowDecodePlan already
supports a projection mask; today the public projection benefit is mainly
exposed through Rows<T>().
This would likely be the best next library feature if wide untyped scans become the remaining measured pain point.
A SeekRowsAsync<T> or projected seek API could let exact indexed lookups avoid
materializing full object[] rows. The current SeekRowsAsync narrows page
discovery through the index but then decodes complete rows for each hit.
This may be worthwhile if measured workloads perform many indexed point lookups and only consume a few columns from each match.
A new opt-in API could expose MEMO/OLE payloads through a lazy reader or handle
instead of immediately returning string / byte[]. This would require a
careful lifetime and page-buffer ownership model. It should not be mixed into the
existing row APIs because those APIs currently return stable values independent
of the reader’s internal page buffers.
Future read-performance work should begin with a benchmark that matches the slow real workload before changing the core decoder again. Useful comparisons:
Rows(...) versus narrow Rows<T>().Rows(...).Where(...) versus SeekRowsAsync for exact indexed predicates.Rows<T>() two-phase MEMO/OLE filtering versus full-row long-value decode.PageCacheSize plus explicit
PageReadOptimizationMode.Enabled.ReadTableAsync only when full materialization is
truly required.string creation.DataTable insertion strategy based only on the prior
Rows.Add(object?[]) or LoadDataRow benchmark results.OpenAsync floor.Run all future measurements on CI, including focused refreshes and single-case, short, or dry runs. Local builds and targeted correctness tests are allowed; see AGENTS.md.
After integrating and publishing main, dispatch the benchmark groups below.
Use -f job=default for release-quality adaptive measurements, and add
-f baseline="<published branch point SHA>" for a paired comparison. Verify the
run’s tested SHA and record its URL and completed verdict before claiming
benchmark acceptance.
gh workflow run benchmarks.yml --ref main -f filter="*AccessReaderRowDecodeBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*AccessReaderOwnedPageDiscoveryBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*AccessReaderTableScanReadAheadBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*AccessReaderReadAheadEligibilityBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*AccessReaderLargeLongValueBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*AccessReaderConcurrentScanBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*QueryIncludeBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*ComplexColumnReadBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*PublicSeekBenchmarks*" -f job=short
gh workflow run benchmarks.yml --ref main -f filter="*AccessReaderColdScanBenchmarks*" -f job=short
Summary decisions from the refresh: