Skip to content

CAS: opt-in content-defined chunking for large part files - #2370

Closed
DimensionWieldr wants to merge 3 commits into
Altinity:antalya-26.6from
DimensionWieldr:cas-chunking
Closed

DimensionWieldr wants to merge 3 commits into
Altinity:antalya-26.6from
DimensionWieldr:cas-chunking

Conversation

@DimensionWieldr

@DimensionWieldr DimensionWieldr commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Addresses #2314: whole-file CAS republishes an entire part file on merge even when most of the compressed bytes already live in the pool.

Summary

  • Adds an opt-in Chunked manifest placement. Large part files are split by a rolling hash (FastCDC / Gear) into ordinary CAS blobs — same key space, envelope, .meta, and GC. No Tree kind, no pack files.
  • Off by default (cas_chunking_enabled). Disk setting, captured at mount; changing it needs a restart and only affects new writes. Existing chunked parts stay readable with the flag off.
  • Closes the restic comparison on the workload that matches how a lot of ClickHouse is actually used.

Where it shines (the common ClickHouse shape)

Append-only, time-ordered tables — logs, metrics, events; PARTITION BY day/month; ORDER BY starting with time; batches landing roughly in time order.

Part ranges barely overlap, so a merge is close to concatenation. The uncompressed granules going into LZ4 are the same as in the inputs; LZ4 of the same bytes is the same bytes; CDC reuses those runs.

That is the measured case, and many production use cases match it.

CDC still does not help when a merge weaves overlapping keys (ORDER BY user_id with concurrent writers, ReplacingMergeTree upserts). Those re-pack compression frames throughout; chunking then costs extra PUTs for no byte win, so in this case the setting should not be turned on.

Gains on that use case

Same protocol as #2314, finding 5, at the larger size: 16 Wide parts of ~42 MB, CODEC(LZ4), unique sipHash payload, restic after every insert and after OPTIMIZE FINAL, then CAS GC + restic forget --keep-last 1 --prune.

step RMT local CAS S3 (off) CAS S3 (on) restic S3
after 16 INSERTs 674 816 275 674 836 737 674 885 181 671 669 908
after OPTIMIZE 1 856 769 946 1 856 789 552 845 692 484 (~2.2x smaller than regular CAS) 714 690 049
CAS GC 1 856 769 946 1 856 801 807 845 740 839 (~2.2x smaller than regular CAS) 714 690 049
after prune 1 856 769 946 1 856 801 807 845 740 839 (~2.2x smaller than regular CAS) 714 599 728
OPTIMIZE FINAL S3 write bytes 1 181 953 697 170 808 185 (~6.9x smaller than regular CAS)

With chunking, CAS is ~2.2× more storage-efficient than whole-file addressing on this merge (1.86 GB → 846 MB) and closes the 2.61× restic gap to 1.18×. SYSTEM CAS FSCK: 475 blob refs → 210 distinct blobs, 0 dangling.

Object storage bills the write. OPTIMIZE FINAL was putting 1.18 GB of objects that were already in the bucket as the 16 input parts. Chunking puts 171 MB instead. Those PUT bytes are charged whether or not GC would later drop the duplicate whole files. Background merges keep parent and child parts live together for old_parts_lifetime, so that write amplification is the steady cost, not only OPTIMIZE FINAL.

The insert phase is a wash on unique data (nothing to share) and pays more requests (d_cas_blob_put per insert 4 → 20; objects 197 → 489). Raise cas_chunk_min_bytes / cas_chunk_avg_bytes to trade granularity for fewer requests.

Test plan

  • 04344_cas_chunking — split is real; scans, point reads, range reads, merge, mutation, post-GC vs MergeTree oracle
  • CAS unit tests including new chunker (11) and manifest (10) tests
  • Confirm default remains off: existing CAS disks unchanged until cas_chunking_enabled is set and the server restarts
  • Re-run gist workload-merge-lz4-large with the flag on vs off if reviewing storage numbers

DimensionWieldr and others added 2 commits September 14, 2026 10:42
A CAS blob is one whole part file, so two files deduplicate only when they are
byte-for-byte identical. A merge or mutation that re-emits most of its input
bytes therefore publishes entirely new blobs, which is the storage cost measured
in Altinity#2314.

Add an opt-in `Chunked` manifest-entry placement: a large part file is split at
boundaries chosen by a rolling hash over its content, and each chunk becomes an
ordinary blob. Because the boundaries follow the content, an unchanged run lands
in the same chunk regardless of what moved around it, so a rewrite pays only for
the chunks that actually changed.

A chunk is an ordinary blob in every respect -- same key space, envelope,
freshness sidecar and GC treatment -- so this adds a manifest placement, not an
object kind. No `Tree` object kind is revived and no pack files are introduced.

Measured on the 16 x 42 MB CODEC(LZ4) merge from Altinity#2314, chunking off vs on:
bytes written by OPTIMIZE FINAL 1,181,953,697 -> 170,808,185 (-85.5%), pool size
1,856,801,807 -> 845,740,839 (-54.5%), CAS/restic ratio 2.61x -> 1.18x. fsck
reports 475 blob references resolving to 210 distinct blobs. The cost is request
amplification: blob PUTs per insert 4 -> 20, object count 197 -> 489.

Off by default. It only pays off when a rewrite re-emits byte-identical
compressed frames (parts that concatenate rather than interleave); an
interleaving merge that re-sorts rows shares close to nothing.

Details:

- `Primitives/CasContentChunker`: Gear rolling hash (FastCDC shape), gear table
  generated by constexpr splitmix64 from a pinned seed so every build cuts
  identically. `feed` reports the boundary as an explicit flag rather than
  implying it from the consumed count, so cut positions never depend on the
  caller's slice size -- otherwise two writers of identical bytes would disagree
  on boundaries and deduplicate nothing.
- Manifest format: `EntryPlacement::Chunked` plus a per-entry `ChunkRef` list.
  Chunk records ride their own lines rather than a nested array, because
  `line_cap` is 64 KiB and a multi-GB column file would breach it as one line.
  The chunk count uses the `!nchunks` critical-key spelling, so a build without
  this placement reports UNKNOWN_FORMAT_VERSION instead of corruption -- no
  pool-wide `G_BUILD` bump, which would have restamped every object class.
- Read path: `ReadBufferFromRemoteFSGather` takes one uniform
  `object_payload_offset` (the envelope length is pool-wide) and hands the
  creator the physical object size, so a wrapped cache layer still sees the real
  object length. `StoredObject::bytes_size` keeps meaning the logical
  contribution, leaving all existing offset arithmetic untouched.
- Write path: `CaContentWriteBuffer` gains a chunked local-staging mode; the two
  existing constructors are unchanged. S3 staging stays unchunked, since its
  promote is a verbatim server-side copy of one [header][payload] object. A
  stream yielding fewer than two chunks is published as an ordinary whole-file
  blob, so small, empty and sub-floor files keep today's exact bytes and keys.
- GC, fsck and relink adoption iterate an entry's referenced blobs through the
  new `forEachEntryBlobRef`, so a chunked entry emits one source edge per chunk.
  The edge key is already `(BlobRef, source_id)`, so no GC model change.

Tests: 11 chunker tests (determinism, slice-size independence, boundary re-sync
after a byte shift, max_bytes ceiling on repetitive input, pinned cut positions),
10 manifest tests (round trip, chunk-list digest coverage, inconsistent-total and
chunk-count-cap rejection), and 04344_cas_chunking.sh, which asserts a file
really was split and then compares scans, point reads, bounded range reads,
merge, mutation and post-GC reads against a plain MergeTree oracle.

Co-authored-by: Cursor <cursoragent@cursor.com>
@DimensionWieldr

DimensionWieldr commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Summarizing the discussion on #2314:

Append-only, time-ordered tables are a common ClickHouse shape: logs, metrics, events; PARTITION BY day/month; ORDER BY starting with time; batches landing roughly in time order. Part ranges barely overlap, so a merge is close to concatenation. The uncompressed granules going into LZ4 are the same as in the inputs; LZ4 of the same bytes is the same bytes; CDC reuses those runs.

That is where this shines, and many use cases match it.

On that workload (16 × ~42 MB Wide, CODEC(LZ4)):

  • Restic comparison: after prune, whole-file CAS was 2.61× restic. Chunking takes that to 1.18×.
  • Pool size: 1.86 GB → 846 MB (~2.2× less stored while parent and child parts are both live).
  • OPTIMIZE FINAL S3 write bytes 1.18 GB → 171 MB (~6.9x smaller). This is significant because object storage bills those PUTs even if GC would later drop the duplicate whole files.

The close-comment caveat still holds for interleaved merges (overlapping keys, upserts): those re-pack frames throughout, CDC shares close to nothing, and you still pay extra requests. Whole-file hashing already covers byte-identical files. Chunking is for the file that changed but whose interior compressed runs did not — which is exactly the concatenative merge above.

Off by default; disk setting cas_chunking_enabled; restart to toggle; only new writes are affected.

@filimonov

Copy link
Copy Markdown
Member

I am skeptical, and I do not expect this to be merged. It buys a small byte saving with more objects, more requests, a second pass over every written byte, and a third manifest placement. CAS needs the opposite right now: fewer objects, fewer requests, less code, and the existing parts working reliably. Better to say this now than after more of your time goes in.

Why I think the benchmark does not transfer:

  • The 85 % comes from alignment the harness guarantees. A merge re-cuts granules by row count and recompresses every frame. Part B stays byte-identical inside the merged part only if every preceding row count is a multiple of index_granularity, rows are fixed-length, and granules exceed min_compress_block_size. workload-merge-lz4-large meets all three by construction: rows_per_part = 16384, fixed 2560-byte payload, sequential ORDER BY id. Real ingest does not, so CDC finds nothing and still pays per chunk.
  • Fragmentation is a cost on every path. One column file becomes a list of ~2–5 MB objects. Long reads cross object boundaries and turn into several requests. Every read has to map file offsets onto chunk lists and skip an envelope per object. Every write spills and hashes many temp files instead of one, then does a HEAD plus .meta check per chunk instead of one per file, and GC tracks and deletes each chunk separately with an unbatchable exact-token delete. Objects 197 → 489, PUTs per insert 8 → 25 in your own run. Request cost is as real as storage cost, and here it only goes up.
  • CPU is unmeasured. The chunker is a serial shift-add per byte on top of the content hash.

If you want to check it yourself, two runs are enough:

  1. Same large workload with rows_per_part = 16000 and payload = randomPrintableASCII(rand() % 2560). I expect d_cas_blob_avoided on optimize_final near zero instead of 218.
  2. One real dataset (hits or GitHub events), random batch sizes, background merges, chunking on vs off: pool bytes, object count, requests by class, merge time.

I would be glad to be wrong. Either way, please write the result up here or in #2314. A clear negative result with data is a real outcome and will save the next person the same experiment.

@DimensionWieldr

Copy link
Copy Markdown
Collaborator Author

Review follow-up: correctness coverage, operator docs, and an operational envelope. Raw tables: https://gist.github.com/DimensionWieldr/03f8e29af8b07bc46de4463b0d47434a (one-shot, not CI). The 16 × 42 MB headline remains the issue #2314 harness.

Where the review notes landed

Note Where
Pre-feature decoder → UNKNOWN_FORMAT_VERSION; write-path 65536 cap; S3 staging does not chunk gtests + 04344 / 04345
Defaults, object-count math, 65536 / 1 TiB, local vs S3 staging, cache-key-per-chunk, mixed-version / downgrade / backup docs/en/antalya/cas/configuration.md #chunking, operations/migration.md #chunking-versions
Codec, chunk size, larger parts, interleave, ReplacingMergeTree, mutations, cold/warm GET, concurrent readers, high object count, PUT/GET list-price cost this comment + envelope gist
Concatenative merge ProfileEvents, RMT insert/select, Replacing + small UPDATE vs local oracle, cache-wrapped cold/warm cas/tests/chunking.py with --cas-s3-cache (4/4 OK)

Not in this round: soak / alter-stress, mixed-package N vs N−1, a hits / GitHub-events ingest, chunker CPU.

inserts ProfileEvent columns in the gist TSV are process-wide totals. The rows below are per-step deltas. PUT/GET cost is count × published AWS S3 us-east-1 list prices ($0.005 / 1000 PUTs, $0.0004 / 1000 GETs), not a bill.

16 × 42 MB concatenative LZ4 (unchanged headline)

OPTIMIZE FINAL write pool after GC objects after GC
chunking off 1.18 GB 1.86 GB 197
chunking on 171 MB (−85%) 846 MB (−54%) 489

Codec / chunk-size / larger parts (4 concatenative Wide parts unless noted)

Production floors 1 / 4 / 16 MiB. sipHash payload, not the 16 × 42 MB run.

cell avoided S3 PUT S3 GET write bytes objects PUT $
codec_LZ4_off 0 35 45 84.6 MB 65 0.000175
codec_LZ4_on 10 53 67 55.3 MB 87 0.000265
codec_ZSTD_off 0 36 56 84.3 MB 65 0.000180
codec_ZSTD_on 10 53 67 55.1 MB 87 0.000265
codec_NONE_off 0 36 55 84.6 MB 65 0.000180
codec_NONE_on 10 54 67 55.3 MB 87 0.000270
chunksize_prod 10 54 68 55.3 MB 87 0.000270
chunksize_coarse (min 4 MiB) 4 36 51 73.2 MB 69 0.000180
large_parts_lz4_on (2 × 50k rows) 54 184 476 273 MB 209 0.000920

Codec is not the discriminator — LZ4 / ZSTD / NONE are the same shape. Coarser chunks: fewer objects, fewer avoided PUTs, still below the unchunked write.

Interleave, ReplacingMergeTree, mutations

cell avoided write bytes notes
interleave_lz4_on 0 84.6 MB overlapping keys; same write as unchunked LZ4
replacing_lz4_on 6 52.6 MB checksum matched a local ReplacingMergeTree oracle
mutation_small (id < 1000) 0 21.1 MB oracle matched; rewritten granules are new bytes
mutation_rewrite (id >= 0) 4 42.2 MB oracle matched; almost a full-column rewrite

Cold/warm cache, concurrent readers, high object count

cell avoided S3 PUT S3 GET write bytes objects
cold_read 0 29 467 n/a 568
warm_read 0 1 0 n/a 568
optimize_with_readers 32 50 102 56.9 MB 106
high_object_count insert 544
high_object_count optimize 534 12 1074 287 KB 547

Cold then warm is GET count, not an SLA (wall clock was 821 ms then 1560 ms). Four concurrent readers during OPTIMIZE FINAL returned the same checksum (~415–433 ms). 64 KiB / 128 KiB floors on a ~42 MB column are the high-object corner — leave production floors at 1 / 4 / 16 MiB unless you are measuring reuse.

Alignment check (re: @filimonov)

Ran (1): rows_per_part = 16000 (not a multiple of index_granularity = 8192) and payload = randomPrintableASCII(1 + rand() % 2560), four parts, chunking on, production floors.

OPTIMIZE FINAL: CASBlobBodyPutAvoided = 4 (not 218), write 129 MB, 94 PUTs. The 85% figure does not transfer onto variable-length misaligned ingest. CDC has nothing identical to reuse and you still pay per chunk.

(2) hits / GitHub events with random batch sizes is not in this envelope. Chunker CPU is still unmeasured.

A related data point: the 4 × 4096-row sipHash cells are also not a multiple of 8192, and they still avoid 10 body PUTs and cut optimize write 84.6 → 55.3 MB. Fixed-length incompressible payload can share something even when a part is smaller than one granule. Variable-length re-packing is what collapses it.

Operator facts from this round are in #chunking (defaults / object-count math / 65536 cap / local-vs-S3 staging / cache keys / mixed-version, plus the envelope conclusions above).

@DimensionWieldr

DimensionWieldr commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

@filimonov

I largely agree that the initial tested use case is too specific. I narrowed down the criteria for chunking to actually be useful:

  1. The merge must not re-sort overlapping keys (time-ordered append, not upsert/interleave).
  2. MergeTree must recompress the same granule bytes it already stored (row-count alignment helps; variable lengths and odd batch sizes hurt).
  3. That identical compressed run must be ≥ cas_chunk_min_bytes (1 MiB by default).

Miss any one of those and you pay extra objects for almost no reuse.

Most tables use at least one of: variable-length strings, irregular insert sizes, and overlapping keys. That's the case where this feature costs requests and saves almost nothing. The 85% figure is a lab merge I constructed to keep compressed frames stable. I have not shown a real dataset that behaves that way, which is going to be my next test.

@filimonov

filimonov commented Sep 16, 2026

Copy link
Copy Markdown
Member

I narrowed down the criteria for chunking to actually be useful

IRL those are never true. You're taking wrong assumptions as your foundation. Real data never follow the rules. Insert come in parallel, out of order, different batch sizes, every row have different size etc.

Also - you need at least 5-10 gigabytes (better few dozens) of data to compare something in clickhouse.

Before that size everything is 'almost instant'.

@DimensionWieldr

DimensionWieldr commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

ClickBench hits https://clickhouse.com/docs/get-started/sample-datasets/anon-web-analytics-metrica (3 million rows, 267 irregular inserts, background merges left on). Chunking vs off:

Chunking vs off
Pool size 1.0× (2.42 GB vs 2.42 GB)
OPTIMIZE write bytes 1.0× (1.07 GB vs 1.07 GB, 0.5% less)
Load + background-merge write 1.0×
Objects 1.01× more (59 298 vs 58 898)
OPTIMIZE S3 PUTs 1.3× more (1360 vs 1036)

The lab merge was 6.9× fewer write bytes and 2.2× smaller pool. That saving does not appear here.

Looks like with a real-world data set, there is next to no benefit, and actually more detriment because of the increased PUTs.

@filimonov I agree that with the current state of how chunking is handled, it is not really useful. Thanks for helping review this experiment!

@DimensionWieldr

Copy link
Copy Markdown
Collaborator Author

Tried another dataset just to see.

Public Bitcoin transactions from AWS https://registry.opendata.aws/aws-public-blockchain/ (one week, 4.3 million rows). Same test as hits: uneven insert sizes, merges left running. Rows are stored in block order, which is the case where chunking was supposed to help. After leftover files were cleaned up:

Chunking vs off
Pool size 1.0× (3.48 GB vs 3.48 GB)
OPTIMIZE write bytes 1.0× (6.98 GB vs 6.97 GB, 0.2% more)
Load + background-merge write ~1.0×
Objects 4.2× more (1 815 vs 436)
OPTIMIZE S3 PUTs 24× more (25 907 vs 1 091)

The final merge reused a small number of chunks (about 1,500 skipped uploads vs none without chunking), but the amount of data written did not drop. Large files were split into ~5 MB pieces, so we paid for more objects and more uploads to store the same 3.48 GB.

Chunking looks at ClickHouse’s own compressed files, not the original parquet on S3. Loading the dump rewrites the data, so similar-looking files in the public bucket do not get reused.

Bitcoin scripts are a different length on almost every row. A merge compresses that data again, and the result is new bytes. Chunking can only skip an upload when those bytes come out identical.

Same conclusion as hits: no saving on data written, and more uploads. The lab merge that wrote 6.9× less and stored 2.2× less only happens in the small case where a merge reproduces the same compressed bytes.

Post-compression CDC never shared across recompress. MergeTree now cuts
uncompressed column bytes into ClickHouse compressed blocks so concatenative
background merges can re-reference the same CAS blobs.

Co-authored-by: Cursor <cursoragent@cursor.com>
@DimensionWieldr

DimensionWieldr commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Grok summary of commit 2af4bc1:

Chunking used to run FastCDC on already-compressed .bin bytes. LZ4/ZSTD do not emit the same ciphertext when the same rows are recompressed, so merges never shared blobs.

It now cuts uncompressed column bytes. Each window is one ClickHouse compressed block, and CAS stores that block. A merge that re-emits the same uncompressed run can reuse those blobs.

Results (chunking off vs on)

Bitcoin transactions, 2024-06-01..07. Checksums matched.

step storage objects PUTs
after background merges 14.39 GB → 11.20 GB (−22%) 59k → 40k (−33%) 1.44× insert PUTs
OPTIMIZE FINAL 10.14 GB → 12.60 GB (pool still holding old+new) 2.4k → 27k ~23× PUTs, almost no reuse (6 avoided)
after GC 7.01 GB vs 7.03 GB 881 → 4578 unique live bytes unchanged

Why we cannot improve both storage and PUTs

Chunking only helps when two live parts contain the same uncompressed runs. That is the −22% after background merges: insert parts and merge outputs still overlap, so some blobs are shared.

OPTIMIZE FINAL then rewrites everything into one part. Variable-length rows re-pack granules, so those bytes are new. After GC, unique storage is the same (~7.01 vs ~7.03 GB) and that rewrite was ~23× PUTs. Finer chunks would only add more PUTs; they cannot make one copy of unique data smaller.

So a unique-storage win and fewer PUTs together are not feasible. The 22% only exists while leftover parts are still around, so it makes it hard to justify the PUT costs.

@filimonov

Copy link
Copy Markdown
Member

What is the problem you trying to solve here? By any cost show that we can reuse some data blocks between parts "before and after the merge" with significant complexity explosion, data fragmentation etc? Do you know that original parts get removed 8 minutes after the merge? So where is the win?

It would be good to discuss such work & ideas before burning tokens on that :)

@DimensionWieldr

DimensionWieldr commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

What is the problem you trying to solve here? By any cost show that we can reuse some data blocks between parts "before and after the merge" with significant complexity explosion, data fragmentation etc? Do you know that original parts get removed 8 minutes after the merge? So where is the win?

It would be good to discuss such work & ideas before burning tokens on that :)

Just experimenting. Not actually pushing for any big changes. The win at this point is really just confirming that the original full-file/blob implementation was the correct decision.

It has also helped me to learn a lot about CAS as a junior engineer, so there's that too.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants