Skip to content

npu: blocked weight layout takes 27B decode from 8.87 to 4.15 s/token (stacked on #661) - #667

Merged
bong-water-water-bong merged 1 commit into
mainfrom
npu/blocked-b-layout
Oct 30, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
npu/blocked-b-layout

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #661 (base = npu/bonsai-decode-speed): the arena's harvest #2, integrated. Decode
8.87 → 4.10–4.29 s/token on the H2 container, bit-exact.

The mechanism

The device streams 25.60 GB of int8 weights per decode token. In the layout every container has
today, each 8 KiB B tile is delivered by a 4-level tap sizes=[8,16,8,8] strides=[8N,8,N,1] — 64
runs of 128 contiguous bytes with a row stride of N
. Every parameter inside that layout was swept
in the previous pass (tile size, run length, columns, core rows, fifo depth, issue/await
discipline): 2.3–3.1 GB/s, ceiling. Pre-blocking the same bytes into the microkernel's own tile
order ([kb][nb][8][8], tile-n-major) turns each tile into one contiguous 8 KiB burst
(strides=[1024,64,8,1]) and the same phase runs at 8.13 GB/s.

Arena per-phase measurements (byte-exact on 3 seeds, ~/wt/npu-arena/LEADERBOARD.md):

phase stock blocked B ×
GU 3.32 GB/s 8.13 2.45
D 3.19 8.25 2.59
QKV 3.35 8.15 2.43
O 3.19 8.15 2.55
Z 3.44 8.36 2.43

What the diff does

  • q4nx_weight_layout in the model dir's config.json: absent/"rm" = row-major (every
    earlier container), "b8" = blocked. tools/gguf_to_q4nx.py --weight-layout rm|b8 writes it, so
    a container and the kernel family it needs are decided in one place.
  • I8Ctx::bpos(i,j) maps element (i,j) of the padded [KD][ND] image into the blocked buffer;
    packB_blocked() writes through it, so packB, packB_into and the resident lm_head tiles all
    follow. Quantizer, per-column scales and every int8 code are unchanged — the buffer is a
    permutation, so the int32 output is bit-identical. Cross-checked against the arena's
    device-proven ordering over all 32,768 positions of a 128×256 image: 0 mismatches, bijective.
  • _b8 artifact naming, because the tap lives in the ELF: a b8 container looks for
    full_i8_<ROLE>_K<K>_N<N>_b8.elf and twins ..._b8_m16.elf; the fused-GU and batched-MoE
    existence probes use the same tag; a missing artifact is a loud "missing full ELF" instead of a
    silently permuted read; init_elf() refuses a b8 pack on a geometry that is not a whole number of
    64×128 tiles.
  • No microkernel change, no new design, no new cores. The 14 ELFs (7 roles × {M=128 prefill,
    M=16 twin}) come from the same generator with the contiguous B tap
    (~/wt/bonsai-speed/emit-b8-elfs.sh, gen-b8/n1_blocked.py) — the arena proved the microkernel
    needs no edit.

Numbers on the H2 container (d1/b8.log)

[ph] wall=4098-4286 | layers=3922-4110 (wait 3163-3316, host 537-588, ql 184-201, n=368)
     | head=176-205 (wait 156-189)
before after
decode step 8.87 s 4.10–4.29 s
1-token request 9.22 s 4.46 s
4-token request 36.1 s 16.8 s
device wait for 25.60 GB 8.01 s (3.2 GB/s) 3.32 s (7.7 GB/s)
host + quantise 0.85 s 0.80 s

Gates

  • NPU_M16_CHECK=1 over the b8 family: checks=7295 mismatched=0 worst int32 maxdiff=0 —
    twin vs M=128 on the same quantized activations and the same weight BO, both families on the
    blocked layout (d1/b8gate.log).
  • Greedy text identical to the row-major baseline on 7 prompts and both timing requests
    (d1/b8.out vs d1/after.out): ' Paris', ' Paris.\nThe', ' Paris.',
    '<|im_end|><think>', ' Au.', '\n\n<think>', ' 4', ' George Washington'.
  • PPL is unchanged by construction (permutation of the same codes; the gate compares raw int32).

Not in this PR

The remaining 0.80 s/token of host + quantise (0.55 layer host, 0.20 quantise/launch) is now 19%
of the step and is the next lever — the device side alone is 3.32 s, so ≤3.5 s/token needs that host
work down, not another kernel change. Container-payload blocking in the converter (as opposed to the
declared layout + load-time pack) would only save load time, not decode.

The ELF set this needs (_b8) — and why it is not in this PR

The B tap lives inside the ELF, so a container that declares q4nx_weight_layout: "b8" needs a
kernel family built with the contiguous tap. The engine derives the names from the declaration, so
a mismatch cannot be silent: a b8 container asks for ..._b8.elf / ..._b8_m16.elf and a missing
artifact fails with missing full ELF instead of reading permuted weights.

For the H2 Bonsai-2-27B container (~/models/npu/bonsai2-27b-NPU, H=5120, IM=17408, 64 layers,
48 GDN + 16 full-attn) that is 14 ELFs — 7 role geometries × 2 families:

role K × N prefill (M=128) decode twin (M=16)
QKV (full-attn layers) 5120 × 14336 full_i8_QKV_K5120_N14336_b8.elf ..._b8_m16.elf
QKV (GDN layers) 5120 × 10240 full_i8_QKV_K5120_N10240_b8.elf ..._b8_m16.elf
O 6144 × 5120 full_i8_O_K6144_N5120_b8.elf ..._b8_m16.elf
G 5120 × 17408 full_i8_G_K5120_N17408_b8.elf ..._b8_m16.elf
U 5120 × 17408 full_i8_U_K5120_N17408_b8.elf ..._b8_m16.elf
D 17408 × 5120 full_i8_D_K17408_N5120_b8.elf ..._b8_m16.elf
Z (GDN gate) 5120 × 6144 full_i8_Z_K5120_N6144_b8.elf ..._b8_m16.elf

These ELFs stay private — they are not added to the repository (same rule as the rest of the
NPU kernel artifacts: the repo ships the generator and the emitter, not the binaries). They are
rebuilt from source with the pinned toolchain by:

~/wt/bonsai-speed/emit-b8-elfs.sh <out_dir>      # generator gen-b8/n1_blocked.py,
                                                 # microkernel mm_kernel_reference.cc (unchanged)

and dropped into the model dir's kernels directory (<model>/kernels/, or <model>/kernels-m16/
for the twins — the loader searches both). The private Bonsai container directory used for the
numbers above is ~/models/npu/bonsai2-27b-NPU-b8/ (symlinks to the H2 model.q4nx + tokenizer, a
config.json carrying q4nx_weight_layout: "b8", kernels -> b8-kernels/). Because the layout is
declared in the container's own config.json, the same model directory cannot be served with the
wrong family by accident.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Re-stacked on the rebased #661 head (): this branch is now , cherry-picked rather than range-rebased so it carries only the layout change (files=4 on GitHub). Builds clean. Its 4.10-4.29 s/token and the 7,295-launch b8 gate were measured before the rebase; they are unaffected by it (the rebase touched only the FFN-cache ownership in #661), and I will re-confirm on the device after the box comes back.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Re-stacked on the rebased #661 head (9551326). My comment above got its backticks eaten by the shell — the correct text:

  • this branch is now 7251733, cherry-picked onto the rebased head rather than range-rebased, so it carries only the layout change (GitHub shows files=4), base = npu/bonsai-decode-speed.
  • Builds clean with the pinned toolchain.
  • Its numbers (4.10-4.29 s/token, and the 7,295-launch NPU_M16_CHECK gate with mismatched=0) were measured before the rebase and are unaffected by it: the rebase changed only who owns the FFN-cache code in npu: Bonsai-2-27B decode 11.5 -> 8.87 s/token (M=16 twins, no per-token re-dequant) #661. I will re-confirm both on the device in the next window and attach the log here.

Base automatically changed from npu/bonsai-decode-speed to main October 30, 2026 17:55
The device streams 25.60 GB of int8 weights per decode token.  In the layout
every container has today, each 8 KiB B tile arrives as 64 runs of 128
contiguous bytes with a row stride of N -- on the 27B GU phase that is 3.32
GB/s, and no parameter inside that layout moves it (tile size, contiguity,
column/core count and the issue/await discipline were all swept: 2.3-3.1 GB/s,
kern-exp2/VERIFIED-DESCRIPTORS.md).  Pre-blocking the same bytes into the
microkernel's own tile order makes each tile ONE contiguous 8 KiB burst and the
same phase runs at 8.13 GB/s.  Measured per phase, arena oracle, byte-exact on
3 seeds: GU 2.45x, D 2.59x, QKV 2.43x, O 2.55x, Z 2.43x.

What this changes:

* Container contract: `q4nx_weight_layout` in the model dir's config.json.
  Absent or "rm" is the padded row-major image every earlier container gets;
  "b8" is the blocked image -- 64x128 tiles of [kb][nb][8][8], tile-n-major so
  a core's k-loop walks forward.  tools/gguf_to_q4nx.py writes it
  (--weight-layout rm|b8), so the payload and the kernel family it needs are
  decided in one place.
* I8Ctx::bpos() maps element (i,j) of the padded [KD][ND] image into the
  blocked buffer; packB_blocked() (and therefore packB/packB_into, including
  the resident lm_head tiles) write through it.  The quantization, the
  per-column scales and every int8 code are untouched: the buffer is a
  permutation of the old one, so the int32 output is bit-identical.  The
  mapping was cross-checked against the arena's device-proven ordering over
  all 32,768 positions of a 128x256 image: 0 mismatches, bijective.
* Artifact naming: a b8 container needs the _b8 kernel family
  (full_i8_<ROLE>_K<K>_N<N>_b8.elf, twins ..._b8_m16.elf), because the tap
  lives in the ELF.  The fused-GU and batched-MoE existence probes use the same
  tag, and a missing artifact is a loud "missing full ELF" rather than a
  silently permuted read.  init_elf() refuses a b8 pack on a geometry that is
  not a whole number of 64x128 tiles.
* The microkernel is unchanged and there are no new cores, no new design: the
  ELF families are emitted from the same generator with the contiguous B tap.

Measured on the H2 container (bonsai2-27b-NPU with the layout declared b8 and
the _b8 family; phase table d1/b8.log):

    before  8.87 s/token    device wait 8.01 s (3.2 GB/s) + 0.85 s host
    after   4.10-4.29 s/token   device wait 3.32 s (7.7 GB/s) + 0.80 s host
    1-token request 9.22 -> 4.46 s;  4-token request 36.1 -> 16.8 s

Gates: NPU_M16_CHECK=1 over the b8 family -- checks=7295 mismatched=0
worst int32 maxdiff=0 (twin vs M=128, same activations, same weight BO, both
phases of every layer); greedy text identical to the row-major baseline on all
7 arena prompts and both timing requests.  PPL is unchanged by construction
(the layout is a permutation of the same int8 codes and the gate compares raw
int32 accumulators).

The remaining 0.80 s/token of host+quantise is now 19% of the step and is the
next lever; the device side alone is 3.32 s.
@bong-water-water-bong
bong-water-water-bong merged commit d51d151 into main Oct 30, 2026
7 of 8 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the npu/blocked-b-layout branch October 30, 2026 18:04
bong-water-water-bong added a commit that referenced this pull request Oct 30, 2026
…for the layout contract (#668)

* tools/npu-arena: the kernel arena, its DMA-ceiling probe, and a test for the layout contract

The arena that produced the last two PRs was a private directory.  It belongs in
the repo: it is the harness every future kernel change is scored with, and the
numbers it produces only mean something if anyone can rebuild it.

tools/npu-arena/ is that harness, self-contained and box-agnostic (paths resolve
from the environment, or from a git-ignored TOOLCHAIN.lock; TOOLCHAIN.lock.example
documents the pins):

* `harness/gen.py` freezes a per-phase harness: A (activations) int8, B int8 in the
  engine's packed layout, the expected int32 = the exact integer matmul, per-column
  scales for provenance, and SHA256SUMS.  Three seeds, ternary with the container's
  real per-32-row-group magnitude structure, so a lossless 2-bit packing stays a
  legitimate entry.
* `runner/runner.cpp` is the on-device scorer: one full ELF, the frozen harness, a
  byte-exact int32 check (two consecutive clean reads, because the runtime's
  readback is occasionally short -- engine issue #666), median of N launches.  It
  can also score a submission's own weight layout (`--w`) and skip the check for a
  probe (`--no-check`).
* `make_task.sh` + `oracle.sh` are the competition: a task bundle per phase, and an
  oracle that rebuilds a submission from source with the pinned toolchain, refuses
  a shared device, replays the leader, and prints the leaderboard row on ACCEPT.
* `make_probe.sh` + `probe/mm_dmabw.cc` + `ceiling.sh` are phase 1 of the
  2026-10-30 14:58 plan: the phase's own objectfifos, taps, blocked layout, tile
  geometry and column-group loop with the matrix unit replaced by a pass-through
  microkernel, so a launch measures the data path alone.  Two variants per phase
  (8-core M=16 / 32-core M=64 with 4 core rows) and a CEILING.md report, so every
  entry can be judged as a percentage of what this box can actually stream.
* `LEADERBOARD.md` and `HARVEST.md` carry the records: the blocked-B layout is
  entry #2 on every phase (2.4-2.6x, since merged as #667), and what the numbers
  still owe (the 32-core N-partition, the 2-bit ternary stream).

Also moves the blocked layout's index math out of the runtime header into
npu/forward/include/q4nx_weight_layout.h, and tests it:
tests/npu_blocked_layout_test.cpp proves the mapping is a bijection over six
geometries (including the 27B's 5120x17408 and 17408x5120), that each 64x128 tile
occupies one contiguous 8192-byte window with the tiles k-fastest, that element
(i,j) inside a tile lands where the int8 microkernel reads it
(b_in[((kb*16+nb)*8+r)*8+c], an independent restatement of its "B LAYOUT
silicon-verified" note), that a geometry which is not a whole number of tiles is
refused, and that the row-major layout every earlier container uses has not moved.
Pure index math, no device and no runtime, so it runs in CI.

No kernel binaries: the ELFs stay private and are emitted from source with the
pinned toolchain (the same rule as the rest of the NPU artifacts).

* tools/npu-arena: split the kernel material out to the private kernel tree

Owner decision on this PR: the probe microkernel and the perf records are kernel
material, so they live with the rest of the ELF tree (npu-kernels) rather than in
the public engine.

Moved to npu-kernels (PR #19, branch arena/leaderboard-and-probe):
* `npu/probe/mm_dmabw.cc` -- the pass-through AIE microkernel the ceiling probe links.
* `docs/arena/LEADERBOARD.md`, `docs/arena/HARVEST.md` -- the per-phase records and the
  techniques/findings behind them.

What stays here is host tooling and documentation only:
* the arena's harness generator, runner, oracle, task/probe builders and ceiling driver,
  which reference the private tree by path (`NPU_KERNELS`/`PROBE_SRC`/`GENERATORS`) and
  carry no kernel source;
* the README, now describing the method instead of our measured tables;
* `q4nx_weight_layout.h` (the layout contract, mechanism only -- the numbers moved to the
  records) and its CI test.

Also applies tools/copyright.py --fix to the new files (the build's copyright check was
failing on them).

* npu: make the layout test compile itself, so CI actually runs it

CI builds an explicit target list and then runs `ctest`, so a registered test whose
binary is not on that list reports "Not Run" and never executes -- which is exactly
what happened to the layout test (test 41, 46 tests, 1 failed, exit 8).  Editing
that workflow list needs a token scope this lane does not have, and a test of pure
index math does not need a build target at all: `tests/npu_blocked_layout_test.sh`
compiles tests/npu_blocked_layout_test.cpp with the build's own compiler at test
time and runs it.

add_test() therefore passes the compiler, the include dir and the source.  It uses
nothing else: no runtime, no device, no NPU toolchain, so it passes on the hosted
runner.

Verified with the runner's own configure (cmake -G Ninja, gcc-16/g++-16, no NPU
options): `ctest -R npu_blocked_layout` -> Passed 0.21s.

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant