Skip to content

Commit 582409b

Browse files
committed
fix(metal): attnFold slabs did not grow with the wide tail-absorbed prefill chunk — ~52K long-context corruption
Root cause of the long-context needle-sweep failures (argmax -1 / instant EOS / silent needle loss at ~52K on E2B): attnFold keyed its reallocation on mlpFold's foldRowCap. mlpFold always runs FIRST and had already raised foldRowCap for a wider chunk, so attnFold skipped its realloc and every attention slab (attn-norm, Q, SDPA out, O out, K/V stage) stayed at the previous row count. The batched prefill's tail absorption produces exactly ONE chunk wider than all before it (window + tail, when promptLen mod window lands in (0, window/2]), so rows past the stale capacity read and wrote out of bounds — undefined bytes that varied per process: NaN cascades through attention (the argmax -1 hard failure), or silently wrong hiddens (the needle loss at 97K, and non-identical temp-0 outputs). attnFold now tracks its own attnRowCap/attnDModel. Forensics that found it stay in-tree, env-gated (LTHN_DEBUG_ARGMAX): the invalid-argmax per-stage dump (hidden -> normed -> logits -> tiles), the per-chunk boundary-hidden NaN scan with per-layer KV fingerprinting, and the last-prefill-step seam dump. cmd/lem gains -prompt-file (long prompts exceed argv). Receipts: TestDenseBatchScratchAttnFoldGrowsWithRows pins the production call order (mlpFold first, then attnFold at a grown row count) against the slab sizes; CLI repro 3/3 clean at 51,924 tokens (was ~2/3 failing) with zero NaN diagnostics; TestE2BQ4LongContextPrefillCorruption (real-model, E2B_Q4_DIR-gated) now produces byte-identical temp-0 tokens across reps at the wide-chunk length (previously diverged per rep — the silent form); full engine/metal suite 1436 green. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 7afee1b commit 582409b

7 files changed

Lines changed: 361 additions & 4 deletions

File tree

go/cmd/lem/generate.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func runGenerateCommand(ctx context.Context, args []string, stdout, stderr io.Wr
3939
fs := flag.NewFlagSet(cliCommandName("generate"), flag.ContinueOnError)
4040
fs.SetOutput(stderr)
4141
prompt := fs.String("prompt", "Write a detailed Go function that reverses a singly linked list, with inline comments on every step, then explain the pointer dance.", "user prompt")
42+
promptFile := fs.String("prompt-file", "", "read the user prompt from a file (long-context runs exceed argv limits); overrides -prompt")
4243
maxTokens := fs.Int("max-tokens", 128, "tokens to generate")
4344
draftPath := fs.String("draft", "auto", "MTP drafter: 'auto' detects one beside a Gemma 4 target (assistant/ pair layout, MTP/ gguf), a path forces it, '' disables")
4445
draftBlock := fs.Int("draft-block", 0, "MTP draft block (verify forward = carried lead + block-1 proposals); 0 = engine default 4")
@@ -91,10 +92,25 @@ func runGenerateCommand(ctx context.Context, args []string, stdout, stderr io.Wr
9192
return 2
9293
}
9394

95+
promptText := *prompt
96+
if *promptFile != "" {
97+
read := core.ReadFile(*promptFile)
98+
if !read.OK {
99+
core.Print(stderr, "%s generate: read -prompt-file %s: %s", cliName(), *promptFile, read.Error())
100+
return 1
101+
}
102+
bytes, ok := read.Value.([]byte)
103+
if !ok || len(bytes) == 0 {
104+
core.Print(stderr, "%s generate: -prompt-file %s is empty", cliName(), *promptFile)
105+
return 1
106+
}
107+
promptText = string(bytes)
108+
}
109+
94110
native.SetPipelinedGPUDecode(*pipeline) // engine-level: -pipeline=false forces the chained serial loop
95111
err := generate.RunGenerate(ctx, generate.Config{
96112
ModelPath: fs.Arg(0),
97-
Prompt: *prompt,
113+
Prompt: promptText,
98114
MaxTokens: *maxTokens,
99115
Temp: *temp,
100116
Think: *think,

go/engine/metal/arch_session.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1867,6 +1867,7 @@ func (s *ArchSession) prefillRetainedTokensBatchedDense(ids []int32, scope strin
18671867

18681868
func (s *ArchSession) prefillRetainedTokensBatchedDenseChunks(ids []int32, scope string) ([]byte, bool, error) {
18691869
var hidden []byte
1870+
chunk := 0
18701871
for len(ids) > 0 {
18711872
n := s.batchedDensePrefillChunkLen(len(ids))
18721873
if n <= 0 {
@@ -1876,6 +1877,29 @@ func (s *ArchSession) prefillRetainedTokensBatchedDenseChunks(ids []int32, scope
18761877
if err != nil || !ok {
18771878
return nil, ok, err
18781879
}
1880+
if argmaxDebugEnabled() {
1881+
if nan, first := bf16NaNScanBytes(nextHidden); nan > 0 {
1882+
nativeTraceLog(core.Sprintf("argmax-diag: batched prefill chunk %d (rows %d, pos now %d): boundary hidden NaN=%d first=%d\n",
1883+
chunk, n, s.pos, nan, first))
1884+
if views, verr := s.stateLayerViews(); verr == nil {
1885+
for _, v := range views {
1886+
kc, kf := bf16NaNScanBytes(v.keyBytes)
1887+
vc, vf := bf16NaNScanBytes(v.valueBytes)
1888+
if kc > 0 || vc > 0 {
1889+
sp := s.state.specs[v.layer]
1890+
at := "sliding"
1891+
if sp.Attention == model.GlobalAttention {
1892+
at = "GLOBAL "
1893+
}
1894+
rowB := kvHeadsOf(sp, s.arch.KVHeads) * headDimOf(sp, s.arch.HeadDim)
1895+
nativeTraceLog(core.Sprintf("argmax-diag: L%2d %s owns=%v shareFrom=%d K-NaN=%d(first row %d) V-NaN=%d(first row %d)\n",
1896+
v.layer, at, sp.OwnsCache(), sp.KVShareFrom, kc, kf/rowB, vc, vf/rowB))
1897+
}
1898+
}
1899+
}
1900+
}
1901+
}
1902+
chunk++
18791903
hidden = nextHidden
18801904
ids = ids[n:]
18811905
}

go/engine/metal/decode_batched_session.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ type denseBatchScratch struct {
7171
attnOutPacked metal.MTLBuffer // K × dModel O-projection outputs
7272
kStagePacked metal.MTLBuffer // K × kvDimMax staged K rows (ring-wrap landing)
7373
vStagePacked metal.MTLBuffer // K × kvDimMax staged V rows
74+
attnRowCap int // attnFold's OWN row capacity — NOT foldRowCap (mlpFold updates that first, which masked row growth and left these slabs short: the ~52K wide-tail-chunk corruption)
75+
attnDModel int
7476
foldQDimCap int
7577
foldKVDimCap int
7678
// per-layer staging for the deferred-landing lane (the big-K staged sliding tail): each
@@ -100,16 +102,22 @@ func (s *denseBatchScratch) mlpFold(k, dModel, dFFMax int) (h, normed, gate, up,
100102
return s.hPacked, s.mlpNormPacked, s.gatePacked, s.upPacked, s.gatedPacked, s.downPacked
101103
}
102104

103-
// attnFold returns the attention-fold slabs, (re)allocating alongside mlpFold's sizing. Call after
104-
// mlpFold (it owns foldRowCap/foldDModel); qDimMax/kvDimMax are the widest per-layer head geometry.
105+
// attnFold returns the attention-fold slabs, (re)allocating when the batch width, model width or
106+
// the widest per-layer head geometry grows. Growth is tracked by attnFold's OWN attnRowCap /
107+
// attnDModel: the old code keyed on mlpFold's foldRowCap, which mlpFold (always called first)
108+
// had ALREADY raised for a wider chunk — attnFold then skipped its realloc and every attention
109+
// slab stayed at the old row count. At ~52K-token prompts the sliding-tail absorption produces
110+
// one chunk wider than all before it (window + tail), so rows past the stale capacity read and
111+
// wrote out of bounds: undefined bytes → per-process NaN/garbage → the long-context corruption.
105112
func (s *denseBatchScratch) attnFold(k, dModel, qDimMax, kvDimMax int) (normed, q, attn, attnOut, kStage, vStage metal.MTLBuffer) {
106-
if s.attnNormPacked == nil || s.foldRowCap < k || s.foldDModel != dModel || s.foldQDimCap < qDimMax || s.foldKVDimCap < kvDimMax {
113+
if s.attnNormPacked == nil || s.attnRowCap < k || s.attnDModel != dModel || s.foldQDimCap < qDimMax || s.foldKVDimCap < kvDimMax {
107114
s.attnNormPacked = scratchBF16(k * dModel)
108115
s.attnOutPacked = scratchBF16(k * dModel)
109116
s.qPacked = scratchBF16(k * qDimMax)
110117
s.attnPacked = scratchBF16(k * qDimMax)
111118
s.kStagePacked = scratchBF16(k * kvDimMax)
112119
s.vStagePacked = scratchBF16(k * kvDimMax)
120+
s.attnRowCap, s.attnDModel = k, dModel
113121
s.foldQDimCap, s.foldKVDimCap = qDimMax, kvDimMax
114122
}
115123
return s.attnNormPacked, s.qPacked, s.attnPacked, s.attnOutPacked, s.kStagePacked, s.vStagePacked

go/engine/metal/decode_batched_session_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,40 @@ func TestStepTokensBatchedDenseSyncsLinearCacheAfterPagedStep(t *testing.T) {
301301
eqBytes(t, core.Sprintf("batched after paged row %d vs stepToken", i), batOut[i], seqOut[i])
302302
}
303303
}
304+
305+
// TestDenseBatchScratchAttnFoldGrowsWithRows pins the ~52K long-context corruption's root
306+
// cause: attnFold must reallocate its slabs when the batch row count GROWS, independent of
307+
// mlpFold. The production call order is mlpFold first (which raises the shared-looking
308+
// foldRowCap), then attnFold — the old attnFold keyed its growth check on foldRowCap and so
309+
// skipped the realloc for the one wide tail-absorbed chunk (window + tail rows), leaving every
310+
// attention slab short: rows past the stale capacity read/wrote out of bounds (undefined bytes,
311+
// NaN/garbage varying per process). The wide-chunk shape only occurs when promptLen mod window
312+
// lands in (0, window/2], which is why it escaped every fixed-size fixture.
313+
func TestDenseBatchScratchAttnFoldGrowsWithRows(t *testing.T) {
314+
requireNativeRuntime(t)
315+
const dModel, qDim, kvDim, dFF = 2048, 2048, 256, 8192
316+
s := &denseBatchScratch{}
317+
// the steady prompt chunks: window-sized batches
318+
s.mlpFold(512, dModel, dFF)
319+
normed, q, attn, attnOut, kSt, vSt := s.attnFold(512, dModel, qDim, kvDim)
320+
if int(bufferLengthFast(q)) < 512*qDim*bf16Size {
321+
t.Fatalf("baseline q slab too small: %d", bufferLengthFast(q))
322+
}
323+
_, _, _, _, _ = normed, attn, attnOut, kSt, vSt
324+
// the wide tail-absorbed chunk: window + tail rows, mlpFold FIRST (production order)
325+
const wide = 724
326+
s.mlpFold(wide, dModel, dFF)
327+
normed, q, attn, attnOut, kSt, vSt = s.attnFold(wide, dModel, qDim, kvDim)
328+
check := func(name string, got, want int) {
329+
t.Helper()
330+
if got < want {
331+
t.Fatalf("attnFold %s slab did not grow with the wide chunk: %d bytes, want >= %d — rows past the stale capacity read/write out of bounds", name, got, want)
332+
}
333+
}
334+
check("attnNorm", int(bufferLengthFast(normed)), wide*dModel*bf16Size)
335+
check("q", int(bufferLengthFast(q)), wide*qDim*bf16Size)
336+
check("attn", int(bufferLengthFast(attn)), wide*qDim*bf16Size)
337+
check("attnOut", int(bufferLengthFast(attnOut)), wide*dModel*bf16Size)
338+
check("kStage", int(bufferLengthFast(kSt)), wide*kvDim*bf16Size)
339+
check("vStage", int(bufferLengthFast(vSt)), wide*kvDim*bf16Size)
340+
}

go/engine/metal/head_nocopy.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package native
66

77
import (
8+
"os"
89
"runtime"
910
"slices"
1011
"sync"
@@ -862,6 +863,11 @@ func (h *headEncoder) greedyBufferAtInPool(hiddenBuf metal.MTLBuffer, hiddenOff
862863
commitCommandBufferFast(cb)
863864
waitUntilCompletedFast(cb)
864865
token = scratch.token()
866+
if (token < 0 || int(token) >= h.vocab) && argmaxDebugEnabled() {
867+
diag := h.argmaxInvalidDiag(scratch, hiddenBuf, hiddenOff)
868+
h.putGreedyScratch(scratch)
869+
return 0, true, core.NewError(core.Sprintf("native.headEncoder.greedy: direct argmax returned invalid token %d for vocab %d%s", token, h.vocab, diag))
870+
}
865871
h.putGreedyScratch(scratch)
866872
if !ok {
867873
return 0, false, nil
@@ -872,6 +878,116 @@ func (h *headEncoder) greedyBufferAtInPool(hiddenBuf metal.MTLBuffer, hiddenOff
872878
return token, true, nil
873879
}
874880

881+
// argmaxDebugEnabled gates the invalid-token forensic dump (the ~52K long-context
882+
// corruption hunt): when the direct argmax returns an out-of-range token, dump per-stage
883+
// NaN counts and extrema (hidden → normed → logits → tiles) so a failing run localises
884+
// which stage the garbage entered at. Enable with LTHN_DEBUG_ARGMAX=1.
885+
func argmaxDebugEnabled() bool { return os.Getenv("LTHN_DEBUG_ARGMAX") != "" }
886+
887+
// bf16NaNScanBytes counts bf16 NaNs in a host byte slice, reporting the first NaN's
888+
// element index (-1 when none) — the host-side sibling of bf16BufStats.
889+
func bf16NaNScanBytes(b []byte) (count, firstIdx int) {
890+
firstIdx = -1
891+
for i := 0; i+1 < len(b); i += 2 {
892+
h := uint16(b[i]) | uint16(b[i+1])<<8
893+
if h&0x7F80 == 0x7F80 && h&0x007F != 0 {
894+
if firstIdx < 0 {
895+
firstIdx = i / 2
896+
}
897+
count++
898+
}
899+
}
900+
return count, firstIdx
901+
}
902+
903+
// bf16BufStats scans n bf16 elements at a buffer offset: NaN/±Inf counts, finite min/max,
904+
// and the first NaN's element index (-1 when none).
905+
func bf16BufStats(buf metal.MTLBuffer, off uint, n int) (nan, inf int, minV, maxV float32, firstNaN int) {
906+
firstNaN = -1
907+
if buf == nil || n <= 0 {
908+
return 0, 0, 0, 0, -1
909+
}
910+
b := unsafe.Slice((*byte)(unsafe.Add(buf.Contents(), uintptr(off))), n*bf16Size)
911+
first := true
912+
for i := 0; i < n; i++ {
913+
h := uint16(b[i*2]) | uint16(b[i*2+1])<<8
914+
if h&0x7F80 == 0x7F80 {
915+
if h&0x007F != 0 {
916+
nan++
917+
if firstNaN < 0 {
918+
firstNaN = i
919+
}
920+
} else {
921+
inf++
922+
}
923+
continue
924+
}
925+
v := bf16ToF32(b[i*2], b[i*2+1])
926+
if first {
927+
minV, maxV, first = v, v, false
928+
continue
929+
}
930+
if v < minV {
931+
minV = v
932+
}
933+
if v > maxV {
934+
maxV = v
935+
}
936+
}
937+
return nan, inf, minV, maxV, firstNaN
938+
}
939+
940+
// argmaxInvalidDiag reads the greedy scratch's shared-storage stages after an invalid
941+
// direct-argmax token and reports where the garbage entered: the input hidden, the
942+
// RMS-normed hidden, the (quant-path) logits, and the per-tile argmax values/indices.
943+
func (h *headEncoder) argmaxInvalidDiag(scratch *headGreedyScratch, hiddenBuf metal.MTLBuffer, hiddenOff uint) string {
944+
rowsPerTile := bf16LMHeadArgmaxRowsPerTile
945+
if h.quant {
946+
rowsPerTile = bf16LogitsArgmaxRowsPerTile
947+
}
948+
tileCount := (h.vocab + rowsPerTile - 1) / rowsPerTile
949+
hn, hi, hmin, hmax, hf := bf16BufStats(hiddenBuf, hiddenOff, h.dModel)
950+
nn, ni, nmin, nmax, nf := bf16BufStats(scratch.normed, 0, h.dModel)
951+
d := core.Sprintf("\n argmax-diag: hidden NaN=%d Inf=%d min=%.4g max=%.4g firstNaN=%d\n normed NaN=%d Inf=%d min=%.4g max=%.4g firstNaN=%d",
952+
hn, hi, hmin, hmax, hf, nn, ni, nmin, nmax, nf)
953+
if h.quant && scratch.logits != nil {
954+
ln, li, lmin, lmax, lf := bf16BufStats(scratch.logits, 0, h.vocab)
955+
d += core.Sprintf("\n logits NaN=%d Inf=%d min=%.4g max=%.4g firstNaN=%d", ln, li, lmin, lmax, lf)
956+
}
957+
if scratch.tileValues != nil && scratch.tileIndices != nil {
958+
tv := unsafe.Slice((*float32)(scratch.tileValues.Contents()), tileCount)
959+
ti := unsafe.Slice((*int32)(scratch.tileIndices.Contents()), tileCount)
960+
tnan, tneg := 0, 0
961+
var tmin, tmax float32
962+
firstBad := -1
963+
for i := 0; i < tileCount; i++ {
964+
v := tv[i]
965+
if v != v {
966+
tnan++
967+
if firstBad < 0 {
968+
firstBad = i
969+
}
970+
continue
971+
}
972+
if i == 0 || v < tmin {
973+
tmin = v
974+
}
975+
if i == 0 || v > tmax {
976+
tmax = v
977+
}
978+
if ti[i] < 0 || int(ti[i]) >= h.vocab {
979+
tneg++
980+
if firstBad < 0 {
981+
firstBad = i
982+
}
983+
}
984+
}
985+
d += core.Sprintf("\n tiles=%d NaN=%d badIdx=%d min=%.4g max=%.4g firstBad=%d tile0=(%.4g,%d) tileLast=(%.4g,%d)",
986+
tileCount, tnan, tneg, tmin, tmax, firstBad, tv[0], ti[0], tv[tileCount-1], ti[tileCount-1])
987+
}
988+
return d
989+
}
990+
875991
func (h *headEncoder) logitsSampleUsable() bool {
876992
if h.finalNorm.buf == nil || h.weight.buf == nil || !logitsSampleBF16Usable(h.vocab) {
877993
return false

0 commit comments

Comments
 (0)