TCP T1/T2: native-client benchmarks, the transport comparison, and a working comparison job - #597
TCP T1/T2: native-client benchmarks, the transport comparison, and a working comparison job#597alex-clickhouse wants to merge 13 commits into
Conversation
| // method. Without this a class carrying [Benchmark(Baseline = true)] makes that one method | ||
| // the baseline for the whole table, and every other row's ratio mixes the transport change | ||
| // with the method difference — unreadable as a regression signal. | ||
| AddLogicalGroupRules(BenchmarkLogicalGroupRule.ByMethod); |
There was a problem hiding this comment.
Comparison groups ignore benchmark params
Medium Severity
AddLogicalGroupRules is called with only BenchmarkLogicalGroupRule.ByMethod, so every parameter combination of a method shares one logical group. Most new classes are parametrized (Shape, Codec, Degree, ElementsPerRow), so the comparison job can treat one param’s baseline job as the ratio baseline for the others and mix the parameter effect into the PR-versus-baseline signal.
Reviewed by Cursor Bugbot for commit 635c2ff. Configure here.
837cd92 to
d917ea3
Compare
There was a problem hiding this comment.
Pull request overview
Adds native TCP and cross-transport benchmarks, category-based execution, and updated comparison workflows.
Changes:
- Adds TCP read, insert, compression, latency, and concurrency benchmarks.
- Categorizes existing HTTP benchmarks and supports package-to-package comparisons.
- Updates benchmark documentation and GitHub Actions automation.
Reviewed changes
Copilot reviewed 38 out of 38 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
CONTRIBUTING.md |
Documents benchmark categories and execution. |
ClickHouse.Driver.Benchmark/TransportReadPoco.cs |
Compares POCO reads across transports. |
ClickHouse.Driver.Benchmark/TransportRead.cs |
Compares transport read tiers. |
ClickHouse.Driver.Benchmark/TransportLatency.cs |
Measures warm request latency. |
ClickHouse.Driver.Benchmark/TransportInsert.cs |
Compares insert shapes across transports. |
ClickHouse.Driver.Benchmark/TcpStringRead.cs |
Benchmarks native string access tiers. |
ClickHouse.Driver.Benchmark/TcpSelectColumn.cs |
Adds native per-type read benchmarks. |
ClickHouse.Driver.Benchmark/TcpReadTiers.cs |
Compares native materialization tiers. |
ClickHouse.Driver.Benchmark/TcpProjectedRead.cs |
Measures projected column reads. |
ClickHouse.Driver.Benchmark/TcpPoolConcurrency.cs |
Measures pool concurrency scaling. |
ClickHouse.Driver.Benchmark/TcpInsertShapes.cs |
Compares native insert shapes. |
ClickHouse.Driver.Benchmark/TcpConnectionCost.cs |
Measures connection lifecycle costs. |
ClickHouse.Driver.Benchmark/TcpCompression.cs |
Benchmarks native compression codecs. |
ClickHouse.Driver.Benchmark/TcpCompositeRead.cs |
Measures composite access paths. |
ClickHouse.Driver.Benchmark/TcpArrayWriteShape.cs |
Compares dense and jagged arrays. |
ClickHouse.Driver.Benchmark/SelectColumn.cs |
Categorizes HTTP column benchmarks. |
ClickHouse.Driver.Benchmark/ResponseDecompressionBenchmark.cs |
Categorizes decompression benchmarks. |
ClickHouse.Driver.Benchmark/ReadValueBenchmark.cs |
Categorizes reads and adjusts baselines. |
ClickHouse.Driver.Benchmark/Program.cs |
Validates categories and comparison baselines. |
ClickHouse.Driver.Benchmark/PocoReadBenchmark.cs |
Categorizes POCO reads. |
ClickHouse.Driver.Benchmark/PocoInsertColumn.cs |
Categorizes POCO column inserts. |
ClickHouse.Driver.Benchmark/PocoInsertBenchmark.cs |
Categorizes POCO inserts. |
ClickHouse.Driver.Benchmark/MultidimArrayInsert.cs |
Categorizes array investigation benchmarks. |
ClickHouse.Driver.Benchmark/InsertCompressionBreakdownBenchmark.cs |
Categorizes compression breakdowns. |
ClickHouse.Driver.Benchmark/DynamicReadBenchmark.cs |
Categorizes dynamic-read investigation. |
ClickHouse.Driver.Benchmark/ComparisonConfig.cs |
Configures job-based comparison ratios. |
ClickHouse.Driver.Benchmark/ClickHouse.Driver.Benchmark.csproj |
Adds TCP references and comparison symbols. |
ClickHouse.Driver.Benchmark/BulkInsertColumn.cs |
Categorizes bulk inserts. |
ClickHouse.Driver.Benchmark/BinaryInsertStreamingBenchmark.cs |
Categorizes streaming investigation. |
ClickHouse.Driver.Benchmark/BinaryInsertObjectArrayBenchmark.cs |
Categorizes object-array investigation. |
ClickHouse.Driver.Benchmark/BinaryInsertCompressionBenchmark.cs |
Categorizes insert compression. |
ClickHouse.Driver.Benchmark/BenchmarkServer.cs |
Centralizes HTTP/TCP endpoints. |
ClickHouse.Driver.Benchmark/BenchmarkModes.cs |
Switches baseline behavior by build mode. |
ClickHouse.Driver.Benchmark/BenchmarkCategories.cs |
Defines benchmark categories. |
ClickHouse.Driver.Benchmark/BatchQueryLineBenchmark.cs |
Categorizes batch investigation. |
.github/workflows/nightly.yml |
Adds the nightly full benchmark suite. |
.github/workflows/benchmark.yml |
Adds TCP and category selection. |
.github/workflows/benchmark-compare.yml |
Selects baselines/categories and runs comparisons. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
1bc008d to
8ffa63f
Compare
8ffa63f to
a55f032
Compare
a55f032 to
45eecaa
Compare
45eecaa to
b2389e0
Compare
b2389e0 to
b2e873c
Compare
b2e873c to
9a6b277
Compare
a11d5a4 to
2d30cf1
Compare
db0e523 to
f0dece7
Compare
f0dece7 to
d01f5cb
Compare
9d2bf66 to
f0e3ca3
Compare
f0e3ca3 to
447d73a
Compare
3bda542 to
546f3f5
Compare
c908fb8 to
fa5db70
Compare
fa5db70 to
344e461
Compare
The CI runs select benchmarks with --anyCategories, so a class with no category matches no filtered run. Program fails the run rather than let one disappear from CI silently. BenchmarkDotNet takes either a baseline method or a baseline job, not both: given both, the method wins and every other row's ratio mixes the package difference with the method difference. Local runs want the method baseline (PocoReadBenchmark exists to report POCO against manual GetValue); the PR comparison wants the job baseline. BenchmarkModes.MethodBaseline selects per mode, and a second guard fails a comparison-mode run if a literal Baseline = true appears later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the tiers a caller chooses between rather than one number per operation: the block tier's borrowed spans against the typed indexer, Values, and the row tier; ReadAs<T>'s projection cost; IStringColumn's bytes against decoded strings; the dense Array(T) write shape against one array per row; and the pool's speedup at three widths. TcpSelectColumn mirrors SelectColumn's 16 expressions at the same row count. The two are not equal work and its docs say so: HTTP's Read() boxes every value into a reused object[], while the block reader decodes into typed storage. TcpCompression prices the codecs on the client's own work only. Over loopback there is no bandwidth to save, so it cannot rank them; T1a owns that question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each class pairs an HTTP arm with its native counterpart on identical data, so the choice between transports comes from numbers rather than from the protocol's reputation. Both clients run at their own defaults, which is what a caller gets. TransportInsert carries one-request arms next to the default ones: InsertOptions.BatchSize defaults to 100,000, so a 500k-row HTTP insert goes as five sequential requests where the native arms send one block. Without the control the table reports that batching as a protocol difference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comparison job has been failing to compile since the Common assembly split: the outer dotnet run left ClickHouseDriverVersion unpinned, so it restored the default release from nuget.org and could not see any API added since. It now pins the baseline version and source, and publishes 9000 so the native benchmarks have a server. baseline_ref defaults to the PR's own base branch. A tcp/** PR sits on a stack whose base is not main, and its benchmarks reference code main does not have, so comparing against main cannot compile. Categories come from the files the PR touched: a change under ClickHouse.Driver.Tcp/ runs the native set, one under ClickHouse.Driver/ the HTTP set, one under either Compression/ directory the codec sweep, and a change to the harness runs everything. Both transports moving adds the cross table. A push to main runs the regression sets; the nightly build runs all of them, where an hours-long job costs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A server with async_insert on buffers any insert carrying a client data block and, with wait_for_async_insert, holds the response until that buffer flushes. The adaptive wait starts at async_insert_busy_timeout_min_ms, 50 ms by default, so on 26.6.1.1193 a one-row HTTP insert costs 55 ms against 1.9 ms with the setting off. It reproduces with curl, on Null, MergeTree and Memory alike, and for VALUES and FORMAT CSV alike, so it is the server's policy and not the client's path. INSERT ... SELECT does not pay it, which is the line the measurements fall on: async inserts apply only when the data comes from the client. The native arms did not pay it, so leaving it on charged one transport for a server setting and reported the difference as a protocol difference. Every arm now sets async_insert = 0. HttpRowsDefaultBatching keeps InsertOptions.BatchSize at its default so the batching cost stays visible on its own. With the buffering out of the way it is not measurable in time, only in the per-request buffers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two transports do not default to the same request codec — HTTP to ZSTD, the native client to LZ4 — and on this payload the codec costs more than the serialization the arms exist to compare, so it reported itself as a protocol difference. Both sides now run uncompressed. Over loopback there is no bandwidth to save and TcpCompression owns the codec axis, so nothing of value is left out. With the codec and the server's insert buffering both out of the way, the native row tier is slower than HTTP's, not faster: it transposes rows into columns while HTTP writes each row straight to the stream. Columnar wins by skipping the transpose. The POCO tier beats the object[] tier on the native side, which is the compiled typed writers against unboxing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At 500,000 every arm ran under 60 ms and the ratios carried a standard deviation of 0.16 to 0.76, which tells a reader nothing. At ten times the size they land at 0.04 to 0.11 and hold across the change: the native row tier reads 1.30 at both sizes, the POCO tier 0.98, and the columnar tier 0.50 against 0.56. It costs about 1 GB of source rows held for the run. The batching arm also becomes quotable: fifty requests against one costs about 2.6 ms per extra request, which is the round trip and little else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The read arms carried the same confound the insert arms did: HTTP defaults to gzip or ZSTD and the native client to LZ4, so the codec difference reported itself as a transport difference. Over loopback a codec costs CPU and saves nothing, so the uncompressed pair is the one that answers which client does less work. BenchmarkServer.HttpUncompressed and CreateUncompressedTcpClient put the knob in one place. It moves the read ratios a long way, and mostly against the native client: HTTP was paying for an expensive codec while the native side paid for a cheap one. On a bandwidth-bound path the codec pays for itself either way, measured against ClickHouse Cloud at 0.22x to 0.56x of the uncompressed time on both transports. The layout is not what decides it. On ClickBench hits the native block format's columnar layout sends 1.04x to 1.09x fewer bytes than RowBinary under ZSTD, and 1.23x more bytes on ten narrow numeric columns, where real data repeats whole rows and row-major keeps each duplicate contiguous. A synthetic three-column shape reports 2.19x for the columnar layout, but only because its columns are short repeating sequences. TcpCompression and a real network own that question; this class stays on client work. Read rows go from 500,000 to 2,000,000, where the arms no longer sit in the tens of milliseconds with a relative standard deviation near 30%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its three columns are number, number % 100 and number / 7, which repeat far more than production columns do. Measured against ClickBench hits, that shape reports a 2.19x layout advantage for the native format where real data gives 1.04x to 1.09x, so a codec scores better here than a caller should expect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zizmor 1.30 added the self-repository audit, which reports the workspace- relative `uses: ./...` form. `$/...` names this repository at the commit that is running rather than a path on disk, so a policy that requires a full pin can cover an in-repo call as well. The ten `./` calls that already exist on main keep that form here; they belong to a separate change against main, not to this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
344e461 to
9368b70
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9368b70. Configure here.
|
|
||
| benchmark-full: | ||
| name: Full benchmark suite | ||
| uses: $/.github/workflows/benchmark.yml |
There was a problem hiding this comment.
Nightly workflow path is invalid
High Severity
The new benchmark-full job sets uses to $/.github/workflows/benchmark.yml, which is not a valid reusable-workflow path. GitHub Actions cannot resolve it, so the nightly full suite never starts.
Reviewed by Cursor Bugbot for commit 9368b70. Configure here.
The categorization guard is new in this branch, and it fails the run outright when a benchmark class carries no category. Three classes merged to main after this branch was first written -- AdoReadPathBenchmark (#499), ArrayInsertElementBoxing (#571) and WideIntegerInsert (#572) -- arrived with the rebase without one, so the harness exited 1 before running anything. All three pin one issue or one past optimization on the HTTP path, which is what http-investigation is for, and each says so in its own summary. Category counts, from --list flat --anyCategories: http-regression 40, tcp-regression 41, cross 14, http-investigation 17, tcp-investigation 0, compression 8. They sum to the 120 the flat list reports, so every benchmark is now reachable from exactly one category. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The categorization fix got local mode running again, but comparison mode was still dead: AdoReadPathBenchmark came from main with a literal [Benchmark(Baseline = true)], and the second guard rejects exactly that, so benchmark-compare.yml -- the mode every PR runs -- exited 1 before BenchmarkDotNet started. BenchmarkModes.MethodBaseline is true locally and false in comparison mode, so UntypedAccessor stays the method baseline for a local run and stands aside for the job baseline in a comparison run, which is what every other class on this branch already does. Verified both ways with --list flat: exit 0 and 120 benchmarks in local mode and with BENCHMARK_COMPARISON defined, neither guard reporting anything. Reported by Cursor Bugbot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>


Epic T1/T2: benchmarks for the native client, the cross-transport comparison, and the PR comparison job that runs them.
Ratios below are time relative to HTTP, so lower is faster — the same convention as BenchmarkDotNet's own
Ratiocolumn.What this adds
Native-protocol benchmarks (
tcp-regression, 41 methods). Each class prices the tiers a caller chooses between, not one number per operation:TcpSelectColumnSelectColumnTcpReadTiersGetValue/object[]/ POCOTcpProjectedReadReadAs<T>indexer /ReadAs<T>.ValuesTcpCompositeReadValues/ row tier, over 5 compositesTcpStringReadIStringColumn.Offsets/GetBytes/ decoded stringsTcpInsertShapesobject[]/ POCOTcpArrayWriteShapeTcpConnectionCostTcpPoolConcurrencyTcpCompressionCross-transport benchmarks (
cross, 14 methods):TransportRead,TransportReadPoco,TransportInsert,TransportLatency. Each pairs an HTTP arm with its native counterpart on identical data in one process, with the codec and the server's insert buffering equalised so that a ratio reports the transport and not a setting that lands on one side only.A working PR comparison job.
benchmark-compare.ymlhas been failing to compile since the Common assembly split: the outerdotnet runleftClickHouseDriverVersionunpinned, so it restored the default release from nuget.org and could not compile against any API added since. It now pins the version and source, publishes 9000, and defaultsbaseline_refto the PR's own base branch — atcp/**PR sits on a stack whose base is notmain, so comparing againstmaincannot compile.Category selection from the diff. A change under
ClickHouse.Driver.Tcp/runs the native set,ClickHouse.Driver/the HTTP set, eitherCompression/directory the codec sweep, and the harness itself runs everything. Both transports moving adds the cross table. A push tomainruns the regression sets; the nightly build runs all of them.TCP against HTTP
One ClickHouse 26.6.1.1193 container serving both 8123 and 9000, so both transports hit the same data and CPU budget. 3 warmup + 30 iterations × 2 launches, on 4 logical and 2 physical cores. Loopback, so this measures client and protocol work and none of the transfer saving that compression exists for. The read sections use 2M rows; the insert section uses 5M, for the reason given there.
Every time below is the fastest of 120 iterations: 30 × 2 launches, run twice. This host interferes in bursts, so an iteration can land far above its neighbours. Means and medians do not survive that; best-case iterations do, agreeing between the two runs to within 0.03% to 6.79% on every one of the 18 cross-transport arms. Interference is additive, so the fastest iteration is the arm's own cost and the tail is the box. Allocation does not depend on this and is quoted as BenchmarkDotNet reports it.
Every arm below runs uncompressed on both sides. The transports do not default to the same
codec (HTTP gzip or ZSTD, native LZ4), so at their defaults the codec difference reports itself as a
transport difference, and over loopback a codec costs CPU while saving nothing. An earlier revision
of this PR ran the read arms at both clients' defaults; that inflated the native client's advantage,
and the corrected figures are below. What compression is actually worth is measured on a real
network further down.
Everything on loopback was re-measured on 2026-09-11, at
bdf1cdb8. Two things make that worth saying rather than silently replacing numbers:mainas of that date, which includes the box-free typed column slots of Box-free ADO read path: typed column slots (stacked on #449) #499. That is a real change to the HTTP read path and it moves the allocation columns below by three orders of magnitude on fixed-width data. It is not noise, and the row-reads section says what it did.TransportLatencyruns 13% to 31% faster. A single cause that slows bulk throughput and speeds up round trips is not obvious, and this re-run cannot identify one. Treat absolute times as this box on this day.The WAN and compression figures further down are the one part not re-measured, because they need a Cloud service; they are attributed where they appear.
Row reads —
TransportReadHttpandTcpRowsboth consume every value;TcpBlocksis the columnar ceiling.Equalising the codec removed the native row tier's read advantage. At the two clients' defaults an earlier revision of this PR measured
TcpRowsat 0.66 / 0.72 / 1.02; uncompressed it reads 1.16 / 1.07 / 1.47. HTTP had been paying ZSTD on the response body while the native side paid LZ4, and that gap was most of the earlier win. On this hardware the native row tier is not faster than the ADO reader.The block tier's time advantage is real and is where the value is: 0.17 on fixed-width data, because nothing is boxed and nothing is copied.
Its allocation advantage on fixed-width data is gone, and HTTP now wins it. 15.5 KB against the block tier's 22.2 KB, where the earlier session measured HTTP at 46,890 KB. #499 gave
GetFieldValue<T>typed column slots that return the value without boxing, so HTTP's reader no longer allocates per row on this shape — 2M rows cost it fixed buffers and nothing else. The same change shows up on the mixed shape, where HTTP drops from 170,330 KB to 76,580 KB and lands within 31 KB of the block tier: both sides now allocate the 2M strings and neither boxes the integer or the float. On three string columns nothing changed, because strings were never the boxing (232,048 KB then, 232,049 KB now). So the block tier is now a time argument, not an allocation one.The native row tier still allocates a fresh
object[]per row — 109,397 KB on the narrow shape in both sessions, 56 B/row. Against HTTP's fixed buffers that is 7,051×, where the earlier session read 2.33×; the multiple moved because HTTP's denominator collapsed, not because the row tier changed.QueryAsyncyields a freshobject[]per row because a consumer is free to keep it, which is the row API's shape rather than the protocol's, and it is not fixable without changing the contract.On string-heavy data both TCP tiers are slower than HTTP, 1.47 and 1.48. The block arm calls
.Valueson the string columns, materialising 6M strings, and pays worse GC for it (Gen1 7,000 per 1,000 ops against HTTP's zero, Gen2 zero on all three) because three 2M-element arrays live for the whole block and get promoted, while HTTP's per-row strings die in Gen0.That is what
IStringColumnis for.TcpStringReadreads the same strings for 33.5 ms and 12.17 KB against 46.1 ms and 19,538 KB: 0.73 on time, and 1/1,600 of the allocation. So TCP's read advantage on strings depends entirely on whether the caller decodes them.POCO reads —
TransportReadPocoQueryAsync<T>QueryAsync<T>All three allocate within 1% of each other, and the figures are identical to the earlier session's — 151.08 MB over 2M rows is 79 B/row, which is the POCO (40 B) plus one fresh string per row (~32 B) and essentially nothing else. The objects are the allocation and no transport can avoid them. Neither path boxes:
QueryAsync<T>materialises straight from the stream on both transports.What the shared allocation floor does not do is equalise the time. The earlier session put all three within 3% of each other and concluded the floor left nothing for a transport to win; re-measured, the native reader is 15% ahead and hand-building from blocks 28% ahead. The allocation is identical to the byte, so whatever moved did not change what these arms allocate, and the gap is far larger than anything the two runs within this session disagree about (0.16% to 4.28% per arm). Beyond that the cause is open: #499 does not reach
QueryAsync<T>, but the rebase brought three weeks ofmainwith it and this re-run cannot separate that from the host. An earlier revision of this PR reported 0.70 here and the PR then withdrew it as a codec artefact; at an equalised codec it reads 0.72, so that withdrawal was wrong. What is fair to say is that the ordering is stable and the margin is not: on this box a POCO consumer should expect the native transport to be somewhere between level and a quarter faster, and should not plan around a specific figure.Hand-building from blocks remains the worst of the three on GC pressure — Gen1 4,000 per 1,000 ops against zero for both
QueryAsync<T>arms — because the intermediate column arrays survive the block.A caller who wants materially better than this has to stop building objects, which is what the 0.17 on narrow blocks shows.
Latency —
TransportLatency500 sequential
SELECT 1on warm connections: HTTP 572.7 ms (1.15 ms/query, 7.19 MB) against TCP 513.4 ms (1.03 ms/query, 4.64 MB), ratio 0.90, allocation 0.65. TCP saves about 0.12 ms of fixed per-request cost.This is the section that moved most against the earlier run, and in the opposite direction to everything else: both arms are faster than they were (824.6 and 590.6 ms), so the native client's per-request advantage narrows from 0.72 to 0.90 while its allocation ratio holds at 0.65 against 0.66. The allocation figures rule out the arms having changed. Whatever this host did between the two sessions, it helped round trips and hurt bulk transfer, so the size of the latency win is the least portable number in this document.
Inserts —
TransportInsert5,000,000 rows, because a smaller insert measures the fixed cost of an insert as much as the serialization the arms compare. Fitting both sizes gives each arm 4.7 to 8.6 ms of fixed cost, which is a fifth of a 500k-row measurement, so the ratios there compress toward 1 (
TcpColumnarreads 0.65 at 500k against 0.60 at 5M,TcpRows1.07 against 1.04,TcpPoco0.91 against 0.94). 5M costs about 1 GB of source rows held for the run, which is the reason not to go further.The native arms send this insert as 100 wire blocks of 50,000 rows, which is
MaxRowsPerBlockat its default, and convert rows to columns one block at a time.Two things are turned off, because each lands on the transports unequally and would report itself as a protocol difference.
async_insert = 0: a server with async inserts on holds an insert's response until its buffer flushes, waiting fromasync_insert_busy_timeout_min_ms(50 ms) upward, and the native arms never paid it. No request compression: the transports do not default to the same codec (HTTP ZSTD, native LZ4), and the codec costs more than the serialization the arms exist to compare. Over loopback there is no bandwidth to save andTcpCompressionowns the codec axis, so nothing of value is dropped.HttpRowsTcpRowsHttpPocoTcpPocoTcpColumnarHttpRowsDefaultBatchingThe
ns/rowcolumn is the slope between the two sizes, so it excludes the fixed cost. †HttpPoco's allocation does not hold still between runs; the lower of the two is quoted and the spread is below.The native protocol's insert advantage still needs the columnar shape to appear.
TcpColumnaris the fastest thing here at 0.60, and hand the same client rows and the advantage disappears — but it no longer reverses. The earlier session putTcpRowsbehind HTTP at 1.35; re-measured it is 1.04, which is parity, andTcpPocoholds at 0.94 exactly. The claim that the row tier is slower than HTTP on inserts does not survive re-measurement; the claim that it gives up the columnar win does.The transpose is still what the row tiers pay, and the two sizes price it twice over: as totals, 400.6 − 233.2 = 167.4 ms for 15M values, or 11.2 ns per value; as slopes, 78.4 − 45.3 = 33.1 ns per row, which is the same 11.0 ns per value. HTTP's RowBinary is row-major, so it matches the input layout and encodes straight into the request body while sending; the native block format is column-major, so every column must be fully materialised before a byte of it goes out, and the transpose runs inside the
schema => …callback, wedged between two round trips with nothing to overlap.The POCO tier beats the
object[]tier on the native side, 362.6 against 400.6 ms — 2.5 ns per value, where the earlier session measured 7.2 — because the compiled typed accessors read fields in place instead of unboxing scattered values. On HTTP the same swap is a wash on the clock (392.5 against 385.8 ms), as it was before. A probe in the earlier session tagging each insert with aquery_idput the HTTP POCO path at 239 ms of client CPU against theobject[]path's 297 ms; that probe was not repeated, and the wall-clock wash it explains still reproduces.Allocation is per unit of framing, and the transports frame differently — and the framing model is the part that reproduces to the digit. A native insert allocates about 13 KB fixed plus 5.05 KB per wire block, which the two sizes pin down: 10 blocks cost 63 to 65 KB and 100 cost 518 to 520 KB, against the earlier session's 13 KB and 5.1 KB per block. HTTP allocates about 20 KB fixed plus 9.3 KB per request, so one request costs 30 KB and the fifty of
HttpRowsDefaultBatchingcost 487 KB. Neither figure includes row handling, because the source objects are built once inGlobalSetup, and no arm triggers a single GC collection. Neither HTTP path boxes during the operation: the POCO path uses the typedAction<T, ExtendedBinaryWriter>writers, andInsertOptions.Formatdefaults toRowBinary, so the boxed getters serve onlyRowBinaryWithDefaultsand failure diagnostics.HttpPoco's allocation was the one arm the earlier session could not reproduce, at 30.9 KB in one run against 358.9 KB in the other. The anomaly recurred, so it is not a one-off: 95.41 KB in this session's first run against 30.36 KB in its second, whileHttpRowsbeside it held at 30.29 and 30.12 KB. Smaller spread than before and the same shape — one arm, on the HTTP side, whose allocation jumps by a multiple between runs while its neighbour is stable to 0.6%. Still unexplained, and now twice observed rather than once.InsertOptions.BatchSize: the default 100,000 sends fifty requests for 5M rows, costing 536.1 against 385.8 ms — about 3.1 ms per extra request, which is the round trip and little else — plus 16.2× the allocation in per-request buffers. An earlier revision of this PR reported a 4× time penalty here; that was fifty exposedasync_insertwaits and the claim stays withdrawn.Compression, and the columnar layout, on a real network
Loopback cannot answer whether a codec pays for itself, so this was measured against a ClickHouse Cloud service (26.4.1.2212): 500k rows, median of 3 after a warm-up, rows taken from ClickBench
hitsso the payload is not a compressor-friendly sequence. This is characterisation from a one-off probe on 2026-08-30, not a committed benchmark, and it is the one section the 2026-09-11 re-run did not repeat — it needs that service, and its arms run for 0.4 s to 30 s each on a path where transfer dominates client work by an order of magnitude, so nothing the client does moves them. The arms run sequentially over a WAN, so read differences under ~15% are noise.Reads:
Inserts:
Compression is the dominant factor and everything else is secondary. It takes 0.10 to 0.26 of the uncompressed time. This is the number the loopback suite structurally cannot produce, and it settles the direction of T1a: compression on by default is right.
ZSTD, not LZ4, is the better codec on this path — it wins 4 of 6 arms, including both large payloads and every insert. LZ4 holds only on the two small, highly compressible reads. One WAN probe is not enough to change a shipped default, but it is evidence T1a did not have.
HTTP's compressed reads beat the native client's on every shape, by 1.6× to 2.1×. Uncompressed the two are level or favour the native client (0.89 on the mixed hits shape), so the gap is specific to the compressed read path, and it is not explained by bytes: the layout table below has the native format sending fewer bytes on this data. Compression improves HTTP's mixed-shape read by 9.7× against a 6.49× byte reduction, while it improves the native client's by only 5.1× against its own 7.05×. The native compressed read is the one arm whose speedup falls short of its byte reduction, so something in that path is not converting the saving. Per-block framing is the leading suspect — a codec context, a CityHash checksum and a frame header per block rather than once per response — but this is unverified, and it is worth a look before the client ships.
Inserts are near parity on real data: HTTP is 21% ahead on the mixed shape, the native client 10% ahead on the numeric one. The native client's large insert win shows up only on the synthetic shape.
The columnar layout does not compress better than RowBinary on realistic data
An earlier revision of this PR claimed it did, on the strength of a synthetic shape, and that claim is withdrawn. Comparing
FORMAT RowBinaryagainstFORMAT Nativefor the same 1M rows under ZSTD-3, compressed in 1 MB frames so neither layout gets cross-block context the native protocol would never have:WatchIDStringThese are server-side format sizes, so no client change can move them, and they are unaffected by the re-run.
The synthetic row is the outlier, and it is the one the withdrawn claim rested on. Its columns are
number,number % 100andnumber / 7: three short-period sequences that compress far better once grouped by column. Real hits columns are high-cardinality —WatchIDandUserIDare near-random,URLandTitleare varied text — so grouping them by column unlocks little the compressor was not already finding.On ten narrow numeric columns row-major wins outright, for a reason that is a property of real traffic rather than of either format: many hits repeat an identical
(CounterID, RegionID, OS, UserAgent, Resolution*, Flash*, Net*)tuple. Row-major keeps each duplicate row contiguous, so the compressor matches one long run per repeat; column-major scatters that tuple across ten distant regions and can only find per-column runs.The frame size barely mattered — whole-stream compression reads 6.98× against the chunked 6.49× on the mixed shape — so the distortion in the withdrawn claim was the synthetic data, not the compression window.
Per-type decode —
SelectColumnagainstTcpSelectColumnSame 16 expressions, 500k rows, both drained without touching a value. These are no longer unequal work in the way the earlier revision of this section said they were. That text had HTTP's
Read()boxing every value into a reusedobject[]; #499 replaced that row buffer with typed column slots, so a drain that calls no accessor now allocates nothing on the fixed-width types — 17.5 KB for 500k rows, flat across all ten of them. The allocation columns are therefore comparable on those types and are given below.This is the section whose ratios reproduce best. Every absolute time is 20% to 40% above the earlier session's, and the ratios still land within 0.04 of it on 15 of 16 types —
String0.51 → 0.59 is the only one that moved further.Decimal128reproduces at 2.21 against 2.19.TCP scales with width —
Int324.7 ms toInt6410.1 ms is 2.1× — which HTTP does not.On the variable-length types HTTP still allocates per value and TCP still does not, but for different reasons on each side, and neither is a protocol win. HTTP's 14.62 MB on the decimals is one decimal object per row (30 B/row) built during
Read(), which typed slots do not cover; TCP's flat 12 to 37 KB is the drain loop declining to materialise strings, arrays and tuples at all.TransportReadis still the honest allocation comparison, because there both sides consume every value.What these turned up
Decimal128reads allocate ~357 B/row, and this reproduces exactly. 128.6 ms and 170.37 MB over 500k rows, against its ownDecimal64at 16.0 ms / 20.0 KB andDecimal256at 20.5 ms / 31.2 KB — the same 174,459 KB the earlier session measured, to the digit.Gen0is 6,000 per 1,000 ops where every other TCP type is zero. It is the one type in the sweep where TCP loses to HTTP, in both sessions.ClickHouseTcpDecimal(Int128 mantissa, int scale)(Numerics/ClickHouseTcpDecimal.cs:39) is documented as sign-extending to 256 bits but reachesInt256throughInt256.FromBigInteger(mantissa), and the implicitInt128→BigIntegerconversion heap-allocates once per row. TheInt256(ulong, ulong, ulong, ulong)constructor next to it costs nothing. Not fixed here — this PR only measures.#499's typed column slots reach the ADO reader and the per-type sweep, and stop there. The three places this suite can see it are HTTP's fixed-width reads (46,890 KB → 15.5 KB on 2M rows), HTTP's mixed-shape read (170,330 KB → 76,580 KB, the integer and the float stopping boxing while the string stays) and the drain loop in
SelectColumn(per-value → 17.5 KB flat). It does not touchQueryAsync<T>, which materialises POCOs straight from the stream and already allocated only the objects;TransportReadPoco's 151.08 MB is identical across the two sessions. Nor does it touch the decimals, arrays, tuples or strings in the sweep, which still build one object per value.Not TCP, and not a defect: the HTTP insert cost is
async_insert. A server withasync_insert = 1andwait_for_async_insert = 1holds an insert's response until its buffer flushes, waiting fromasync_insert_busy_timeout_min_ms(50 ms) upward. On 26.6.1.1193 a one-row HTTP insert costs 52 ms against 2.0 ms with the setting off, where an emptySELECT 1round trip is 2.2 ms. It reproduces withcurl, onNull/MergeTree/Memoryalike and for VALUES/FORMAT CSValike, and a bufferedExecuteNonQueryAsyncliteral pays it too — so it is the server's policy, not the driver's path.INSERT ... SELECTcosts 2.4 ms, which is the line it falls on: async inserts apply only when the data comes from the client. Worth documenting rather than fixing, sinceBatchSizemultiplies it andasync_insert = 1is the ClickHouse Cloud default. (Measured 2026-08-30; not repeated.)RowBinary costs the server about three times what the native block format does. For the same 109 MiB, the same 5M rows and the same
ENGINE Nulltarget, the server burns 150 to 180 ms of CPU parsing RowBinary against 55 to 68 ms reading native blocks, and on the native path it spends most of its time waiting for the client rather than parsing. So HTTP has a server-side floor the client cannot get under, and the native protocol's insert advantage is partly the server's work, not only the client's. Measured by tagging every insert with aquery_idand readingquery_duration_ms,ProfileEvents['UserTimeMicroseconds']andProfileEvents['NetworkReceiveElapsedMicroseconds']out ofsystem.query_log. (Measured 2026-08-30; not repeated, and note that it predicts a larger native insert win than the re-measuredTcpRows1.04 shows.)Where the row tier's cost is, and where it is not. The native row tier costs 11.2 ns per value above the columnar arm, 2.5 ns when the values come off POCO fields, and neither figure has a confirmed cause. Both are smaller than the earlier session's 14.4 and 7.2. Two candidates are ruled out:
Type.IsInstanceOfType, called once per value in the gather thatUntypedRowColumns.CreateBuildercompiles, is not it: removing it movedTcpRowsby less than its standard deviation.TcpRows356.4 against 347.5 ms,TcpPoco263.5 against 241.2,TcpColumnar129.4 against 131.1. Those are that session's numbers — the same arms read 400.6, 362.6 and 233.2 here — but the comparison was same-session and back-to-back, which is what makes it valid. At three columns the row array is walked three times either way, and a 50,000-row window is still 200,000 scattered heap objects, about 5 MB of them. Whether locality pays on wide rows is a question three columns cannot answer.That change is visible in this suite only as allocation: 5.05 KB per wire block is unchanged by it, but the default block size moved, so the same 5M-row insert allocates 519 KB as 100 blocks where it allocated 40 KB as 5. Its real subject, the peak memory of the conversion buffers, is not something a timing table shows.
Caveats
TcpCompressioncannot rank codecs; its own docs say so. The Cloud figures above come from a one-off probe that is not part of the suite, and T1a still owns making that repeatable.TcpPoco's insert ratio held at 0.94 exactly. Three did not:TcpRowson inserts 1.35 → 1.04,TcpBlockson string reads 1.79 → 1.48, and the POCO read arms 0.97 → 0.85 and 0.72. Read any single ratio here as good to about ±0.1 and only the large ones as load-bearing.RatioSD, and the job should say how many rows it suppressed.tcp-investigationis defined and unused, kept as the slot symmetric withhttp-investigation.MinIterationTimeadvisory fires on the sub-100 ms arms. UnderRunStrategy.Monitoringthere is no invocation amortisation to increase; the measurements are still five orders of magnitude above timer resolution.Verification
Programguards were failing on this branch, in both modes, and both are fixed. The 2026-09-09 rebase brought three benchmark classes frommainthat predate this branch's conventions, and each guard caught one half of that:AdoReadPathBenchmark(Box-free ADO read path: typed column slots (stacked on #449) #499),ArrayInsertElementBoxing(perf(types): per-value allocations on UUID, Array and Decimal (reland #550) #571) andWideIntegerInsert(perf(types): two per-value allocations on the write path (reland #556) #572) arrived carrying no category, and the guard exits 1 rather than let them be silently skipped by every--anyCategoriesrun — so the harness ran nothing at all, in either mode. All three pin one issue or one past optimization on the HTTP path and are nowhttp-investigation.AdoReadPathBenchmark.UntypedAccessoralso arrived with a literal[Benchmark(Baseline = true)], which the second guard rejects in comparison mode — sobenchmark-compare.yml, the mode every PR actually runs, stayed broken after the categories were fixed. It now usesBenchmarkModes.MethodBaseline, keeping it the method baseline locally and standing aside for the job baseline in a comparison run. Reported by Cursor Bugbot.--list flatboth ways — local, and withBENCHMARK_COMPARISONdefined: exit 0 and 120 benchmarks each time, neither guard reporting anything.--list flat --anyCategories:http-regression40,tcp-regression41,cross14,http-investigation17,tcp-investigation0,compression8. They sum to the 120 the flat list reports, so every benchmark is reachable from exactly one category.bdf1cdb8, twice, at 30 × 2 launches per run. Best-case iterations agree between the two runs to within 0.03% to 6.79% on the 18 cross-transport arms, and to within 3% on the per-type sweep and the string tiers.ns/rowslopes and the allocation model come from fitting 500k against 5M. The native model reproduces the earlier session's to the digit: 5.05 KB per wire block against 5.1, ~13 KB fixed against 13, 10 blocks at 63 to 65 KB and 100 at 518 to 520 KB.HttpPoco's unstable allocation went the other way — it recurred, so that caveat is kept and strengthened.async_insertfinding, the server-CPU comparison, the client-CPUquery_idprobe and the Cloud/WAN section are from the 2026-08-30 session and were not repeated; each is marked where it appears.Compression/subdirectory rule, the harness escape hatch, and the docs/tests/examples fallbacks.Stacked on #595. Nothing here depends on the examples, so the diff to review is the commits on top of
tcp/epic-t3-examples.🤖 Generated with Claude Code