docs: add a performance tuning section (#491) - #558
Conversation
Collects the tuning advice that was spread across the reference tables, and adds the parts that were not documented anywhere, in order of how much each one usually gives you. New material, all of it from the macro-benchmark suite: - Materialization path. QueryAsync<T> is the box-free path; MapTo<T> and the ADO accessors are not. 1,372 MB against 3,133 MB on a 1M-row, 105-column read. ORMs drive the ADO reader, so they get the boxed path. - Batch size, with a warning. Large batches used to be a pessimisation and are now the fastest option, so tuning done against 1.3.0 or earlier is now inverted. Small batches cost far more on Cloud than on local disk. - Compression by direction. Responses: compress. Requests: measure first, as it cost time at every distance measured up to 27 ms RTT. - Server GC, which is worth up to 49% on insert-heavy work and nothing at all on reads, with the pause-length trade-off stated. - Cold start decomposed into its three round trips, to show that no driver setting moves it and only connection reuse does. - How to measure without misleading yourself, since several of these results reverse between loopback and a real network. Also warns against GZipCompressor's default level. It constructs at CompressionLevel.Fastest, and the server ingests that stream slowly enough that a large insert can pass the 120 s HttpClient timeout. InsertOptions' own documentation steers proxy-bound users toward gzip, so the default constructor is easy to reach. Corrects the ReadBufferSize row in the settings table, which still documented the 8 KiB default and a large-object-heap warning. The default has been 64 KiB and pooled since #451. Co-Authored-By: Claude <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Assumes #499 (ADO typed column slots) lands before this. With it, the reader keeps storage of the column's own type, so GetInt32/GetInt64/GetGuid and the scalar GetFieldValue<T> read a field instead of unboxing. GetValue and GetValues still box, because they return object. That splits the ORM advice rather than flattening it: linq2db registers the typed accessors per column and so reads without boxing, while a path that goes through GetValue does not. Co-Authored-By: Claude <noreply@anthropic.com>
⚠ Now depends on #499 — do not merge before itUpdated the read-path guidance to assume #499 (ADO typed column slots) lands first. This makes the PR wrong if merged on its own. The section now tells readers that What changed in the section:
Also worth knowing for review#550 (stacked on #499) removes the last per-value allocations on UUID, array elements and |
There was a problem hiding this comment.
Pull request overview
Adds task-oriented performance tuning guidance for reads, inserts, compression, GC, buffering, and measurement.
Changes:
- Documents performance tradeoffs and benchmark results.
- Corrects
ReadBufferSizedocumentation. - Adds a changelog fragment.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
docs/overview.mdx |
Adds performance guidance and updates buffer settings. |
changelog.d/491-performance-tuning.docs.md |
Records the documentation update. |
Suppressed comments (2)
docs/overview.mdx:1441
- The current typed accessors delegate to
GetValue, so linq2db still consumes values thatRead()already boxed inCurrentRow. Claiming that typed-accessor ORMs are box-free contradicts the implementation and the PR description's note that #499 has not merged.
- **ORMs get the fast path when they use typed accessors.** linq2db registers `GetInt64`,
`GetDouble` and `GetDateTime` for each column, so it reads without boxing. Code that reads through
`GetValue` — including a `dynamic` result from Dapper — boxes each value. If an ORM query is hot
and reads through `GetValue`, use `QueryAsync<T>` for that one query.
docs/overview.mdx:1516
- The existing rough guide at
docs/overview.mdx:1015-1020recommendszstd:1for same-region inserts, but this table says compression costs 18% in that same deployment category and the at-a-glance table suggests disabling it. These opposing recommendations need to be reconciled or explicitly scoped to different bandwidth, codec, or parallelism conditions.
| Client to server | Effect of compression |
|---|---|
| Same host (loopback) | Costs 30% |
| Same cloud region | Costs 18% |
| One region away | Costs 4% |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Closes #491.
The issue asked for buffer size configs, compression codecs, POCOs vs boxed
object[], and skippingschema probes. Those knobs were each documented in their own reference table already, so rather than
repeat them this adds a task-oriented section that orders them by payoff, and fills in the parts that
were not written down anywhere.
What is new
Beyond the four items in the issue:
QueryAsync<T>is box-free;MapTo<T>and the ADO accessors are not. 1,372 MB against 3,133 MB on a 1M-row, 105-column read. Nothing said which to reach for.GZipCompressordefault level warningTwo things worth a maintainer's eye
1. A warning about
GZipCompressor's default level. It constructs atCompressionLevel.Fastest(zlib level 1), and the server ingests that stream slowly enough that a large insert can pass the
120-second
HttpClienttimeout. This is reachable:InsertOptions.Compressor's own XML doc says"
gzipremains the safer choice behind such a tier", so a proxy-bound user is steered toward gzipand the default constructor selects exactly the bad level. The default insert compressor is
zstd,so the out-of-box path is unaffected.
The underlying behaviour looks server-side rather than driver-side — replaying captured wire bytes
with
curl, no .NET involved, reproduces it. Worth deciding separately whetherGZipCompressorshould default to
Optimal.2. The insert-compression guidance may sit oddly next to the existing table. The existing
#tuning-compression"rough guide by deployment" recommendszstd:1for same-region inserts. Themeasurements say uncompressed was fastest at every distance tested (0.1 ms, 8.7 ms, 26.8 ms RTT),
costing 30% / 18% / 4%. Those are not strictly contradictory — the existing table reasons from
bandwidth thresholds and single-thread encode rates, and notes that parallel inserts move the
threshold — but a reader could land on both and be confused. I deliberately did not rewrite the
existing section, since it was revised recently on purpose. Flagging it for you to reconcile.
Drive-by fix
The
ReadBufferSizerow in the settings table still documented an 8 KiB default and alarge-object-heap warning. Since #451 the default is 64 KiB and the buffer is pooled, so the
warning no longer applies and the number was wrong.
Provenance
Every figure comes from the August 2026 macro-benchmark package (ClickBench
hits, 1M rows,105 columns, ClickHouse 26.x) across four environments spanning a 270x range of network latency. Each
number is captioned with the environment it holds in, because several of them invert.
Checks
<Note>/<Warning>tags balanced; code fences balanced.main:GZipCompressor(CompressionLevel = Fastest, ...),InsertOptions.Compressoris nullable with aZstdCompressor.Defaultdefault,RegisterPocoType<T>().mainspecifically: Box-free POCO read fast path #449 has merged soQueryAsync<T>is box-free, butBox-free ADO read path: typed column slots (stacked on #449) #499 has not, so the ADO accessors still box. The section says so and does not overclaim.
dotnet run scripts/changelog.cs -- --checkpasses.Draft because of the two open questions above.
🤖 Generated with Claude Code