Add RepMaxCDC as a selectable remote cache chunking function#30131
Add RepMaxCDC as a selectable remote cache chunking function#30131ifutivic wants to merge 11 commits into
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
--experimental_remote_cache_chunking currently always uses FastCDC 2020 to split large blobs. The Remote Execution API also defines the RepMaxCDC chunking function (REP_MAX_CDC / RepMaxCdcParams), as implemented by https://github.com/buildbarn/go-cdc, but Bazel does not implement it as a client. This change adds a RepMaxCDC implementation and a new flag, --experimental_remote_cache_chunking_function={fast_cdc_2020,rep_max_cdc}, to select the chunking function. The default remains FastCDC 2020, so existing behavior is unchanged. - RepMaxCdcChunker ports the algorithm from the Go reference implementation. Chunk boundaries are byte-identical to the reference implementation; the test vectors in RepMaxCdcChunkerTest were generated with it. - ChunkingConfig becomes a sealed interface with per-function parameter records (FastCdcChunkingConfig, RepMaxCdcChunkingConfig) negotiated from the server capabilities (FastCdc2020Params / RepMaxCdcParams). - Both chunkers implement ContentDefinedChunker and share the Gear table (GearTable). - Capability validation, SplitBlob/SpliceBlob requests, and the remote worker (capabilities advertisement, splice bookkeeping) use the selected function instead of hardcoding FAST_CDC_2020. - Tests mirror the existing FastCDC coverage: chunker unit tests with reference test vectors, config negotiation tests, a JMH benchmark, and an integration test running the chunked cache flow end-to-end with RepMaxCDC against the remote worker.
6f0d14e to
60b3271
Compare
The previous RepMaxCdcChunker was a port of go-cdc's simple reference implementation, which rehashes the full lookahead window for every chunk. With the default parameters (horizon = 8x the minimum chunk size) this makes chunking 5-7x slower than FastCDC, as documented in the go-cdc README. Replace it with a port of the optimized implementation (rep_max_content_defined_chunker.go), which hashes every input byte exactly once by carrying cutting point candidates and rolling hash state across chunks. JMH results (BLAKE3 digests included, Apple M series): 64 MiB input before after FastCDC throughput 2.5 ops/s 12.3 ops/s 17.9 ops/s Following go-cdc's own testing strategy, the simple implementation is kept as a test-only reference (SimpleRepMaxCdcChunker), and the two are asserted to produce identical chunks across randomized parameters and inputs, including low-entropy inputs and buffer refill boundaries. The existing test vectors, generated with the optimized Go implementation, are unchanged and continue to pass.
Drop SimpleRepMaxCdcChunker from the test package. The randomized tests that previously compared the optimized implementation against it now verify the algorithm's guarantees directly: chunk sizes are within [minChunkSize, 2 * minChunkSize), the final chunk may only be smaller than minChunkSize if the entire input is, chunk sizes add up to the input size, and chunking is deterministic. Cross-implementation correctness remains covered by the test vectors generated with the Go reference implementation.
|
Hello Ivan,
Yeah, these numbers seem to be expected. FastCDC and RepMaxCDC should have virtually the same throughput when it comes to raw input processing, as both use Gear hashing. However, RepMaxCDC needs to do slightly more work:
The above may also not be an apples to apples comparison. With the parameters used above, FastCDC will likely have a true average chunks size closer to 600 KB, as the 512 KB you stated is what is in FastCDC referred to as the 'expected' or 'normal' size. In the case of RepMaxCDC you'll likely see that the average chunk size is about
Glad to see the Go implementation was of use! |
|
Hi Ed, thanks for taking a look!
I reran the benchmark with comparable chunk sizes by setting RepMaxCDC's min to 448 KiB (
Seems like matching the chunk sizes changes almost nothing: RepMaxCDC at min 448 KiB is identical to min 256 KiB within error, even though it produces ~1.75x fewer chunks. |
The hot loop's serial dependency chain (one shift+add per byte) caps throughput regardless of superscalar width. The Gear recurrence is linear, so the hashes of the next four positions can be computed directly from the hash at the block start: h(i+k) = (hash << k) + s(k), where s(k) is the Gear sum of the k block bytes. This shortens the serial chain to one shift+add per four bytes; the remaining work is independent and executes in parallel on an out-of-order core. All intermediate hash values are materialized exactly, so cutting points and bestHash updates are unchanged and chunk boundaries stay byte-identical to the Go reference implementation; the existing test vectors verify this. The tail of a hash region is handled by the original scalar loop. JMH, Apple M series, 64 MiB random input, default parameters: raw chunking goes from 1.32 GiB/s to 1.89 GiB/s (+43%), and chunkToDigests including BLAKE3 chunk digests from 0.81 GiB/s to 0.98 GiB/s, about 85% of FastCDC throughput (previously 69%). The speedup is capped by the per-byte Gear table load, which is why the gain is not 4x and why wider unrolling does not pay.
|
Hey @EdSchouten, while looking into the throughput gap I found an optimization for the gear hash loop. In It's worth ~1.4x on raw chunking throughput (1.32 to 1.89 GiB/s here), which brings RepMaxCDC to ~85% of FastCDC in the end-to-end benchmark. I updated the PR description with the new numbers. The unroll factor is an empirical optimum. 2/4/8-way give 1.38/1.89/1.48 GiB/s. The change is in eca3101 on this PR. Since you're the original author of RepMaxCDC, would you mind taking a look to make sure I'm not breaking a case the tests don't catch? And if it holds up, the same trick should apply to the Go implementation, so it might make sense to add it to go-cdc as well. Happy to send a PR there if you're interested. |
Benchmarking the unroll factor showed that wider unrolling regresses rather than plateaus: two-way and eight-way measured 1.38 and 1.48 GiB/s respectively against 1.89 GiB/s for four-way, with eight-way losing to register pressure. Update the comment to state the measured trade-off instead of claiming there is merely no further gain.
|
Yeah, that seems about right. I've also seen implementations where they have multiple Gear tables. A stock one, and one that is pre-shifted by 1 or more bits. In theory we could also make a change like that where we compute I was also wondering, what would the impact of something like this be? long original_best = best;
if (Long.compareUnsigned(best, h1) < 0) {
best = h1;
incompleteChunks.add(currentChunk + idx + 1);
}
if (Long.compareUnsigned(original_best, h2) < 0 && Long.compareUnsigned(best, h2) < 0) {
best = h2;
incompleteChunks.add(currentChunk + idx + 2);
}
if (Long.compareUnsigned(original_best, h3) < 0 && Long.compareUnsigned(best, h3) < 0) {
best = h3;
incompleteChunks.add(currentChunk + idx + 3);
}
if (Long.compareUnsigned(original_best, h4) < 0 && Long.compareUnsigned(best, h4) < 0) {
best = h4;
incompleteChunks.add(currentChunk + idx + 4);
}As in, only compare against the latest With regards to making a change like that to the Go implementation: go for it! The reason I didn't fiddle with any of that was because I was mainly interested in designing/optimizing the algorithm at a high level, not a low level. |
|
I benchmarked your I think that we're currently very close to the practical maximum without changing how the function fundamentally works. What remains of the gap to FastCDC looks like the sub-minimum cut-point skipping you mentioned earlier. With min = 128 KiB and a ~595 KiB true average chunk size, FastCDC never hashes ~22% of the input, while RepMaxCDC has to hash every byte to track the maxima. This lines up well with the performance difference we're seeing between the two functions when it comes to pure chunking throughput (~22% slower). Pure chunking throughput (no digests, 64 MiB random input, default parameters, JMH single thread, Apple M-series):
Note that the table in the PR description includes the BLAKE3 digest calculation, which runs over every byte for both functions, so the relative gap is smaller there. I'll put together a PR for go-cdc with the unroll once this (hopefully) gets merged, thanks for the green light! |
|
While through put is an important side of this implementation, the other side would be chunk deduplication and size distribution. it would be interesting to see how this algorithm impacts something like a local disk cache after building a range of commits in bazel.git, or some other bazel repo, in comparison to no chunking and fastcdc it would also be nice to see how the chunk size distribution differs versus fastcdc once those benchmarks finished |
I think that all of these things are well understood at this point in time, as the findings are documented in the README.md of go-cdc.
|
|
To add to what Ed said: the reason this thread has been so focused on throughput is that it's the only property where this PR could differ from the reference. Chunk boundaries produced by this implementation are byte-identical to go-cdc (the test vectors were generated with it), so the size distribution and deduplication behavior documented there carry over 1:1. What the Java port can get wrong is speed, so the benchmarking here was about making sure the port doesn't regress against Ed's Go implementation and holds up against the FastCDC implementation already in Bazel. That said, a dedup comparison on actual Bazel build outputs (rather than kernel tarballs) at the chunk sizes this PR defaults to would still be interesting as follow-up data, |
|
I ran the disk cache experiment @sluongng suggested. Setup: built Commits: bf0de33 -> 99be0f5 -> 9ae0848 CAS size after each commit:
RepMaxCDC ends up ~6% smaller than FastCDC overall. A caveat: at default parameters part of that comes from RepMaxCDC chunking at finer granularity (~340 KiB vs ~600 KiB average chunk size) and having a lower chunking threshold (512 KiB vs 2 MiB). At matched average chunk sizes Ed measured 2-3% when testing using the Linux kernel. Blob size distribution in the CAS after the final commit (all blobs, including small unchunked outputs):
|
Both implementations throw IOException solely from InputStream reads; chunking and digest computation are pure in-memory operations.
The threshold equals the maximum chunk size by definition: blobs above it split into at least two chunks, while blobs at or below it would come back as a single chunk, making chunking pointless.
With auto, which is the new default, the chunking function is negotiated from the server capabilities: FastCDC 2020 is used if the server advertises it (including when it advertises both, for backward compatibility), otherwise RepMaxCDC. Chunking fails the capability check only if the server advertises neither function. The explicit fast_cdc_2020 and rep_max_cdc values keep requiring the selected function to be advertised. The negotiated function now flows explicitly from the resolved ChunkingConfig into the SplitBlob/SpliceBlob requests instead of being derived from the flag, so requests always carry the function that was actually used for chunking.
fmeum
left a comment
There was a problem hiding this comment.
LGTM, but the way to select the algorithm may need more thought

Description
Adds support for the RepMaxCDC function to the remote cache chunking feature, selectable via a new flag
--experimental_remote_cache_chunking_function={auto,fast_cdc_2020,rep_max_cdc}. Withauto(the default) the function is negotiated from the server capabilities: FastCDC 2020 is used if the server advertises it (including when it advertises both, for backward compatibility), otherwise RepMaxCDC. The explicit values require the selected function to be advertised by the server. Against servers that advertise FastCDC 2020 today, behavior is unchanged.RepMaxCdcChunkerports the algorithm from the Go reference implementation (buildbarn/go-cdc). Chunk boundaries are byte-identical to the reference implementation. The test vectors inRepMaxCdcChunkerTestwere generated by running it on the same test image used by the FastCDC test vectors.ChunkingConfigbecomes a sealed interface with per-function parameter records (FastCdcChunkingConfig,RepMaxCdcChunkingConfig) negotiated from the server capabilities (FastCdc2020Params/RepMaxCdcParams).ContentDefinedChunkerand share the Gear table (GearTable).SplitBlob/SpliceBlobrequests, and the remote worker use the selected function instead of hardcodingFAST_CDC_2020.Performance
JMH throughput of
chunkToDigests(chunking plus BLAKE3 chunk digests, single thread, Apple M-series). Average chunk sizes are measured: FastCDC's 512 KiB parameter is its "expected" size and comes out at ~595 KiB in practice, so RepMaxCDC is shown both with its defaults and with min = 448 KiB, which matches FastCDC's true average chunk size:RepMaxCDC reaches ~85% of FastCDC throughput.
Note: these numbers include one additional optimization compared to the original Go implementation, a four-way unrolling of the Gear hash loop. Chunk boundaries are unaffected and the test vectors are still the same. Without it, RepMaxCDC sits at ~70-75% of FastCDC.
RepMaxCdcChunkercarries a small primitiveIntListhelper instead of usingArrayList<Integer>: a variant backed by a boxed list measured ~2.2x slower overall.Disclaimer
This PR heavily leveraged Claude Fable to rewrite the Go implementation of RepMaxCDC into Java but the code was manually reviewed by me (which is not saying a lot since I'm not a Java dev :D).
Motivation
Tracking issue: #30132
--experimental_remote_cache_chunkingcurrently always uses FastCDC 2020 to split large blobs. The Remote Execution API also defines the RepMaxCDC chunking function (REP_MAX_CDC/RepMaxCdcParams), as implemented by buildbarn/go-cdc, but Bazel does not implement it as a client.RepMaxCDC provides a deduplication rate comparable to FastCDC while giving tight bounds on chunk sizes: all chunks fall within
[min_chunk_size, 2 * min_chunk_size), so for a given blob it is trivial to check whether it is already chunked purely by looking at its size. Supporting it lets Bazel clients share chunk data with servers and other clients that use RepMaxCDC (e.g. future Buildbarn deployments).Build API Changes
This PR adds a new experimental command-line flag,
--experimental_remote_cache_chunking_function, and extends the existing--experimental_remote_cache_chunkingfeature.ChunkingFunction.REP_MAX_CDC/RepMaxCdcParams); this PR implements the client side in Bazel.auto, which negotiates the function from the server capabilities and prefers FastCDC 2020 whenever the server advertises it, preserving the current behavior of--experimental_remote_cache_chunkingagainst existing servers.Checklist
Release Notes
RELNOTES[NEW]: Added
--experimental_remote_cache_chunking_functionto select the content-defined chunking function used by--experimental_remote_cache_chunking. Supported values areauto(the default, which negotiates the function from the server capabilities, preferring FastCDC 2020),fast_cdc_2020andrep_max_cdc.