Skip to content

npu: the b8 LM head runs one row per launch in a batched pass, so a 16-row verify pass spent its time in the head #1341

Closed

Description

@bong-water-water-bong

The number

The b8 LM head's tile GEMM runs 12.4x slower than a layer GEMM of exactly the same shape on the same
device, in the same process, at the same row count. Qwen2.5-0.5B b8, measured in-process ([bs] split plus an
isolated 16-launch head-only arm), 2026-11-07:

The measurement that isolates it: in one pass, at equal row count and the same shape, the head's launches and
the layer's launches differ by an order of magnitude, and what differs is not the kernel, the context or the
layout — it is the number of rows each launch carries.

Why it matters

The head is most of a batched (M=16-row) pass, one row at a time. Giving the head the rows it can take moves the
pass from 42.4 to 12.2-12.8 ms/row (3.3-3.4x), which is what makes a drafter/verify loop pay at all — without
it the layers amortise and the head eats the gain (#1309 measured exactly that, and closed on it).

So this is the item that makes row batching (a drafter/verify loop) pay: without it, batching the layers buys
little because the head eats the gain (#1309 measured exactly that, and closed on it).

Resolved (see the comments): engine-side, no kernel change

The candidates below were the hypotheses at filing time. The answer is in the thread: the head shares the
layers' context, kernel family and shape, and the difference was the row count per launch. Kept for the record.

Candidates at filing time (not the cause)

  1. Context switch per launch. The head runs on its own I8Ctx (ctx_head_) and 16 launches go through it
    per pass; npu_forward/engine-src/npu_engine_i8ctx_inc.h's own comment records hw_context switching at
    ~2.7 ms, and 16 x 2.7 would be ~43 ms of the 548 — i.e. a plausible full explanation. If so the fix may be
    engine-side (keep the head's launches on the same context as the layer that produced its input, or issue
    the head as one launch over the whole vocabulary), not a kernel change.
  2. Which ELF the head uses. There is no full_i8_LMHEAD_* in the 0.5B b8 set, so the head borrows a
    layer-shaped ELF at N=9728 (designs_[i_head]) with its own packed weight BO. A head-specific ELF (or the
    one-launch N=vocab form the gemv family already uses) may be the right shape.
  3. Weight BO construction. packB-packed per tile vs a resident b8 image; if the head's BO is walked
    in a different order than the layer's, every tile fetch could be re-reading 8 KiB bursts the kernel
    cannot coalesce.

Acceptance

Head tile at ≤ 3 ms for (M=16, K=1024, N=9728) on 0.5B b8 — i.e. within noise of the layer GEMM of that shape
— with logits matching today's head bit-for-bit at temperature 0 on three prompts, and a batched pass at
≤ 15 ms/row. Same rows as this issue: one npu-hold ticket per attempt, amdxdna ERROR 0, medians when the
box is below loadavg 10.

Kernel work stays in npu-kernels (rule 4); this issue is the public statement of the measurement and the
acceptance. Refs #1309 (closed on this measurement), #671 (the pack program).

Activity

  1. bong-water-water-bong commented on Nov 7, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Correction and a cleaner isolation: it is not tiling/layout — the same ELF, on the same shape at the same row count, is 17x slower through the head's own path

    The 12.4x in the issue body compared the head's _b8_m16 tile GEMM against the _b8 (M=128) build of GU
    in a different phase. Those are two different kernel builds, so the comparison was confounded — my error, and
    this run tests exactly that. Three arms, one quiet-ish box (loadavg 5-13), one ticket each, Qwen2.5-0.5B b8:

    kernel dir batched pass (16 rows) head cost GU cost
    full set (_b8 + _b8_m16) 678 ms (42.4 ms/row) 535.9 ms = 33.5 ms/row 2.037 ms/launch (_b8)
    only _b8 (no m16 twins) 2113 ms (132 ms/row) 1733 ms 5.5 ms/launch
    full set minus the GU m16 twin (head falls back to _b8) 2066 ms (129 ms/row) 103.3 ms/launch 5.51 ms/launch

    So the m16 twins are the right family for a 16-row pass (forcing _b8 costs 3x overall and 3x on the head),
    and the head is not mis-tiled. The clean comparison is inside one run, at equal M, equal rows, and — since
    the head borrows the GU design's ELF — the same ELF file:

    [prof] dec   GU    16   19 launches   ql 0.08   wait  1.928   ms/launch  2.009   (1 row, sequential arm)
    [prof] dec   HEAD  16   16 launches   ql 1.80   wait 29.959   ms/launch 31.758   (same 1 row, batched arm)
    

    Same ELF, same (K=1024, N=9728), same one row, same process: 1.93 ms vs 29.96 ms — 15.5x in wait. That
    kills every tiling/layout explanation (they would have to differ, and they cannot: same artifact), and it also
    kills my residency guess: the head-only arm (no layers streaming beside it, so nothing to evict it) still costs
    33.2 ms/row, unchanged across passes.

    What is actually different

    Only the plumbing of the head's own launch context — ctx_head_, a second I8Ctx with its own weight BOs
    (16 of them, one per vocabulary tile) and, if the ELF pool keys on a different path spelling, its own
    hw_context and its own copy of the same ELF. The per-launch ql also differs by 22x (1.80 vs 0.08 ms) for
    quantising the same one row, which points the same way: the head's path does the work, but through objects the
    layer path does not use.

    Ranked, for the next pass (all cheap to check, one ticket each):

    1. Context identity: does ctx_head_ share the pooled ElfHost/hw_context with GU's decode context, or is
      it a second one? ElfHost is keyed by the ELF's canonical path, and the head resolves its ELF through
      m16dirs_like_head(...) — a different spelling there silently yields a second context. Cost of a
      hw_context switch is documented in this header at ~2.7 ms, so it does not explain 30 ms alone, but it is
      the first thing to rule out because it also doubles the ELF's device footprint.
    2. Weight-BO construction: 16 separate make_weight_bo allocations per tile versus one layerB[l] for GU.
      If the head BOs land in a different memory class (or each is re-mapped per launch), per-launch cost becomes
      a mapping cost, not a compute cost — which is what an invariant 30 ms with no dependence on rows looks like.
    3. Only if 1 and 2 are clean does the kernel's tile walk for this role become the suspect again, and then it is
      a private-kernel question.

    Not claimed: anything about absolute rates from the earlier loaded runs (loadavg 36-47), and the 12.4x figure
    above, which is withdrawn.

  2. bong-water-water-bong commented on Nov 7, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Correction and a cleaner isolation: it is not tiling/layout — the same ELF, on the same shape at the same row count, is 17x slower through the head's own path

    The 12.4x in the issue body compared the head's _b8_m16 tile GEMM against the _b8 (M=128) build of GU
    in a different phase. Those are two different kernel builds, so the comparison was confounded — my error, and
    this run tests exactly that. Three arms, one quiet-ish box (loadavg 5-13), one ticket each, Qwen2.5-0.5B b8:

    kernel dir batched pass (16 rows) head cost GU cost
    full set (_b8 + _b8_m16) 678 ms (42.4 ms/row) 535.9 ms = 33.5 ms/row 2.037 ms/launch (_b8)
    only _b8 (no m16 twins) 2113 ms (132 ms/row) 1733 ms 5.5 ms/launch
    full set minus the GU m16 twin (head falls back to _b8) 2066 ms (129 ms/row) 103.3 ms/launch 5.51 ms/launch

    So the m16 twins are the right family for a 16-row pass (forcing _b8 costs 3x overall and 3x on the head),
    and the head is not mis-tiled. The clean comparison is inside one run, at equal M, equal rows, and — since
    the head borrows the GU design's ELF — the same ELF file:

    [prof] dec   GU    16   19 launches   ql 0.08   wait  1.928   ms/launch  2.009   (1 row, sequential arm)
    [prof] dec   HEAD  16   16 launches   ql 1.80   wait 29.959   ms/launch 31.758   (same 1 row, batched arm)
    

    Same ELF, same (K=1024, N=9728), same one row, same process: 1.93 ms vs 29.96 ms — 15.5x in wait. That
    kills every tiling/layout explanation (they would have to differ, and they cannot: same artifact), and it also
    kills my residency guess: the head-only arm (no layers streaming beside it, so nothing to evict it) still costs
    33.2 ms/row, unchanged across passes.

    What is actually different

    Only the plumbing of the head's own launch context — ctx_head_, a second I8Ctx with its own weight BOs
    (16 of them, one per vocabulary tile) and, if the ELF pool keys on a different path spelling, its own
    hw_context and its own copy of the same ELF. The per-launch ql also differs by 22x (1.80 vs 0.08 ms) for
    quantising the same one row, which points the same way: the head's path does the work, but through objects the
    layer path does not use.

    Ranked, for the next pass (all cheap to check, one ticket each):

    1. Context identity: does ctx_head_ share the pooled ElfHost/hw_context with GU's decode context, or is
      it a second one? ElfHost is keyed by the ELF's canonical path, and the head resolves its ELF through
      m16dirs_like_head(...) — a different spelling there silently yields a second context. Cost of a
      hw_context switch is documented in this header at ~2.7 ms, so it does not explain 30 ms alone, but it is
      the first thing to rule out because it also doubles the ELF's device footprint.
    2. Weight-BO construction: 16 separate make_weight_bo allocations per tile versus one layerB[l] for GU.
      If the head BOs land in a different memory class (or each is re-mapped per launch), per-launch cost becomes
      a mapping cost, not a compute cost — which is what an invariant 30 ms with no dependence on rows looks like.
    3. Only if 1 and 2 are clean does the kernel's tile walk for this role become the suspect again, and then it is
      a private-kernel question.

    Not claimed: anything about absolute rates from the earlier loaded runs (loadavg 36-47), and the 12.4x figure
    above, which is withdrawn.

  3. bong-water-water-bong commented on Nov 7, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Fixed, and the cause was not in the kernel set — engine PR #1348

    Cause. With a debug print of what each launch actually sees (NPU_HEAD_DBG=1, now in the tree) the head and
    the layers turned out to share everything the issue suspected: the same pooled ElfHost / hw_context, the
    same kernel family, the same shape (K=1024, N=9728). What differed was the row count on the launch.
    run_lm_head_rows had a per-row launcher only for the ternary head; the int8 packed head fell back to
    M x ntiles single-row launches, and a single-row launch is latency-bound — a single-row launch leaves the array idle
    behind each weight fetch where a batch-row launch does the same work with the rows it needs.

    Fix (#1348). One launch per vocabulary tile for the whole batch, via the launch_async_with_bo_rows the
    ternary path already used: per-row amax/127 activation scales, dequantised per row with that row's own scale.
    No kernel change, and none was needed.

    Measured, Qwen2.5-0.5B b8, one npu-hold ticket, loadavg 6.9, amdxdna ERROR 0:

    before after
    batched verify pass, 16 rows 678.2 ms (42.4 ms/row) 194.4-202.5 ms (12.2-12.7 ms/row) 3.3-3.4x
    single-token decode — unchanged M=1 never enters this path

    Numerics: bit-identical — 0/16 rows differ in argmax, worst |dlogit| = 0.000e+00.

    Two corrections to my own earlier text, both in the thread: the 12.4x in the issue body compared two different
    kernel builds and is withdrawn, and the row-outer hypothesis was wrong. What the 15.5x turned out to measure is
    the per-row/per-batch difference reported above.

    What is left on this issue: nothing on the engine side. The pass is now layer-dominated again (144.8 ms
    layers vs 28.7 ms head), so the remaining lever for verify-based decode is the layers' own cost — #1309's item 1
    (fused per-layer ELF) territory — and the drafter that would drive the batch is #1309's item 3 / #805.

  4. changed the title [-]npu: the b8 LM head's tile GEMM runs 12.4x slower than an identically shaped layer GEMM (9.3 vs 115 GFLOP/s), and it is 79% of a batched pass[/-] [+]npu: the b8 LM head runs one row per launch in a batched pass, so a 16-row verify pass spent its time in the head[/+] on Nov 7, 2026
  5. bong-water-water-bong commented on Nov 7, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Fixed and merged — engine #1348 (017335c)

    The cause was not tiling, layout or the kernel set. The head shares the layers' hw_context, kernel family and
    shape; what differed was the row count on each launch: run_lm_head_rows had a per-row launcher only for the
    ternary head, so the int8 packed head fell back to M x ntiles single-row launches. The fix gives the int8 head
    the same batching the ternary one already had — one launch per vocabulary tile for the whole batch, with each
    row's own activation scale — and gates on M <= hctx->MD so a batch wider than the twin falls through to the
    per-row path.

    Measured end to end, Qwen2.5-0.5B b8, one npu-hold ticket, amdxdna ERROR 0:

    before after
    batched verify pass, 16 rows 678.2 ms (42.4 ms/row) 194.4-204.1 ms (12.2-12.8 ms/row) 3.3-3.4x
    single-token decode unchanged unchanged M=1 never enters this path (gated)

    Numerics bit-identical: 0/16 rows differ in argmax, worst differing logit 0.000e+00.

    Scope, so the next reader does not expect more: this makes a verify batch cheap, which is the prerequisite for a
    drafter/verify loop (#1309's remaining lever, #805's territory). Plain one-token decode is unchanged on purpose.
    After the fix the pass is layer-dominated again, so the next lever for batched decode is the layers themselves —
    #1309 item 1 (a fused per-layer artifact), not this head.

    Closing as fixed. No kernel change was needed and none was made.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions