vatt'ghern jaskier's ballads
本文 5 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

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 更短。

在檔案前段插入一個 byte 之後 fixed-size (before) chunk A chunk B chunk C chunk D chunk E fixed-size (after, +1 byte) A' B' C' D' E' 全部新 digest CDC (before) A B C D E CDC (after, +1 byte) A*(new) B(hit) C(hit) D(hit) E(hit) 只 A* 是新 關鍵差異:fixed-size 的切點是「位置」(每 N byte), CDC 的切點是「內容」(rolling hash 命中 mask); 後者讓視窗在新內容兩側自動重新對齊。
插入一個 byte 之後,fixed-size 的切點向後位移,整檔變新;CDC 的切點由內容決定,rolling hash 在 A* 之後重新對齊 B、C、D、E 的原始邊界,dedup 命中率不退化。

有了 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 怎麼跟著移動。

19
35%
avg chunk size512 KiB
chunks new / total1 / 64
dedup ratio98.4%
before after insert existing chunk(cache hit) new chunk(must upload) insert point(+1 byte) model: 8 MiB byte-stable blob; rolling hash with low-N-bit mask. smaller mask → bigger chunks → fewer cut points → fewer new chunks per edit but coarser dedup. target zone 18–20 bits ≈ 256 KiB – 1 MiB avg chunk; BuildBuddy ≈ 19.
拖 mask bit 數會直接改 avg chunk size(理論上 2^bits byte 一次命中);拖 insert offset 模擬源碼修改在檔案不同位置,看哪幾個 chunk 變新。Mask 太小,chunk 太大,一次 edit 可能讓 1/4 檔案重新上傳;mask 太大,metadata 開銷吃掉節省。

拖 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 的嚴格度。

具體做法分三段:

  1. 0 ≤ len < MIN_CHUNK:mask 完全忽略,永遠不切——強制最小長度。
  2. MIN_CHUNK ≤ len < AVG_CHUNK:用嚴格 mask(多一個 bit 為 0),讓切點更難命中——壓制小 chunk。
  3. AVG_CHUNK ≤ len < MAX_CHUNK:用寬鬆 mask(少一個 bit 為 0),讓切點更容易命中——壓制大 chunk。
  4. 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 看它在做什麼。

FastCDC pipeline · 每個 byte 經過五個 stage 1 · GEAR HASH h ← (h «1) + G[b] 256-entry table 64-bit rolling 2 · LEN BAND len ∈ ? band min / avg / max 128K / 512K / 2M 3 · PICK MASK MASK_S / _L strict before avg loose after avg 4 · MASK TEST (h & MASK)==0? rare pattern ≈1 hit / avg bytes 5 · EMIT + RESET SHA-256 · CAS h=0; start=i chunk digested miss · next byte(最常見路徑) hit · new chunk emitted, hash reset, scan next byte
1 · gear hash Rolling hash 增量更新:每讀一個 byte b,把當前 h 左移一位、加上 256-entry 隨機表的 G[b]。整個操作只一個 shift 加一個查表加一個加法,每 byte CPU 開銷大約 3-4 ns。Rabin fingerprint 需要的多項式運算被一張預生成的隨機表完全取代——這是 Gear hash 比 Rabin 快約 3-5 倍的原因。
每個 byte 都跑完五個 stage。最常見的路徑是 1 → 2 → 3 → 4 → miss → 下一個 byte,整個迴圈每秒可以掃過 1-2 GB 的 byte(Gear hash 是主要瓶頸前的 CPU 預算)。命中 mask 的位置才走 stage 5,SHA-256 在這裡才被算——這是 CDC 啟用後 CPU 真正花掉的地方。

每個 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 量物件上。

threshold2.0 MiB
objects ≥ th4.2%
bytes ≥ th72%
2 MiB
橫軸是 cache 物件大小(log scale, 1 KiB ~ 1 GiB),縱軸是累積百分比。綠線(左→右)是「≤ 此大小的物件佔總物件數的比例」,橙線(左→右)是「≤ 此大小的物件佔總 byte 數的比例」。在 2 MiB 線右邊:物件數 ≈ 4.2%,但 byte 量 ≈ 72%。模型來自典型 Bazel monorepo build output 的 lognormal-tail 分布。

橫軸是 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 的全程:

scrub the upload pipeline · drag the handle stage 0
stage 0 · enter
100 MiB link 輸出剛從 linker 落地 ; CDC pipeline 尚未啟動。
100 MiBblob in hand
0 MiBnetwork sent
拖動圓點,看 100 MiB 的 link 輸出怎麼一步一步變成 ~2.5 MiB 的網路傳輸量(5 個新 chunk × 512 KiB)。FindMissingBlobs 的 RPC 只送 digest(每個 32 byte),整個 stage 3 的網路成本是 200 × 32 = 6.4 KiB——遠小於 chunk byte 本身。Stage 5 是真正花網路頻寬的地方,但只送 missing 的 5 個。

拖動圓點,看 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 distribution 差很多——normalization 把長尾壓回常態是 production 真正重要的差異。CPU 數字來自 FastCDC paper(Xia 2016)的 single-thread 測試;點 header 可排序。

單一插入的 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 之後兩週的累積。

CDC 啟用前後在 BuildBuddy 自有 repo 上量到的差異 0% 25% 50% 75% 100% 40% upload bytes ↓ 50-commit benchmark 40% disk cache ↓ 50-commit benchmark 85% written-bytes dedup ↑ two-week production 20–40% cache traffic ↓ two-week production 4.2% objects ≥ 2 MiB eligible for CDC
左半:benchmark 數字(50 commit, BuildBuddy 自家 repo)——upload data 與 disk cache 各減 40%。中:production 兩週累積——寫入路徑 dedup 達 85%。右:production 整體 cache traffic 節省 20-40%(依 workload 而定)。最右:能啟用 CDC 的 cache object 只佔 4.2%,但這 4.2% 佔 byte 總量極高——對的物件抓 dedup,比所有物件抓一點點有效。

絕對值更直觀:兩週累積跳過約 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 是放大器,不是修復器。

具體列舉退化情境:

  1. 嵌入 timestamp 的 binary——`__DATE__` / `__TIME__` macro 在 C/C++ 預設展開、Go `runtime.buildVersion` 含 build time、Java jar 的 manifest 預設帶 build timestamp。每次 build 同一個 source 都產生不同 byte——dedup 接近 0。
  2. 跨 chunk 的大重排——例如 linker order 改變、stripped section 順序變動。CDC 對「插入一個 byte」這種小修改是 robust 的,但對「整個 .text section 順序重排」就退化——絕大多數 chunk 都會跨越新的重排邊界。
  3. 已經壓縮過的輸出——`.tar.gz`、Docker layer (gzip)、`.zip`。Gzip 的 byte stream 對輸入的小修改是「整段 hash chain 重算」,所以 source 的 1-byte 修改可能造成 compressed output 90%+ 不同——CDC 在 compressed bytes 上找不到 dedup 機會。
  4. obfuscation / randomization 啟用的 release build——例如 ProGuard、LLVM ObfuscatorLLVM、隨機 symbol renaming。這些工具的輸出本質上「同 input 不同 output」,CDC 幫不上忙。
  5. 檔案小於 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 的。