Alpha.4 War Story — The Eight-Headed Bug That Hid Behind a System Prompt #9
ToddThomson
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
When I added system prompt support to Mila's chat CLI, something unexpected happened. Prompts that had previously produced perfect responses started generating fluent, coherent, and completely context-oblivious text. Ask for a story about rabbits living on the moon eating green carrots — get a charming tale about Hungarian meadow rabbits instead. The model wasn't broken. It was just ignoring everything I said.
This is the story of how a single innocent constant —
kPrefillChunkSize— quietly corrupted eight different stages of the GQA prefill pipeline, and why it took a system prompt to wake the beast.The Setup
Mila's prefill pipeline processes long prompts in chunks of 64 tokens (
kPrefillChunkSize). For each chunk, the GQA attention op writes K/V entries into the cache, expands them for grouped query attention, computesQ @ K^Tvia cuBLASLt, applies a causal softmax, computesatt @ V, and unpermutes the output. All pre-allocated buffers are sized tokPrefillChunkSizerows.The key insight — which should have been obvious but wasn't — is that
kPrefillChunkSizeserves two distinct roles:For full chunks these are identical. For partial chunks — the final chunk when
T_promptis not a multiple of 64 — they diverge. And every place in the pipeline that confused one for the other became a bug.The Trigger
Without a system prompt, typical user messages fit comfortably within 64 tokens — a single full chunk. All bugs were dormant. The pipeline had never actually executed a partial chunk followed by meaningful downstream processing.
Adding a system prompt pushed
T_promptpast 64, activating the partial chunk path for the first time. Eight bugs woke up simultaneously.The Bugs
Every bug was a variation of the same mistake —
kPrefillChunkSizeused as a stride wherechunk_lenwas required:build_qk_prefill_planstrideC = kPrefillChunkSize * TstrideC = chunk_len * Tbuild_att_value_prefill_planstrideA = kPrefillChunkSize * TstrideA = chunk_len * Tbuild_att_value_prefill_planstrideC = kPrefillChunkSize * HSstrideC = chunk_len * HSprefill_softmax(BF16)row_offsetusedchunk_striderow_offsetuseschunk_lenprefill_softmax(FP32)row_offsetusedchunk_striderow_offsetuseschunk_lenCudaGqaOp::prefillposition_offset * HSposition_offset * HSto Q pointerkvcache_expand_kv[0..position_offset + chunk_len)historyprefill_unpermute_output_paddedpadded_T = kPrefillChunkSizepadded_T = chunk_lencuBLASLt wrote
preatt_with 64-row head strides regardless of actual chunk size. The softmax then read the wrong row ofpreatt_for every head beyond head 0. The model was computing attention over garbage for every layer, every head, every chunk beyond the first.The Symptom
The corrupted attention heads didn't produce noise. They produced fluent, grammatically correct, contextually irrelevant text. The model was responding from its language prior — "what statistically follows a system prompt" — rather than from the actual prompt content.
This made the bug particularly deceptive. A model producing garbage output is obviously broken. A model producing polished, confident, wrong answers requires careful prompting to diagnose. The tell was specificity: the model correctly responded to the shape of the prompt (a story request, a question) but ignored every specific detail (the moon, green carrots, Paris).
The Diagnosis
The debugging session worked through the pipeline systematically, clearing each stage in turn:
abs_pos = position_offset + t)prefill_softmaxrow offset — first fix (chunk_stride→chunk_len)The softmax fix was necessary but not sufficient. After it was applied, the model produced better output on small partial chunks (71 tokens: one full + seven-token partial worked correctly) but still failed on larger inputs with multiple preceding full chunks. The debug print:
confirmed the softmax parameters were correct — which redirected suspicion to the cuBLASLt plans themselves.
The smoking gun was
strideCinbuild_qk_prefill_plan:cuBLASLt was writing
preatt_with 64-row head strides. The softmax (now correctly striding bychunk_len) was reading with 11-row head strides. Every head beyond head 0 was reading from a completely different location in the buffer.The Fix
Eight coordinated one-line fixes, all of the form: replace
kPrefillChunkSizewithchunk_lenin stride and offset calculations.A comment has been added to
kPrefillChunkSizeat its declaration:Lessons
Single-chunk testing is insufficient for chunked pipelines. A test suite that never exercises the partial chunk path cannot catch partial chunk bugs — no matter how thorough it is otherwise. Mila's validation suite now includes explicit multi-chunk prefill cases with non-multiples of
kPrefillChunkSize.The most dangerous bugs produce fluent wrong answers. Numerical corruption that manifests as coherent text is harder to catch than corruption that manifests as garbage. When a model ignores specific details of a prompt while responding correctly to its general shape, suspect attention score corruption before anything else.
Buffer allocation size and active row count are different things. In a chunked pipeline with pre-allocated buffers, the temptation to use the allocation constant as a stride is always present and always wrong on partial chunks. Name them differently, document the distinction at the declaration site, and never let them meet in index arithmetic.
Mila is a solo CUDA-accelerated LLM inference framework targeting local inference. Built in C++23 with cuBLASLt and custom CUDA kernels.
Beta Was this translation helpful? Give feedback.
All reactions