Skip to content

Combine small blocks into one Arrow IPC record batch on output - #119873

Open
groeneai wants to merge 2 commits into
ClickHouse:masterfrom
groeneai:arrow-output-record-batch-coalescing-119815
Open

Combine small blocks into one Arrow IPC record batch on output#119873
groeneai wants to merge 2 commits into
ClickHouse:masterfrom
groeneai:arrow-output-record-batch-coalescing-119815

Conversation

@groeneai

@groeneai groeneai commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Closes: #119815
Related: #117754

Changelog category (leave one):

  • Improvement

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Added output_format_arrow_record_batch_size and output_format_arrow_record_batch_size_bytes, which make the Arrow and ArrowStream output formats combine consecutive blocks that are individually smaller than the target into a single record batch. A selective SELECT produced one small record batch per scanned block, and the fixed metadata and buffer padding of each batch then dominated the payload. Both settings default to 0, which keeps the previous behavior of one record batch per block. Closes #119815.

Description

ArrowIPCBlockOutputFormat::consume wrote one record batch per incoming chunk, and nothing squashes on the SELECT ... FORMAT path: markOutputFormatPrefersLargeBlocks("ArrowStream") is consulted only on the INSERT path, through IStorage::prefersLargeBlocks. On the reported example, SELECT number FROM numbers(268435456) WHERE number % 65536 = 0 FORMAT ArrowStream produces 721,040 bytes in 4,096 one-row batches; at output_format_arrow_record_batch_size = 65409, output_format_arrow_record_batch_size_bytes = 1048576, 16,728 bytes in 1 batch, byte-identical to a hand-written single batch.

consume now stages sub-target chunks into one accumulator, and its former body moved verbatim into writeChunk. A chunk that already reaches a target flushes what is staged and is then written as it stands, so a large block is never copied or merged and a combined batch holds fewer than twice the target rows. The byte target measures the accumulated block, as min_insert_block_size_bytes does, not the encoded batch, and for a LowCardinality column the two differ by the deduplication factor in both directions; the setting documents that and points at the row target. Rows are copied in rather than the chunks kept, so the accumulator does not hold a filtered block's whole reservation.

These are targets to accumulate to, not maximums; #117754 adds the maximum on the same seam and rewrites the body this moves into writeChunk, so either merge order rebases mechanically.

Both default to 0, where the output is byte-identical to before (cmp). Measured at 1 MiB: the repro's first batch reaches the client after 186 ms instead of 1 ms, while its total drops from 262 to 186 ms; peak memory rises by up to 7 MiB where blocks already filled a batch, the cost of the bigger batch rather than of the accumulator; the repro itself peaks lower.

If you would rather have it on by default (65409 / 1 MiB), say so.


Workflow [PR]
Sync PR [sync-upstream/pr/119873]

ArrowIPCBlockOutputFormat::consume wrote exactly one record batch per incoming
chunk, and nothing squashes on the SELECT ... FORMAT path:
markOutputFormatPrefersLargeBlocks("ArrowStream") is only consulted through
IStorage::prefersLargeBlocks() on the INSERT path. A selective query therefore
emitted one small batch per scanned block, and the fixed FlatBuffers metadata
and buffer padding of each batch dominated the payload. On the reported example,
SELECT number FROM numbers(268435456) WHERE number % 65536 = 0 FORMAT
ArrowStream produced 721,040 bytes in 4,096 one-row batches; at
output_format_arrow_record_batch_size = 65409 and
output_format_arrow_record_batch_size_bytes = 1048576 it produces 16,728 bytes
in one batch, byte-identical to a hand-written single-batch encoding of the
same rows.

consume becomes a staging front end and its former body moves verbatim into
writeChunk, which is also the seam PR ClickHouse#117754 rewrites to split an oversized
chunk, so either merge order is a mechanical rebase and the two compose in the
right order: accumulate up to the target, then split what a single batch still
cannot address.

Sub-target chunks are appended into one accumulator built from
cloneEmptyColumns() rather than from the first chunk: FilterTransform passes
result_size_hint = -1, so ColumnVector::filter reserves the whole source block
and resize_exact does not release it, and adopting the first chunk would make
the accumulator carry that reservation for the lifetime of every batch. A chunk
that already reaches a target flushes what is staged and is then written
un-copied, which keeps a large block out of the accumulator and bounds a
combined batch below twice the target; gating that branch on an empty
accumulator instead would let a few staged rows absorb a 1,048,449-row INSERT
block.

The size is measured on the materialized chunk because ColumnConst::byteSize
reports one stored value, and materializeChunk is a precondition of appending
at all: MessageQueueSink drives this writer through IOutputFormat::write with
no MaterializingTransform in front of it, and appending into an accumulator
cloned from a ColumnConst would advance the row count without copying values.
The targets bound the accumulated data as Chunk::bytes() measures it, on the
same terms as min_insert_block_size_bytes, not retained memory: a
LowCardinality shared dictionary and an aggregate state's foreign arenas sit
outside that measure. Measured against blocks made equally large with
max_block_size instead, the accumulator adds between -1.0 and +1.7 MiB for a
1 MiB target.

The precedent and the refuted alternative, for the record:
output_format_parquet_row_group_size_bytes stages on the cruder
chunk.allocatedBytes() (ParquetBlockOutputFormat.cpp:121) and under-counts
LowCardinality the same way, because byteSize() counts the deduplicated
dictionary once while the encoder writes one value per row unless
output_format_arrow_low_cardinality_as_dictionary is set. Summing each incoming
chunk's bytes as Squashing does was rejected, not overlooked:
ColumnLowCardinality::filter keeps the whole source dictionary and filters only
the indexes (ColumnLowCardinality.h:126-130), so over the 4,096 one-row chunks
this change exists to combine, that sum over-counts by about the chunk count and
would flush at the first one. The byte setting's description states the measure
and advises pairing it with the row criterion.

Both settings default to 0, which is byte-identical to the previous behaviour,
and SettingsChangesHistory records 0 -> 0 so compatibility below 26.9 keeps it.

Closes: ClickHouse#119815

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@groeneai groeneai added can be tested Allows running workflows for external contributors groeneai-origin-request PR origin: a maintainer pinged or directed groeneai labels Sep 14, 2026
@groeneai

Copy link
Copy Markdown
Collaborator Author
Internal second-model review

An independent cold review plus a second model reviewed this change over two rounds before it was
published. Verdicts are mine; findings I disagreed with are recorded with the evidence that refuted them.

Fixed: the byte target published a bound it did not deliver for LowCardinality.
ColumnLowCardinality::byteSize deliberately does not count what the encoder expands (it counts the
deduplicated dictionary plus the indexes, and skips a shared dictionary entirely), while
RecordBatchEncoder writes such a column expanded whenever
output_format_arrow_low_cardinality_as_dictionary = 0. Measured: a 1500-byte target over repeated
1000-byte strings ended batches after 484 rows holding about 484 KiB of values, against two-row
batches for the equivalent plain String. With the byte criterion set alone the batch is unbounded in
rows. The measure is unchanged, deliberately: it is the same measure as min_insert_block_size_bytes,
and output_format_parquet_row_group_size_bytes uses a cruder one with the same property. What
changed is the contract: the setting now documents the measure and the consequence, tells the user to
pair it with the row criterion to bound the batch, and two test arms pin both halves.

⚠️ Disagreed: track cumulative incoming chunk bytes instead, as Squashing does.
ColumnLowCardinality::filter keeps the whole source dictionary and filters only the indexes, so
every one-row chunk of a filtered LowCardinality column carries its source dictionary. Summing
per-chunk bytes over the 4,096 chunks of the reported query over-counts by about that factor and
flushes at the first chunk, which makes the feature inert on exactly the query it exists to fix; and a
shared dictionary is excluded per chunk as well, so it does not fix the reported case either.

⚠️ Disagreed: account for the representation passed to the encoder. Exact accounting needs a
per-row walk or an estimate, on a knob whose siblings all measure block bytes. Rejected as scope
creep; the precedent is recorded in the commit message.

⚠️ Disagreed: the plan's peak-memory rule should have forced a redesign. The rule's denominator
conflated the cost of encoding a bigger batch with the cost of the accumulator. Against a control
producing the same batch sizes through max_block_size instead, the accumulator adds between -1.0 and
+1.7 MiB for a 1 MiB target, negative for LowCardinality. The user-visible rise is in the PR
description.

💡 Noted, not blocking: the byte setting's wording covers the private-dictionary case only. Both
the cold review and the second model raised this in the final round, and I agree with it. The setting
says a LowCardinality column "counts its deduplicated dictionary once";
ColumnLowCardinality::byteSize excludes that dictionary entirely when it is shared, and an empty
accumulator adopts a structurally compatible shared source dictionary, which is the usual shape for a
column deserialized from a part. The measure is then one index byte per row, so the gap the sentence
warns about is larger than it says rather than smaller, and both the warning and the advice to pair the
byte target with the row target hold either way. If you want the sentence exact, this is the wording I
would push: "a LowCardinality column counts its dictionary once, or not at all when that dictionary
is shared with the blocks it came from".

💡 Noted, not blocking. The dictionary-delta and Arrow-file arms were strengthened to produce
several coalesced batches, so a delta emitted from a merged dictionary and a multi-entry file footer
are both pinned. FormatSettings::arrow::row_group_size is dead across src/ (a leftover of the
removed Apache Arrow library writer) and is deliberately left alone, so as not to collide with
#107897.

@clickhouse-gh clickhouse-gh Bot closed this Sep 14, 2026
@clickhouse-gh clickhouse-gh Bot reopened this Sep 14, 2026
@clickhouse-gh

clickhouse-gh Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [4e9d911]

Summary:


AI Review

Summary

This PR adds output_format_arrow_record_batch_size and output_format_arrow_record_batch_size_bytes so Arrow and ArrowStream can coalesce consecutive small output blocks into larger record batches, and it wires the new settings through docs, settings history, fuzzing, and focused stateless coverage. I reviewed the current head diff, the modified code paths around staging/finalization/dictionary handling/framing, the existing GitHub discussion, and the current PR CI, and I did not find any new blockers or majors. The earlier LowCardinality wording issue is addressed on the current head, and the PR's CI is all green.

Final Verdict

✅ No new findings on the current PR head.

LLVM Coverage Report

Measured on commit 4e9d911.

Metric Baseline Current Δ
Lines 89.00% 89.00% +0.00%
Functions 91.80% 91.80% +0.00%
Branches 81.50% 81.50% +0.00%

Changed lines: Changed C/C++ lines covered: 75/76 (98.68%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-improvement Pull request with some product improvements label Sep 14, 2026
@groeneai

Copy link
Copy Markdown
Collaborator Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, one command, byte-exact and not statistical: clickhouse local --query "SELECT number FROM numbers(268435456) WHERE number % 65536 = 0 FORMAT ArrowStream" gives 721,040 bytes in 4,096 one-row record batches, which is the reporter's figure exactly.
b Root cause explained? ArrowIPCBlockOutputFormat::consume wrote exactly one record batch per incoming chunk, and nothing squashes on the SELECT ... FORMAT path: markOutputFormatPrefersLargeBlocks("ArrowStream") is consulted only through IStorage::prefersLargeBlocks on the INSERT path. A filtering query therefore emitted one small batch per scanned block, and per-batch metadata plus buffer padding dominated the payload.
c Fix matches root cause? The batch boundary is now decided where it is made, in consume. No widened bound, no reduced dataset, no defensive check masking an upstream problem.
d Test intent preserved / new tests added? No existing test changed: with both settings at 0 nothing in the suite changes behaviour. New test 05211_arrow_output_record_batch_coalescing.sh, 15 arms, each asserting an exact batch count plus the rows read back.
e Both directions demonstrated? Enabled: 16,728 bytes in 1 batch, cmp-identical to a single-batch encoding of the same rows. Default: cmp-identical to the unpatched binary's output. The new test fails on the unpatched binary (Unknown setting) and passes on the patched one. 50/50 runs green.
f Fix is general across code paths? Both registrations (Arrow file and ArrowStream) share this writer and both are covered, including the file footer via ipc.open_file. The sibling output-side squashing sites (ParquetBlockOutputFormat::consume, PrettyBlockOutputFormat) already have their own targets and are untouched. CHColumnToArrowColumn, which the ArrowFlight endpoint uses, is not this writer: 120 test_arrowflight_interface tests confirm it.
g Fix generalizes across inputs (params/datatypes/wrappers)? Arms cover UInt64, a constant expression, LowCardinality(String) plain and dictionary-encoded, Nullable(UInt64), a 0-row result, a chunk that alone reaches the target, one that exactly equals it (a >=/> mutant reddens that arm), a 0-column chunk, dictionary deltas across several combined batches, and the row and byte criteria alone and together, including the byte criterion alone over a repeated LowCardinality value, whose documented deduplicated measure the arm pins against the same values as a plain String.
h Backward compatible? Both settings default to 0 and the output is byte-identical to master by cmp. Two SettingsChangesHistory rows in the 26.9 block record 0 -> 0, so compatibility below 26.9 keeps today's behaviour. No format, protocol or schema change; Arrow IPC readers are batch-agnostic by specification.
i Invariants and contracts preserved? Every consumed row appears exactly once, in order, in some emitted batch. finalizeImpl drains the accumulator before EOS/footer, resetFormatterImpl clears it so rows cannot leak across the write/finalize/reset reuse that MessageQueueSink performs (test_storage_kafka test_block_based_formats_2 passes), the accumulator is only appended to, the schema still precedes the first batch, and a chunk that reaches a target flushes what is staged rather than absorbing it, so a batch this change creates stays below twice the target. Each of those is pinned by a mutant that reddens the suite.

Session id: cron:clickhouse-impl-slot-5:20260913-232400

@clickhouse-gh clickhouse-gh Bot added the comp-formats Input/output formats (CSV/JSON/Parquet/ORC/Arrow/Protobuf/etc.). label Sep 14, 2026
Comment thread src/Processors/Formats/Impl/ArrowIPC/ArrowIPCBlockOutputFormat.cpp
`ColumnLowCardinality::byteSize` counts one index per row plus each distinct
value once, and nothing at all for the values while the dictionary is still
shared with the block the column came from (ColumnLowCardinality.h:196-197, and
the empty-plus-shared adoption at ColumnLowCardinality.cpp:245-250). That has
two consequences for `output_format_arrow_record_batch_size_bytes` and only one
of them was documented:

- a batch of repeated values encodes to more than the target, because the record
  batch writes one value per row;
- a block filtered out of a larger one keeps that block's dictionary, so it can
  reach the target on its own. Measured on a four-block fixture with one
  surviving row per block: the byte target ends every batch at that single row
  and combines nothing, and since whichever target is reached first ends a
  batch, it also ends the batches a paired row target would have combined.

This is the accounting of `min_insert_block_size_bytes`, which the setting
documents itself against: `Squashing::oneMinReached` uses the same
`Chunk::bytes` measure, the same per-chunk pre-check and the same disjunction of
the two criteria (Squashing.cpp:395-411), and it produces identical block counts
on that fixture in all three configurations. The behaviour is therefore
unchanged; the setting now states what it counts, and 05211 pins both halves
with a `String` control and the row-target remedy beside them.

The `staged` comment no longer claims the accumulator holds none of the source
block's allocations, which the shared-dictionary path would falsify.
@clickhouse-gh

clickhouse-gh Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 4e9d91119 with master 0d04f2bc2 (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes

programs/clickhouse-stripped: smaller than the master baseline by the known offset between the two builds, so the difference is not shown. A delta that differs from the offset by more than 50% of it is shown, in either direction.

The official master build is compiled with -g and a pull request build is not, and XRay counts debug instructions towards its instrumentation threshold, so master instruments thousands of functions more and its binary is ~0.4% larger no matter what the pull request does.

Compile time of recompiled translation units

3169 translation units recompiled, 16061 s compile time in total, 3169 of them have a recent master baseline.

Job report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors comp-formats Input/output formats (CSV/JSON/Parquet/ORC/Arrow/Protobuf/etc.). groeneai-origin-request PR origin: a maintainer pinged or directed groeneai pr-improvement Pull request with some product improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ArrowStream emits thousands of tiny record batches, adding substantial decoding overhead

1 participant