F-mesh-1: KV Cache OOM Root Cause + Fix Status
Executive Summary
- Symptom:
vec![0.0; 503 MiB]per worker on gemma4-31b boot, exceeds 128 MiB CF Worker isolate cap, kernelunreachablepanic on/split-a. - Root cause: legacy whole-model KV alloc — every worker (even single-layer trisplit shards) allocated slots for all 60 layers.
- Fix: already in source as wave-12 task #31.
KVCache::with_base(base, num_layers, …)constructor +kv_base_layer/kv_num_layersplumbing throughWasmGemma4Pipeline::from_backendand called fromapps/distributed-inference-worker/src/index.ts:569. Per-worker alloc drops from ~503 MiB → ~8 MiB. - Status: code-complete and end-to-end verified at the source level (kv_cache.rs → model_gemma4.rs → wasm_bindings.rs → distributed-inference-worker/index.ts:569). Not yet validated on a deployed trisplit worker — the wasm bundle in
apps/distributed-inference-worker/wasm/may still predate the change. Bule cost to ship: ~3 (rebuild wasm + redeploy 188 nodes + smoke). - Verification (2026-05-03): confirmed (a)
KVCache::with_baseallocatesnum_layers * num_kv_heads * max_seq_len * head_dimfloats — exactly the legacy formula, butnum_layersis now caller-controlled; (b)Gemma4Pipeline::from_knot_with_kv_rangedefaultskv_layers = cfg.num_layerswhenNone(back-compat preserved); (c)WasmGemma4Pipeline::from_backendexposes bothOption<u32>params with the explicit comment "single-layer trisplit worker passing(17, 1)cuts the cache from ~503 MiB to ~8 MiB"; (d) the TS worker computeskvBaseLayer = spec.startLayer; kvNumLayers = spec.endLayer - spec.startLayerand passes both intoWasmGemma4Pipeline.from_backend(gemma path) ANDWasmLlamaPipeline.from_backend(llama path). No code change required — only build + deploy.
1. Allocation Site
open-source/gnosis/distributed-inference/src/kv_cache.rs:40-49
pub fn with_base(base_layer, num_layers, num_kv_heads, head_dim, max_seq_len) -> Self {
let size = num_layers * num_kv_heads * max_seq_len * head_dim;
KVCache {
...,
keys: vec![0.0; size], // f32 * size bytes
values: vec![0.0; size], // f32 * size bytes
}
}Caller at model_gemma4.rs:215-223:
let kv_base = kv_base_layer.unwrap_or(0);
let kv_layers = kv_num_layers.unwrap_or(cfg.num_layers); // <-- legacy = full 60
let kv_cache = KVCache::with_base(
kv_base, kv_layers,
cfg.num_kv_heads.max(cfg.global_num_kv_heads.unwrap_or(0)), // 16
head_dim_max, // 512 (global)
KV_MAX_SEQ_LEN, // 128 on wasm32
);Formula breakdown (gemma4-31b, wasm32)
| Factor | Value | Reducible? |
|---|---|---|
| num_layers | 60 | YES — per-worker share is 1–3 layers |
| num_kv_heads (max) | 16 | No — model architecture |
| head_dim_max | 512 | No — global layer requirement |
| KV_MAX_SEQ_LEN wasm | 128 | No (already trimmed from native 2048) |
| K + V | 2 | Structural |
| f32 bytes | 4 | Reducible (f16 ×0.5, i8 ×0.25) — not needed |
Whole-model: 2 × 60 × 16 × 512 × 128 × 4 = 503,316,480 B ≈ 480 MiB (memory log rounds 503 MiB).
2. Per-Worker Math (the win)
Trisplit worker owns 3 layers:
2 × 3 × 16 × 512 × 128 × 4 = 25,165,824 B ≈ 24 MiB
Single-layer fluid-mesh worker:
2 × 1 × 16 × 512 × 128 × 4 ≈ 8 MiB
Either is comfortably under the 128 MiB cap.
KV_MAX_SEQ_LEN=128 on wasm32 (model_gemma4.rs:51) — already trimmed from the native 2048 in a prior pass for prompt-fit headroom; do not bump without re-budgeting.
3. Proposed Fix — already implemented
The exact wave-12 task #31 fix is in source:
src/kv_cache.rs—with_base(base_layer, num_layers, …)constructor +local_layer()translates absolute → local slot with debug-assert range check.new(...)keeps back-compat (delegates towith_base(0, num_layers, …)).src/model_gemma4.rs:108-118, 124-128, 215-223—from_partsandfrom_knot_with_kv_rangeacceptOption<usize>forkv_base_layer/kv_num_layers; defaults to whole-model alloc whenNone.src/wasm_bindings.rs:760-814—WasmGemma4Pipeline::from_backendexposes both asOption<u32>over the wasm-bindgen boundary. Same parameters added toWasmLlamaPipeline::from_backend(line 241).apps/distributed-inference-worker/src/index.ts:569-609— TS worker computeskvBaseLayer = spec.startLayer,kvNumLayers = spec.endLayer - spec.startLayerand passes both intocls.from_backend(...)for both Gemma and Llama paths.
What is NOT yet done:
- Rebuild + redeploy of the 180 tri-g4 + 8 cobordism-g4 workers with the wasm bundle that contains this code.
- Smoke verify on one trisplit worker that boot completes without
unreachablepanic.
4. Validation Plan
cd open-source/gnosis/distributed-inference && wasm-pack build --target web --out-dir pkg(release; debug-assert off in release so the local_layer panic won't crash prod).- Copy
pkg/distributed_inference_bg.wasmintoapps/distributed-inference-worker/wasm/. - Deploy ONE worker first:
wrangler deploy --name tri-g4-001 --env production. - Boot smoke:
curl https://tri-g4-001.taylorbuley.workers.dev/health— expect 200, nounreachable. - Inference smoke:
curl -X POST .../split-awith the canonical prompt; expect coherent K/V chunk in the response (not garbage-filled, not OOM). - Memory delta: peek
wrangler tailboot log for[boot] arch=gemma4 pipeline=WasmGemma4Pipelineand watch for the absence of the prior OOM signature. - If clean, batch-deploy remaining 179 trisplit + 8 cobordism workers.
5. Hopf-Link Prediction (F-mesh-4)
F-mesh-4 = "KV cache stride mismatch". kv_stride = max(num_kv_heads * head_dim, global_num_kv_heads * global_head_dim) at model_gemma4.rs:210 accommodates both sliding (16×256=4096) and global (4×512=2048) shapes in a single batch_k/v buffer. head_dim_max (512) is what feeds the KV cache row size, and is independent of per-layer kv_dim.
The OOM bug and the stride bug share the same physical buffer (the K/V rows of the per-worker cache) but represent different failure modes:
- F-mesh-1 (this fix): row count is wrong (full 60 instead of owned slice) → boot OOM.
- F-mesh-4: row layout within a slot may be wrong when sliding-vs-global layers interleave on a worker that owns layers spanning a pattern boundary.
Hopf-link prediction: the two are linked but not identical. Cutting num_layers to the owned slice means a worker now hosts 1–3 contiguous absolute layers, so it sees at most one global / sliding boundary. The stride buffer is already sized to the max so writes won't go out-of-bounds. Whether the read side (attention scoring across cached positions) honors per-layer kv_dim is the second test — a pipeline that owns layers [5..8] straddles the global layer 5. If F-mesh-4 was a function of "many layers, mixed types, all aliased into one cache", reducing to 3 layers may collapse the symptom without an explicit stride fix.
Verdict: not a guaranteed joint resolution but a strong test of the wave-13 Hopf-link hypothesis. After the F-mesh-1 deploy, run the F-mesh-4 reproducer (KV scoring divergence vs Ollama oracle) before and after; if F-mesh-4 also clears, the link is confirmed.
6. Risks & Rollback
- Range mistake (worker passes wrong
[startLayer, endLayer)):local_layer()debug_assert! catches in debug builds; release builds will silently read wrong cache rows → garbage output. Mitigation: smoke one worker before fanning out. - Wasm bundle mismatch (deployed worker has stale wasm without the new param positions): wasm-bindgen ABI shift = boot panic. Mitigation: verify
wasmgemma4pipeline_from_backendarity in the deployed.wasm.d.tsbefore deploy (current ABI = 6 params:a-f). - Rollback:
wrangler rollback tri-g4-XXXreturns to the previous version that has the legacy whole-model alloc. The previous version OOM'd before but did not corrupt state on disk; rollback is one command. - R2 knot is unaffected — KV cache is purely worker memory, no on-disk side effect.
Bule Cost
~3 bule total. Source change is done. Remaining: rebuild wasm (~5 min), copy to apps/*/wasm/ (~1 min), deploy first worker + smoke (~10 min), batch-deploy 187 more (~30 min via wrangler script). All low-risk because the source fix is already authored and end-to-end plumbed; we are validating, not designing.