Bazel 的 remote cache 早就會 dedup——只要兩個 action 產出 byte-for-byte 相同的輸出,第二份不會佔新的儲存。問題出在「99% 相同的輸出」這種情況:改一行 source code,得到一個新的 link、新的 archive、新的 deploy bundle——digest 全部變了,cache 把它當成完整的新 blob。BuildBuddy 的解法是把 dedup 從檔案層拉到 sub-file chunk 層,演算法叫 content-defined chunking。
BuildBuddy 把 FastCDC 帶進 Bazel——300 TiB 重複資料消失
BuildBuddy 在 Bazel remote cache 啟用了 content-defined chunking(CDC):上傳前把 ≥2 MiB 的檔案切成內容定義的 chunk、各自編 digest、各自存。之後同一個檔案的後續版本只上傳真正新出現的 chunk,沒變的 chunk 直接命中既有 digest。內部 benchmark 顯示上傳資料量減少 40%、整體 cache 流量節省 20–40%,兩週生產環境累積跳過 ~300 TiB。要看懂這 40% 從哪裡來,必須一路追到 rolling hash 怎麼切點、normalization 怎麼壓 chunk 分布、CAS 怎麼把 chunk 串回原 blob、以及在哪些 build 上這個機制會悄悄失效。
這篇從演算法骨架往實作細節走:先看 rolling hash 為什麼能讓「內容前段插入一個 byte」不會位移後續所有 chunk;接著看 FastCDC 的 normalization 怎麼把切點的幾何分布縮緊到一個窄帶;再轉到 BuildBuddy 為何把 2 MiB 設為啟用門檻、Bazel 的 CAS(content-addressable storage)怎麼用 chunk digest 把原始 blob 串回來;最後看 production 數字、以及什麼樣的 build 會讓 CDC 退化成噪音。每一段都對應一個可以調整或翻轉的參數,懂了參數,就懂了 40% 的邊界在哪。
讀這篇之前最好先有兩個前置認識。第一,Bazel 的 remote cache 是 Action Cache 與 Content-Addressable Storage 兩層分離的設計,CDC 只動 CAS 的儲存策略,不碰 Action Cache 的語意。第二,content-defined chunking 本身是老技術(rsync 90 年代末就用過),FastCDC 是其加速版;「把 CDC 落地到 build system 的 cache 層」是新東西。BuildBuddy 顯然跑了多輪 parameter sweep 才把 2 MiB 門檻與 19-bit mask 鎖定,這篇就是反推這些參數的依據。
rolling hash 與 gear-based boundary detection
CDC 的核心是「切點要由內容決定,不由位置決定」。傳統 fixed-size chunking 每 N 個 byte 切一刀,問題是檔案前段插入一個 byte,後面所有的切點都跟著位移——dedup 整段失效。CDC 改用 rolling hash 在小視窗(例如 48 到 64 byte)上滑動,當 hash 命中某個稀有 pattern 時切一刀。內容不變的區段,rolling hash 抽到相同的切點;新內容只影響到它所跨的 chunk,前後 chunk 保持原 digest。
具體一點。Rolling hash 的定義:取目前位置往前 W byte 的內容,丟進一個 hash function,得到一個整數 h。位置每往前一步,視窗丟掉最舊的一個 byte、納入最新的一個 byte——hash 可以從上一個位置的 h 增量計算,不必每步重算 W byte。這個「增量計算」是「rolling」的字面意思,也是 CDC 在大檔案上仍然 O(n) 的關鍵。Rabin fingerprint 是最早期的 rolling hash 實作,FastCDC 換用了 Gear hash,速度更快、實作更短。
Gear hash 的形式是:對每個輸入 byte b,查一張 256 entry 的 64-bit 隨機表 G[b],然後把 hash 寫成 h = (h << 1) + G[b]。視窗大小是隱式的——shift 出視窗的位元自然被擠掉,但因為 shift 一格就只丟一個 bit 而非整個 byte,實際「有效視窗」由 hash mask 寬度與 shift 行為決定。對 BuildBuddy 的目標 chunk size ~512 KiB,mask 大約是低 19 bit 全部為 0:每 byte 觸發機率 1/524288,rolling 過 512 KiB 會期望命中約一次。命中時切點落在當前 byte,emit 一個 chunk,hash 歸零繼續。
G 表本身怎麼產出?FastCDC paper 與多數實作都用 PRNG 預生成 256 個 64-bit 整數,種子固定——重點不是隨機性「好」,而是兩件事:第一,所有部署的 client 與 server 必須用同一張 G 表,否則 chunk 邊界不會對齊、跨 client 的 dedup 直接失效;第二,G 表的 bit 應該足夠「平均」——每個 bit 在 256 個 entry 裡大約一半為 0 一半為 1,避免某些 bit 永遠是 0 造成 mask 命中機率扭曲。BuildBuddy 沒有公開他們的 G 表,但 Restic、borg、casync 用的是 paper 附錄的 reference table——這在開源 CDC 生態裡形成了事實 standard。如果未來 BuildBuddy 要跟外部工具互通 dedup(例如把 CAS 對接到 Restic backup),G 表的一致性會是第一個要對齊的東西。
為什麼是 Gear hash 而不是 Rabin?Rabin fingerprint 每 byte 約 3.5 ns,Gear hash 約 0.9 ns,差約四倍。Gear hash 的視窗滑出由 shift 隱式處理,不必維護 ring buffer,hot loop 更短。
有了 rolling hash 與 mask test,整個切點偵測迴圈長這樣:
// 對 ≥ 2 MiB 的 input blob 跑 FastCDC-style boundary detection。
// G[256] 是預先生成的 64-bit 隨機 gear 表;MASK 控制平均 chunk 大小。
fn split(input):
h = 0
start = 0
for i in 0..len(input):
h = (h << 1) + G[input[i]]
if (h & MASK) == 0 and i - start >= MIN_CHUNK:
emit_chunk(input[start..i])
start = i
h = 0
else if i - start >= MAX_CHUNK:
// hard cut——避免超大 chunk 撐爆 metadata 預算
emit_chunk(input[start..i])
start = i
h = 0
if start < len(input):
emit_chunk(input[start..])
幾個實作細節值得停下來看。第一,hash 在每次切點之後歸零——這讓相同內容無論之前發生什麼,都會落在相同的下一個切點,是 dedup 命中的關鍵。第二,MIN_CHUNK 與 MAX_CHUNK 的存在意義不只是 metadata 控制:MIN_CHUNK 阻止「mask 在 50 byte 內又命中」這種退化情況,MAX_CHUNK 阻止「mask 一直不命中」導致整檔變成一個 chunk。第三,emit_chunk 內部要算 SHA-256(Bazel CAS 要求),這是 CDC 啟用後 CPU 預算的主要去處——Gear hash 本身極快,CDC 的 CPU bottleneck 在 chunk hashing。
BuildBuddy 在 blog 文末把這個切點偵測稱為 FastCDC 風格,但實作細節沒有完整公開——hash 表的生成、mask 的精確 bit pattern、與 normalization 的邊界都留白。下面這個 widget 用一個合理的模型還原這個過程:rolling 過一段 byte stream,每個位置計算一次 h,命中 mask 的位置標成切點。讀者可以拖 mask bit 數,看平均 chunk size 怎麼跟著移動。
拖 mask bit 數會直接改 avg chunk size(理論上 2^bits byte 一次命中);拖 ins…
mask bit 數決定平均 chunk 大小:太小時一次 edit 讓 1/4 檔案重新上傳;太大時 metadata 開銷吃掉 dedup 節省。
Widget 上看到的趨勢值得記住:mask 從 19 bit 退到 15 bit(avg chunk 從 512 KiB 縮到 32 KiB),dedup ratio 在「單點插入」這種理想情境下其實只小幅改善——因為原本就只有「跨越切點的那個 chunk」需要重新上傳。Mask 從 19 bit 推到 22 bit(avg chunk 4 MiB),單次插入仍然只影響一個 chunk,但這個 chunk 變大了——上傳量隨之上升。BuildBuddy 選 19 附近,是在「metadata 開銷(每 chunk 約 32 byte SHA-256 + index entry)」與「插入影響的單一 chunk 大小」之間取平衡。
還有一個被多數教程跳過的細節:mask 不只一個。FastCDC 同時定義兩個 mask——MASK_S(strict)與 MASK_L(loose),bit 寬度分別比 avg 多一與少一。這對應下一節要講的 normalization:當 chunk 還短於 avg,用 strict mask 讓切點變稀疏、強迫長一點;當 chunk 已長於 avg,改用 loose mask 讓切點密度倍增、快點切掉。兩個 mask 用同樣的 G 表與同樣的 h 變數,差別只在「拿哪個 mask 去 AND」——這讓 normalization 的額外 CPU 成本接近零,是「免費的」chunk size distribution 整形。
normalization:把 chunk size 從幾何分布壓回常態
單純用一個固定 mask 做切點偵測有個問題:實際 chunk size 服從幾何分布。每個位置切點命中機率是 p = 1 / 2^bits,「下一個切點距離 d」是 Geometric(p)——很多很短、極少超長。實際分布的尾巴拖很長,超大 chunk 撐爆 CAS 的 1 MiB blob 上限不是罕見事件。FastCDC 的原始 paper(Xia et al., USENIX ATC 2016)提出 normalization:根據目前 chunk 已累積的長度,動態調整 mask 的嚴格度。
具體做法分三段:
- 0 ≤ len < MIN_CHUNK:mask 完全忽略,永遠不切——強制最小長度。
- MIN_CHUNK ≤ len < AVG_CHUNK:用嚴格 mask(多一個 bit 為 0),讓切點更難命中——壓制小 chunk。
- AVG_CHUNK ≤ len < MAX_CHUNK:用寬鬆 mask(少一個 bit 為 0),讓切點更容易命中——壓制大 chunk。
- len ≥ MAX_CHUNK:硬切——強制最大長度。
結果:chunk size 分布從幾何尾巴變成「集中在 AVG 附近、兩側衰減」的近似常態形狀。BuildBuddy 沒有公開具體的 MIN / AVG / MAX 數字,但根據 FastCDC paper 的典型設置(min = avg/4, max = avg×4),對 avg 512 KiB 來說大概是 128 KiB / 512 KiB / 2 MiB。Min 設 128 KiB 也呼應了 CAS 的 metadata 開銷:每 chunk 32 byte SHA-256 + 索引條目,128 KiB chunk 的 metadata 開銷大約 0.025%,可接受。
Normalization 的 dedup 影響值得再說清楚。乍看之下「同一個位置可能用 strict 也可能用 loose mask」聽起來會破壞 dedup——但其實不會,因為 mask 的選擇由「當前 chunk 已累積長度」決定,而長度由「上一個切點位置」決定,上一個切點由前面內容決定。內容相同的兩個檔案,前面切到同一個位置,後面當然走同樣的 mask 決策——一切都是內容驅動。Normalization 沒有引入額外的隨機性,只是把「平均切點密度」做成 chunk 內部的位置函數。
相較於 rsync、restic、borgbackup 等早期 CDC,FastCDC 借的是 rolling-hash + mask test 的整體結構,真正貢獻在兩件事:把 Gear hash 跟 chunking 場景結合(比 Rabin 快 3-5 倍)、提出 normalization 讓 chunk size 從幾何分布變窄帶常態。BuildBuddy 把這兩個 FastCDC 創新整套搬進 Bazel CAS 路徑,工程貢獻在「把學術 paper 嵌進 production protocol」。
下面這個 widget 把 FastCDC 的 pipeline 拆成五個 stage,每一個是「rolling hash 一個位置」會經過的判斷:先 update hash,再看 chunk 已累積長度落在哪個區段,挑對應的 mask,做 mask test,命中就 emit chunk、重置狀態。點任一 stage 看它在做什麼。
每個 byte 都跑完五個 stage
Gear hash 每 byte 都跑(1–2 GB/s),SHA-256 只在 mask 命中邊界計算——CDC 的 CPU 成本集中在此。
注意 stage 4 → stage 1 的回頭虛線:絕大多數 byte 走的是這條路徑。對 512 KiB avg chunk,平均 524287 個 byte 走 miss 路徑、只有 1 個 byte 走 hit 路徑——CDC 的 CPU 預算幾乎完全花在「Gear hash + mask test + 沒命中、走下一格」這個 tight loop 上。這也回應了為什麼 Gear hash 比 Rabin 快這麼多:對 99.9999% 的 byte 來說,唯一的成本就是 hash 計算本身。
這也解釋了一個經常被誤解的事:CDC 啟用後 client 端的 CPU 並沒有暴增。Hot loop 是「load byte → shift → add → AND → branch」這五個指令,現代 CPU 一個 cycle 就能跑完——對 100 MiB blob 大約 0.3 秒、單核。真正貴的是命中切點之後算 SHA-256——這部分是 chunking 與 non-chunking 都要付的成本,CDC 並沒有引入額外的 SHA-256 計算(chunk 的 SHA-256 加總跟原 blob 的 SHA-256 同階複雜度)。所以從 CPU 預算看,CDC 對 client 端是 net-zero 或微小淨增——換來的是上傳網路量的 40% 節省,這是非常划算的交換。
2 MiB 門檻:CDC 何時不啟動
BuildBuddy 對小於 2 MiB 的檔案不啟用 CDC,直接整檔上傳。原因是小檔案的內容變動率高、又往往無法切出多個有意義的 chunk——CDC 的 metadata 開銷超過節省。Bazel 產出物的大頭通常是 link、archive、bundle、container layer,每個動輒幾十到幾百 MiB——這才是 CDC 真正發揮的對象。
2 MiB 門檻同時也是「相對成本」的閾值:小於這個尺寸時,網路傳輸與儲存的固定開銷比 chunk metadata 還小。BuildBuddy 公開的數字是「大約 4.2% 的 cache 物件超過這個門檻」,這 4.2% 卻佔 cache 總 byte 量極高比例——典型的 power-law 分布。把 CDC 留給這 4.2%、其他直接整檔處理,是一個明確的 cost-benefit 切割。
把這個 Pareto 切法畫出來最直觀。下面這張圖把「累積物件數百分比」與「累積 byte 百分比」畫在同一張座標上(兩個都是 log-X,物件大小由小到大排序);拖動垂直的 threshold 線到 2 MiB,可以同時讀到「右邊有多少比例的物件」與「右邊涵蓋多少比例的 byte」。CDC 工程預算就花在右邊那一小撮高 byte 量物件上。
橫軸是 cache 物件大小(log scale, 1 KiB ~ 1 GiB),縱軸是累積百分比
2 MiB 以上的物件只佔總物件數 4.2%,但佔總 byte 量 72%——CDC 對這部分的 dedup 比全物件抓一點點有效。
BuildBuddy 描述這個策略是「CDC 在 outputs that are large and byte-stable across revisions」最有效——關鍵詞是 byte-stable:source revision 之間 byte 級不漂移,例如不含 timestamp、不含 random ID、不含 build serial number。Bazel 預設 build 是 reproducible 的,這個前提剛好成立。如果你的 toolchain 有沒清掉的 timestamp(例如某些 linker 的 `__DATE__`),那 CDC 仍會找到相同的 hash pattern 切到差不多的位置,但 chunk 的內容仍會變——dedup 退化成接近 0。
2 MiB 也跟 CAS 的 blob 上限相關。Bazel CAS 對單一 blob 沒有硬上限,但實作上 gRPC payload 與 storage backend 都偏好「不要太大」。把 ≥ 2 MiB 的 blob 切成 ~512 KiB chunk,每個 chunk 自己是一個 CAS blob,剛好落在「不浪費 metadata、又不撐爆單 blob」的甜蜜點。Chunk 數量上限:對 100 MiB 的 link 輸出,期望 ~200 個 chunk,每個 32 byte digest + index entry,總 metadata 開銷 ~10 KiB——可以忽略。
有一個容易忽略的設計選擇:BuildBuddy 沒有把「是否要 CDC」做成 per-action 的標籤,而是 per-blob 的 size threshold。這意味著 build graph 上同一個 action 可能輸出多個 file,其中大檔案走 CDC、小檔案直接整檔上傳——cache 對這兩種情況用同一個 lookup 路徑,client 不需要關心。這個 transparency 是讓 `--experimental_remote_cache_chunking` 可以在不改 BUILD 檔的情況下開啟的關鍵。
2 MiB 這個門檻怎麼來的?BuildBuddy 沒明寫推導過程,但邏輯可以反推。假設 avg chunk 是 512 KiB,min chunk 是 128 KiB,那一個 ≥ 2 MiB 的 blob 大致可以切出 ≥ 4 個 chunk——四個 chunk 足夠提供有意義的「修改一處不動其他三處」的 dedup 機會。低於 2 MiB 的 blob 切不出多少 chunk,CDC 退化成「整檔切兩刀」這種沒意義的事;同時每個 chunk 都帶 SHA-256 與 CAS index entry 的固定開銷,小檔案的 metadata 比例會被放大。把門檻設在「期望切出 ≥ 4 chunk」這條線,是 CDC 開銷對節省的盈虧平衡。
更深一層的考量是 cache traffic 的 power-law 分布。BuildBuddy 引用過「4.2% 的 cache object 超過 2 MiB」這個數字,但這 4.2% 在 byte 量上佔比常常是 70-80%——典型的 Pareto 分布。Cache miss 的延遲與帶寬主要由這 4.2% 決定,所以把優化資源全部投在這群「大檔案」上、其他 95.8% 走最簡單的整檔路徑,是經典的 80/20 工程取捨。CDC 不是萬靈丹,是針對 fat tail 的精準武器——這也解釋了為什麼整體 cache traffic 只降 20-40%(而不是 40%+):CDC 只能對 4.2% 的物件做手腳,剩下 95.8% 不受影響。
Bazel CAS 整合:chunk 怎麼被 reference
Bazel 的 remote cache 有兩層結構:Action Cache(AC)儲存「action input digest → action output set」的對映,Content-Addressable Storage(CAS)儲存「blob digest → blob bytes」。CDC 在第二層動手腳:原本一個 100 MiB 的 link 輸出是一個 100 MiB 的 CAS blob、digest 是這 100 MiB 的 SHA-256;啟用 CDC 後,這 100 MiB 被切成 ~200 個 chunk,每個 chunk 自己是一個小 CAS blob、有自己的 digest。原始 blob 的「身分」呢?BuildBuddy 用一個 reconstruction metadata:原 blob 的 digest 仍然是整個 100 MiB 的 SHA-256,但 cache 上對應的不是 100 MiB 的 bytes,而是一個「chunk digest list」——告訴 client「要重組這個 blob,請依序拉這 200 個 chunk」。
// 寫入 100 MiB 的 link 輸出到 CAS(啟用 CDC 之後)
fn upload_blob(blob):
if len(blob) < 2 MiB:
// 小檔案,整檔走原本的 CAS upload 路徑
digest = sha256(blob)
cas.put(digest, blob)
return digest
// 大檔案:切 chunk、各自 upload
chunks = split(blob) // FastCDC pipeline
chunk_digests = []
for chunk in chunks:
d = sha256(chunk.bytes)
chunk_digests.push(d)
if not cas.has(d):
// 只在 cache 不存在時上傳——這是 dedup 真正生效的地方
cas.put(d, chunk.bytes)
blob_digest = sha256(blob) // 原始 blob 的 digest 不變
reconstruction_metadata = serialise(chunk_digests)
cas.put_metadata(blob_digest, reconstruction_metadata)
return blob_digest
關鍵在 `if not cas.has(d)` 這一行——是 CDC 把 dedup 變成可能的地方。對一個小源碼改動而言,~200 個 chunk 裡通常只有 1-2 個是新的,其他 198 個 chunk 的 digest 已經在 CAS 裡(之前的 build 留下的),`cas.has(d)` 回 true、上傳被跳過。網路上傳量從 100 MiB 縮到 ~1 MiB——這就是「40% upload data reduction」的 micro-scale 來源。
實際 upload 流程比 pseudocode 上看的更精緻:client 不會對 200 個 chunk 各打一次 `FindMissingBlobs`,而是把整個 chunk digest list 一次 batch 過去,server 回一個 missing digest 的子集。對 missing 的部分 client 用 `BatchUpdateBlobs` 或 `Write` stream 上傳;non-missing 的部分整批跳過。這個 batch 設計把 RTT 從 200 次砍到 1 次,是 CDC 在高延遲網路上仍能保持 build wall time 持平的關鍵——沒有 batching 的話,光 RPC round-trip 就會吃掉 CDC 省下的網路時間。
讀取路徑也有對稱的好處。Client 拿到 blob_digest,CAS 先回傳 reconstruction metadata(chunk digest list),client 並行拉 200 個 chunk、本地 stitch 回原始 100 MiB。對於已經有本地 cache 的 client,這 200 個 chunk 裡常常已經有大半在本地——本地 cache 也吃到了 sub-file 粒度的 dedup。BuildBuddy 提到「disk cache 縮小 40%」,主要就是這個效果:相同的 200 個 chunk 同時服務多個版本的 blob,磁碟不需要重複儲存。
有個值得注意的細節:blob_digest 仍然是整個原始 blob 的 SHA-256,沒有變。這是 Bazel remote-cache protocol 不能違反的——AC 仍然用 action input → blob digest 的映射,blob digest 必須是 byte content 的 SHA-256,不能換成「chunk digest list 的 hash」否則 protocol 兼容性破壞。BuildBuddy 的設計巧妙之處在於:blob 在 CAS 上的「存在」改用 metadata 替代 bytes,但 blob 的 identity(digest)保持不變。Client 用 `--experimental_remote_cache_chunking` 切換的不是 protocol,是 CAS backend 的「儲存策略」——舊 client 仍然能 read,只是看不到 chunking 的好處。
Cache key 設計上還有兩個細節值得提。第一,chunk 自己的 digest 用 SHA-256(跟原 blob 一致),不是 BLAKE3 或更快的 hash——這是 Bazel CAS protocol 鎖死的選擇。也就是說,chunk 跟 blob 在 storage 層共用同一個 namespace、同一個 lookup index,CAS 不需要為 chunk 開新的 schema。第二,reconstruction metadata 用什麼格式 BuildBuddy 沒明寫,但邏輯上至少要記錄「chunk digest 的有序 list + 每個 chunk 的 byte length」——後者是用來在 read 時提前 reserve buffer、不需要先讀 chunk header 才知道長度。粗估每個 chunk 的 reconstruction entry 約 40 byte(32 byte SHA-256 + 8 byte length),對 100 MiB blob、200 chunk 的 metadata 是 ~8 KiB——對 100 MiB blob 來說 0.008% 的 overhead,可忽略。
Protocol 兼容性的細節值得多說一句。Bazel 的 remote-execution API(REAPI)是個 gRPC protocol,CAS 暴露的就是 `FindMissingBlobs` + `BatchUpdateBlobs` + `BatchReadBlobs` 這幾個方法。CDC 啟用後,client 不直接呼叫 `BatchUpdateBlobs` 上傳 100 MiB blob,而是先呼叫 `FindMissingBlobs` 帶上 200 個 chunk digest,server 回「這 5 個我沒有」,client 只上傳這 5 個。整個流程仍在 REAPI 的標準動詞之內——`FindMissingBlobs` 本來就是為了 batch dedup 設計的,CDC 不過是把它的粒度從「blob」拉到「chunk」。從 protocol 角度看,這是無痛擴展,沒有新動詞、沒有新 message type。Reconstruction metadata 是 BuildBuddy 在 CAS 上額外存的,client 用一個新的 `GetBlobChunks` (或類似) RPC 去讀——這部分是 BuildBuddy 對 REAPI 的私有擴展,但不影響其他 Bazel client 的 read。
把這條 upload 路徑沿著時間軸拖一遍,每個 stage 處理多少 byte、留下多少 byte,會比 pseudocode 更直觀。下面這個 scrubber 模擬一次 incremental build 的 link 輸出(100 MiB blob,200 個 chunk,其中 5 個是新的)走完 client → CAS 的全程:
拖動圓點,看 100 MiB 的 link 輸出怎麼一步一步變成 ~2.5 MiB 的網路傳輸量(5 個新 chunk…
100 MiB 連結輸出中只有 5 個新 chunk 需上傳,網路傳輸量縮到 ~2.5 MiB;FindMissingBlobs 只送 digest。
關鍵讀數在 stage 3 與 stage 5:6.6 KiB 的 digest 交換、2.5 MiB 的 byte 上傳,總網路成本 ~2.51 MiB——對比沒啟用 CDC 的 100 MiB 直接整檔上傳,節省 ~97.5%。Production 平均 40% 是因為「平均」要把 cold cache、第一次 build、無重疊的場景都算進去;單一 incremental 場景的微觀比例會明顯比 40% 高,這也是 BuildBuddy 強調 CI release pipeline 受益最大的原因。
下面這個表整理了 CDC 在 CAS 上引入的所有 metadata 開銷與節省,並對比另外幾種 chunking 方案(fixed-size 與 Rabin fingerprint)。
| scheme | boundary | cpu / GB | single-insert dedup | size distribution |
|---|---|---|---|---|
| fixed-size | 每 N byte 固定切 | 0.1 s | ~0% | δ at N(完全相同) |
| Rabin fingerprint | 多項式 rolling hash + mask | 3.5 s | ~98% | 幾何分布(長尾) |
| FastCDC (vanilla) | Gear hash + mask | 0.9 s | ~98% | 幾何分布(長尾) |
| FastCDC + norm | Gear hash + 雙 mask + min/max | 1.0 s | ~98% | 近似常態(窄帶) |
單一插入的 dedup ratio 在 rolling-hash 家族都接近 98%,但 size distribut…
rolling-hash 家族單次插入 dedup 率都接近 98%,真正 production 差異在於 size distribution 正規化能力。
從表上可以看到,FastCDC(vanilla)跟 Rabin fingerprint 在 dedup 質量上幾乎一樣——98% 上下的 single-insert ratio——但 CPU 預算是 Rabin 的約四分之一。這是 BuildBuddy 選 FastCDC 風格而非 Rabin 的關鍵:cache 寫入路徑上的 CPU 預算是有限的,特別是 BuildBuddy 自己作為 hosted cache service,每秒要處理上 GB 的 ingress——把 chunking 的 CPU 從 Rabin 的 3.5s/GB 砍到 FastCDC 的 1.0s/GB 是直接的 SLA 改善。
production 上看到的數字
BuildBuddy 在自己的 repo 上做了 benchmark,跨兩週的生產資料收尾。Benchmark 設計是「對自家 BuildBuddy repo 跑 50 個連續 commit 的 build」,每次 build 啟用與停用 chunking 各跑一次,比較 upload bytes、disk cache size、build wall time。Production 數字則來自所有用戶啟用 chunking 之後兩週的累積。
絕對值更直觀:兩週累積跳過約 300 TiB 重複 chunk,尖峰時段每小時節省超過 4 TiB。對於 monorepo 與 CI 高度依賴 remote cache 的團隊,這直接折算成 S3 / GCS 出口流量帳單;對於 self-hosted 的 remote cache,則是磁碟採購節奏的延後。BuildBuddy 自己負擔的是「我們的 hosted cache backend 出口帳單」,這個數字省下來直接落到 margin。
Benchmark 的「50 個 commit」設計值得展開一下。對單一 commit 的 build cache miss 是 100%——這時候 CDC 沒幫助(沒有先前的 chunk 可以命中)。Benefit 從第二個 commit 開始累積:相同的 link 輸出、相同的 archive、相同的 deploy bundle 在連續 commit 之間 byte-stable,CDC 把這些 byte-stable 部分當成 chunk-level dedup 目標。50 個 commit 的累積,這個 benefit 趨近一個穩定值——40% 就是 BuildBuddy 自家 repo 在這個 workload 下的穩態。
另一個值得注意的數字:85% 的 written-bytes dedup。這指的是 cache 服務寫入路徑上,「本來要寫入 cache 的 chunk」之中有多少其實已經存在、被跳過。85% 是極高的數字——這個比例說明 BuildBuddy 自家 repo(與類似的客戶 repo)的 build output 大多數 byte 在不同 commit 之間其實沒變,CDC 把這個事實兌現成 cache 寫入的 IOPS 與磁碟空間節省。20-40% 的 cache traffic 比例則涵蓋讀寫兩側,且把 4.2% 以下的小檔案(不經 CDC)也算進來——所以數字比 85% 低,但仍然是兩位數的整體改善。
使用者實際看到什麼?BuildBuddy 強調的是「對 user 透明」——啟用 chunking 後 build time 略降(主要來自上傳變快與 cache hit rate 上升),但沒有需要重新教育 developer 的新概念。對於 monorepo 高度依賴 remote cache 的團隊(Android、iOS app build、Go 大型服務),最敏感的指標通常不是 cache 命中率而是「cold cache 之後 warm 起來有多快」——這條曲線從 CDC 啟用後的數據看會更陡,因為 cache 從第一個 build 之後就以 sub-file 粒度開始積累。對於 CI fleet,這意味著新加入的 worker 不需要從零拉所有 blob——它的本地 disk cache 與 remote cache 都共享 chunk 層的 dedup,warm-up 時間縮短。
BuildBuddy blog 沒給出具體的 build time 數字,但給了一個方向性的句子:「user-facing build latency improvements are most visible on builds dominated by large output uploads」。翻譯:CI 上跑 release artifact 的 build——typically Docker layer push、deploy bundle upload、symbol file archive——這些 step 的 wall time 直接被網路 throughput bound 住,CDC 把上傳量砍 40% 等於 wall time 砍 40%。Dev 端 incremental build 大多數 step 在 1 MiB 以下,CDC 不啟用,所以沒有可量化的差別——這也是為什麼 BuildBuddy 把 CDC 描述為「優化 production CI pipeline」而非「優化 dev inner loop」。
何時 CDC 退化
有一個值得注意的負面情境:CDC 對非 byte-stable 的輸出(例如帶 timestamp、未做 reproducible build 的 binary)幫助有限——切點仍會找到相同的 pattern,但實際 chunk 內容仍會變,dedup 退化。要真正吃到這 40%,得先把 build 的 determinism 做好。CDC 是放大器,不是修復器。
具體列舉退化情境:
- 嵌入 timestamp 的 binary——`__DATE__` / `__TIME__` macro 在 C/C++ 預設展開、Go `runtime.buildVersion` 含 build time、Java jar 的 manifest 預設帶 build timestamp。每次 build 同一個 source 都產生不同 byte——dedup 接近 0。
- 跨 chunk 的大重排——例如 linker order 改變、stripped section 順序變動。CDC 對「插入一個 byte」這種小修改是 robust 的,但對「整個 .text section 順序重排」就退化——絕大多數 chunk 都會跨越新的重排邊界。
- 已經壓縮過的輸出——`.tar.gz`、Docker layer (gzip)、`.zip`。Gzip 的 byte stream 對輸入的小修改是「整段 hash chain 重算」,所以 source 的 1-byte 修改可能造成 compressed output 90%+ 不同——CDC 在 compressed bytes 上找不到 dedup 機會。
- obfuscation / randomization 啟用的 release build——例如 ProGuard、LLVM ObfuscatorLLVM、隨機 symbol renaming。這些工具的輸出本質上「同 input 不同 output」,CDC 幫不上忙。
- 檔案小於 2 MiB——根本不會啟用 CDC,直接整檔 hash。對 source、小 header、Bazel runfiles 中的小 script 都適用。
對前三點,有解。第一點:強制 reproducible build(Bazel 文件有完整的 `--repo_env`、`--action_env` 清單去除 timestamp)。第二點:穩定 linker 的 section 順序(lld 的 `--no-rosegment`、ld 的 `--no-keep-memory` 之類旗標)。第三點:把壓縮這一步從 cache 路徑挪出去——cache 大檔案的 uncompressed 版本,壓縮在 download 之後 client 端做。第四、五點本質上是 trade-off:要 release-grade obfuscation 或要 CDC dedup,二選一。
還有一個 cold-cache 限制:CDC 對「rebuild from scratch」沒有任何幫助——cache 是空的,第一次 build 不管切多少 chunk 都得全部上傳。Benefit 完全來自「跟先前 build 的 chunk 重疊」,所以「持續性 cache」(remote cache 跨 build 持續)才是 CDC 的甜蜜點。加密過的 blob 也是 by-design 的退化區——加密讓 byte 看起來像隨機,CDC 找不到 pattern;處理方式是把加密步驟挪到 cache 之後。
還有一個操作細節:`--experimental_remote_cache_chunking` 是 Bazel 8.7 / 9.1+ 才有的旗標。對較舊 Bazel 版本,BuildBuddy 仍然能 serve cache,但 client 不會做 chunking——這是個漸進升級路徑,不需要強迫整個 org 同步升級 Bazel。Hosted cache backend 已經啟用 chunking 的話,舊 client 寫入仍是整檔 blob(不做 chunking),新 client 寫入會做 chunking——兩種 blob 在 CAS 上共存,read 路徑自動分辨(透過 reconstruction metadata 是否存在)。這個漸進性是 Bazel remote-cache protocol 的相容性設計給了空間。
最後一個值得記住的事實:CDC 不是 BuildBuddy 發明的——FastCDC paper 在 2016 年就發表了。把 FastCDC 帶進 Bazel cache 的真正貢獻不在演算法本身,而在「把這個機制 wire 進 Bazel 的 CAS protocol、保持 blob digest identity 不變、給出 production-grade 的 4.2% / 85% / 40% / 300 TiB 證據」——這是工程上難的部分。
What this enables:把 dedup 從「整檔 digest 比對」拉到「sub-file rolling-hash chunk 比對」,讓 Bazel 在每次源碼小變動時,只為真正新出現的 byte 付儲存與網路成本——前提是 build 本身已經是 byte-reproducible 的。