Whisper: Trace One Encoder Tensor into Every Decoder Layer

Linear “layer → layer” diagrams hide the interesting part of encoder-decoder models: one tensor feeding many consumers, and one operation receiving inputs from different parts of the graph. This deep dive follows Whisper's encoder memory — the tensor produced once by the encoder and consumed by every decoder layer's cross-attention — through CKE's real circuit, compiler tables, kernel maps, memory plan, and generated C. It is a companion to the model bring-up guide, which teaches the full bring-up lane; this page zooms into one tensor's edges. Machine-readable evidence: whisper_cross_attn_trace_evidence.json.

Evidence labels on this page.
executed the command was run at the stated revision and the output shown is real. source-verified the file or mechanism was read at the stated revision but the behavior was not re-executed for this page. experimental exists with incomplete coverage. proposed does not exist yet. Everything below is pinned to one freshly generated artifact: Whisper Base decoder, regenerated at revision bc04db403 by cks-v8-run audio hf://openai/whisper-base --force-compile on 2026-09-20 (artifact hashes in the evidence JSON). Quoted generated C comes from that artifact's model_v8.c; JSON excerpts come from its lowered_*_call.json and layout_*.json; nothing is hand-invented.

The concept: one tensor, many consumers

In a decoder-only model, the dominant dataflow pattern is a chain: layer n feeds layer n+1. Whisper's decoder breaks that pattern. The encoder runs once over the audio features and produces encoder memory E — for Whisper Base, a [1500, 512] fp32 token-major grid (1500 frames × 512 channels). Then each decoder layer applies its own projections to that one tensor:

Q_l = decoder_hidden_l x WQ_l        # from the current decoder stream
K_l = E x WK_l                       # from encoder memory, layer l's own WK
V_l = E x WV_l                       # from encoder memory, layer l's own WV
cross_attention(Q_l, K_l, V_l)

So the cross-attention operation has three input edges from different parts of the graph: Q from the decoder stream at layer l, K and V from layer l's own projections of the encoder memory. Two consequences that linear diagrams miss:

The circuit declares exactly this, in version/v8/circuits/audio_transformer_decoder.json source-verified:

"cross_attention": {
  "causal": false,
  "query_source": "decoder_stream",
  "key_value_source": "encoder_memory",
  "kv_layout": "immutable_encoder_context",
  "projection_cache": {
    "populate_phase": "prefill",
    "reuse_phase": "decode",
    "invalidation": "encoder_memory_update",
    "storage": "layer_major_head_major_fp32"
  }
}

and the decoder body names the operation instances explicitly: "cross_attn_norm", "cross_q_proj", "cross_k_proj", "cross_v_proj", "cross_attn", "cross_out_proj" between the self-attention residual and the MLP.

Who owns which fact about the tensor

A kernel map is not the whole wiring diagram. Six components each own a different fact about encoder memory; a correct bring-up writes each fact once, in the component that owns it:

ComponentOwns (for encoder memory E)Concrete instance in this trace
Circuit / resolved IRWhich operation produces the tensor and which consumers receive it (the logical edges)audio_transformer_decoder.json: key_value_source: encoder_memory; compiler dataflow table binds cross_k_proj.inputs.x = "external:encoder_memory" (build_ir_v8.py)
Operation contractPort meanings and logical dimensions (what Q/K/V mean, which axis is tokens)required_numerical_contracts.audio.decoder.cross_attention + checkpoint audio.decoder.layer.{layer}.cross_attention.output, logical_layout: head_major, axis names head/query_token/channel
Kernel map / providerImplementation, physical layout, dtype, ABI, scratchattention_forward_query_key_head_major_f32_decode_heads.json: ports query/key/value [H,Tq,D]/[H,Tk,D] fp32, scratch score_scratch [H,Tk], exact C symbol
CompilerBinding resolution, compatibility validation, memory planning, call emissionbuild_ir_v8.py resolves the provider and sizes encoder_memory / cross_k_cache regions; codegen_v8.py emits the call with resolved offsets
Native runtimeState and schedule execution (flag ownership, call order, cache lifetime)generated ck_model_set_encoder_memory, encoder_kv_ready flag, prefill-then-decode schedule in model_v8.c
KernelComputing on supplied pointers — nothing elseattention_forward_query_key_head_major_f32_decode_heads in src/kernels/attention_kernels.c

Two honesty notes. First, the compiler must understand enough semantics — via contracts — to validate shapes, layouts, state, aliasing, and execution order; it does not need (or get) the handwritten arithmetic inside kernels. Second, current CKE still carries explicit operation knowledge in compiler tables: the cross_k_proj/cross_v_proj edge to external:encoder_memory is a literal entry in a dataflow table inside build_ir_v8.py (quoted below), not something derived solely from kernel maps. Do not claim otherwise.

What runs where (the honest split)

ResponsibilityStatusMechanism
Encoder inference (audio features → E)executedgenerated encoder artifact (libmodel.so), run at this revision on the JFK sample: frontend=0.084s encoder=0.493s
Decoder inference with cross-attention over Eexecutedgenerated decoder artifact, same run: prefill=0.061s decode=0.158s tokens=25 stop=eos, transcript matches the known JFK reference sentence
Transport of E from encoder process to decoder processexecuted (host-side bridge)the runner writes encoder-NNNN.npy per window into a temp dir, the decoder side np.loads it and calls ck_model_set_encoder_memory (run_whisper_v8.py). Python/numpy performs this transport — it is tooling, not generated-model execution
Installing E inside the decoder arena + invalidationexecuted (generated code)generated ck_model_set_encoder_memory: validates tokens==1500 && dim==512, copies 3,072,000 bytes into A_ENCODER_MEMORY, clears encoder_kv_ready
Fully native encoder→decoder orchestration (one generated schedule, no host transport)proposedno demonstrated deployment does this for Whisper at this revision; the inspected path is encoder artifact + host transport + decoder artifact. Do not draw it as one schedule

View 1 — follow the tensor

Encoder memory E from production to every consumer. The dashed middle hop is the host-side bridge (a file + np.load + one generated API call); everything to its right is the decoder's generated runtime. All shapes, sizes, and flags below come from the pinned artifact's layout_prefill.json/layout_decode.json and model_v8.c executed:

producer - generated encoder artifact encoder model (libmodel.so) audio features -> E E: [1500, 512] fp32 token-major 3,072,000 bytes, written once host-side bridge (tooling, not generated schedule) runner transport encoder-0000.npy -> np.load -> ck_model_set_encoder_memory clears encoder_kv_ready decoder arena (generated runtime) A_ENCODER_MEMORY [1500, 512] fp32, 3,072,000 B immutable during one window's decode planner-owned region, runtime-owned memory consumers - each layer re-projects E with its own weights layer 0: cross_k_proj + cross_v_proj E x WK_0 -> K_0 | E x WV_0 -> V_0 GEMM [1500,512]x[512,512] + head-major transpose layer 1: cross_k_proj + cross_v_proj E x WK_1 -> K_1 | E x WV_1 -> V_1 own weights, own cache slab (+3,072,000 B) layer 2: same pattern E x WK_2, E x WV_2 cache slab at +6,144,000 B layers 3-4: same pattern E x WK_l, E x WV_l slabs at +9,216,000 / +12,288,000 B layer 5: same pattern E x WK_5, E x WV_5 cache slab at +15,360,000 B A_CROSS_K_CACHE [6, 8, 1500, 64] fp32 18,432,000 B, layer stride 3,072,000 A_CROSS_V_CACHE (same) persistent state: layer-major head-major weight bindings (one pair per layer) WK_0..WK_5, WV_0..WV_5 decoder weights (bump region) W_LAYER_l_CROSS_WK / _WV + biases BK_l / BV_l lifetime across decoding window N: set_encoder_memory -> kv_ready=0 prefill: 12 GEMMs + 12 transposes populate caches decode (every token): all 6 layers read E's projections; E itself is never re-read, never mutated window N+1 arrives: set_encoder_memory overwrites E, kv_ready=0 -> decode refuses (-2) until re-prefill solid cyan = tensor data edge dashed yellow = weight binding green = persistent-state dependency one producer, twelve projection consumers, six attention consumers per decode step - and E is never the encoder's internal K/V; each layer re-projects from E with its own weights.
click / tap the diagram to expand

View 2 — expand one cross-attention operation

One decode-step cross-attention at layer 0, traced through every artifact layer. Click a pointer argument in the generated call to highlight the arena region it references (and, for K/V, the prefill producer edge back to encoder memory). Everything quoted is from the pinned artifact executed:

artifact chain (click a pointer argument in step 5) 1. circuit operation - audio_transformer_decoder.json body ops: ... cross_q_proj, cross_k_proj, cross_v_proj, cross_attn, ... contract: query_source=decoder_stream, key_value_source=encoder_memory 2. resolved I/O edges - lowered_decode_call.json, op cross_attn query <- activation:query | key <- activation:key | value <- activation:value call_abi owner: kernel_map (attention_forward_query_key_head_major_f32_decode_heads.json) 3. provider + kernel map ports fp32 [H,Tq,D] / [H,Tk,D]; scratch score_scratch [H,Tk] contract: attention_query_key_scaled_ordered_fp32_decode_heads 4. ABI bindings (map-owned call_abi, version 1) activation:* / output:* / scratch:* / dim:* / runtime:query_tokens 5. generated C - model_v8.c:1484 (decode, layer 0) query -> A_CROSS_Q_SCRATCH key -> A_CROSS_K_CACHE value -> A_CROSS_V_CACHE output -> A_CROSS_ATTN_SCRATCH score_scratch -> A_MLP_SCRATCH dims: num_heads 8, query_tokens 1 (runtime), key_tokens 1500, head_dim 64, scale 0.125 6. source implementation src/kernels/attention_kernels.c - computes on supplied pointers 7. tests + X-Ray checkpoint unittest/test_audio_encoder.py | checkpoint audio.decoder.layer.{layer}.cross_attention.output decoder arena (regions referenced by the call) A_ENCODER_MEMORY [1500, 512] fp32 installed by ck_model_set_encoder_memory prefill producers cross_k_proj / cross_v_proj GEMM + head-major transpose A_CROSS_K_CACHE [6, 8, 1500, 64] fp32 - persistent layer l slab at + l*3,072,000 B A_CROSS_V_CACHE [6, 8, 1500, 64] fp32 - persistent A_CROSS_Q_SCRATCH [8, 1, 64] fp32 - this decode step produced by cross_q_proj from decoder stream A_CROSS_ATTN_SCRATCH [8, 1, 64] fp32 - op output A_MLP_SCRATCH score_scratch aliases it (decode) planner lifetime reuse: MLP idle during attn read the call like a compiler Q edge: decoder stream (this step) | K/V edges: encoder memory, projected at prefill, head-major, immutable during decode shape equality is NOT compatibility: the [H,Tk,D] head-major port contract is why the prefill transpose ops exist (view 3) the compiled #define offsets in this hybrid runtime come from the prefill plan (layout_prefill.json, arena 380,258,816 B); layout_decode.json is the decode-only view. interactive: click an argument box to highlight its arena region (keyboard: tab + enter).
click / tap the diagram to expand; click an argument to highlight its buffer

The copyable excerpts behind View 2, verbatim from the pinned artifact:

/* Op 18: attention_forward_query_key_head_major_f32_decode_heads (cross_attn) layer=0 section=body */
attention_forward_query_key_head_major_f32_decode_heads(
    (const float*)(model->bump + A_CROSS_Q_SCRATCH),     /* query <- activation:query  */
    (const float*)(model->bump + A_CROSS_K_CACHE),        /* key   <- activation:key    */
    (const float*)(model->bump + A_CROSS_V_CACHE),        /* value <- activation:value  */
    (float*)(model->bump + A_CROSS_ATTN_SCRATCH),          /* output <- output:output    */
    (float*)(model->bump + A_MLP_SCRATCH),                /* score_scratch <- scratch:* */
    8,      /* num_heads    <- dim:num_heads        */
    1,      /* query_tokens <- runtime:query_tokens */
    1500,   /* key_tokens   <- dim:key_tokens (= encoder_memory_length) */
    64,     /* head_dim     <- dim:head_dim         */
    0.125   /* scale        <- dim:attention_scale  */
);

And the layer-5 decode call reads (const float*)(model->bump + (A_CROSS_K_CACHE + 15360000)) — the same persistent cache, fifth layer slab. The K/V caches are the projected encoder memory, one slab per layer, exactly as the circuit's projection_cache.storage: layer_major_head_major_fp32 declares.

View 3 — prefill vs decode: when the projections happen

The projection cache is both a correctness story and an optimization story. Correctness: decode must never read stale projections, so installing new encoder memory invalidates the cache by flag (encoder_kv_ready = 0), and decode refuses (-2) until prefill repopulates it. Optimization: within one window, the 1500-frame encoder memory is projected once per layer at prefill and reused by every decode step — unchanged audio features are never re-projected per token. Timeline from the pinned artifact executed:

phase 0 - encoder memory becomes available host bridge installs E ck_model_set_encoder_memory(data, tokens=1500, dim=512) memcpy 3,072,000 B -> A_ENCODER_MEMORY encoder_kv_ready = 0 phase 1 - decoder prefill (once per window) ck_prefill_range populates projections per layer l: cross_k_proj, cross_v_proj GEMMs read A_ENCODER_MEMORY, write slab l transpose_cross_key/value_to_head_major self-attn KV seeded by prompt tokens encoder_kv_ready = 1 phase 2 - decode (once per generated token) reuse, never re-project cross_attn reads K/V cache slabs (query_tokens = 1) self-attention KV cache grows +1 token E untouched; projections immutable phase 3 - new audio window invalidation set_encoder_memory overwrites E encoder_kv_ready = 0 decode before re-prefill: -2 stale projections never read the prefill producer, verbatim from the pinned artifact (model_v8.c:3633) /* Op 18: gemm_nt_f32_llama_production_parallel_dispatch (cross_k_proj) layer=0 */ gemm_nt_f32_llama_production_parallel_dispatch( (const float*)(model->bump + A_ENCODER_MEMORY), /* A <- activation:a */ (const float*)(model->bump + W_LAYER_0_CROSS_WK), /* B <- weight:_first_weight */ (float*)(model->bump + W_LAYER_0_CROSS_BK), ..., (float*)(model->bump + A_CROSS_K_CACHE), the flag contract, verbatim (model_v8.c) ck_model_decode: if (!g_model->encoder_kv_ready) return -2; ck_model_set_encoder_memory: ... memcpy(...); g_model->encoder_kv_ready = 0; return 0; end of ck_prefill_range: model->encoder_kv_ready = 1; (after all cross-K/V slabs + transposes) correctness: decode can never observe stale projections - the flag is the runtime witness of the circuit's declared invalidation: encoder_memory_update. optimization: 12 cross projections per window, not per token - a 25-token answer reuses 12 prefill projections instead of running 300; unchanged audio features are projected once.
click / tap the diagram to expand

The compiler-side declaration behind this is literal and readable source-verified — build_ir_v8.py's dataflow table:

"cross_k_proj": {
    "inputs": {"x": "external:encoder_memory"},
    "outputs": {"y": {"slot": "cross_k_cache", "dtype": "fp32"}},
},
"cross_v_proj": {
    "inputs": {"x": "external:encoder_memory"},
    "outputs": {"y": {"slot": "cross_v_cache", "dtype": "fp32"}},
},

and its buffer sizing fails closed when the geometry is undeclared: "encoder-decoder artifacts require a positive encoder_memory_length" and "num_heads * head_dim == embed_dim" are hard errors, not defaults. Note again: this is explicit operation knowledge in the compiler, not knowledge derived from kernel maps.

Deliberate failure: token-major K meets a head-major consumer

Suppose a contributor wires a cross-K projection that emits token-major output straight into a cross-attention consumer whose port is head-major — and no transform is declared. Shape equality does not establish compatibility: both layouts carry the same element count, so only the declared physical layout distinguishes them. The mechanical join in version/v8/scripts/resolve_layout_chain_v8.py (circuits own logical edges; maps own physical layouts; the module joins the declarations) rejects it. Executed at this revision with the real consumer map and a synthetic token-major producer executed:

RuntimeError: no compatible physical provider chain: no physical-layout route from
cross_k_proj_token_major_hypothetical:y/token_major_contiguous/local to
attention_forward_causal_head_major_gqa_flash_compact_token_output:q/head_major_contiguous/local

Read the diagnostic as an edge report: it names the producer endpoint (producer:port/layout/placement) and the consumer endpoint — the edge is the failure, not either operation alone. The contributor's repair path is to reach the responsible contract, not to patch the kernel: declare the transform. With the real converter map registered, the same join resolves:

route: cross_k_proj_token_major_hypothetical -> layout_convert_token_to_head_f32
       -> attention_forward_causal_head_major_gqa_flash_compact_token_output | cost 10

(layout_convert_token_to_head_f32.json declares from_layout: token_major_contiguous, to_layout: head_major_contiguous, value_semantics: bit_exact_copy.) In the real Whisper circuit this transform exists as the generated transpose_cross_key_to_head_major/transpose_cross_value_to_head_major ops in prefill — the GEMM emits token-major rows, the transpose re-lays them head-major, and only then does the attention provider's port contract hold. For compiler-failure vocabulary beyond layouts (missing ports, unknown slots, aliasing without declaration), the fail-closed messages are HARD CIRCUIT INTERFACE FAULT/HARD CIRCUIT DATAFLOW FAULT from validate_circuit_interfaces_v8.py and build_ir_v8.py; the kernel-maps contributor recipe walks the general lane.

Inspect this yourself

Image
100% | |
Scroll to zoom | Drag to pan | W/H to fit | 0 to reset | ESC to close