Skip to content

gemm: asymmetric INT8xINT4 (4-bit weights) on AIE2P 4x16x16 mmul — bit-exact + W4A8 llama decode - #182

Open
bong-water-water-bong wants to merge 19 commits into
amd:develfrom
1bit-MONSTER:devel
Open

gemm: asymmetric INT8xINT4 (4-bit weights) on AIE2P 4x16x16 mmul — bit-exact + W4A8 llama decode#182
bong-water-water-bong wants to merge 19 commits into
amd:develfrom
1bit-MONSTER:devel

Conversation

@bong-water-water-bong

Copy link
Copy Markdown

Summary

Adds asymmetric INT4 GEMM (A=i8, B=i4 packed two-per-byte) to the AIE2P 4x16x16 mmul path, plus the W4A8 llama inference arc that uses it.

The core fix (issue: sizeof(int4) pointer arithmetic)

The AIE API's int4_t is an empty struct — sizeof(int4) == 1 although each element is 4 bits. The kernel's manual const int4* pointer arithmetic (the j-block offset and the k-loop B advance) therefore moved 2x the real byte distance: the k-loop read k-blocks {0,2} then out-of-range, capping the effective K at half the tile. Fixed with B_ADV = size_B / 2 for int4 (identity for int8) applied to every B pointer advance in mm.cc (matmul_vectorized_2x2_mmul).

Verified

  • INT4 GEMM bit-exact vs numpy int32 reference at all tested shapes (K=32/64/128/256, M up to 1024, N up to 8192 via partition_N) — 10/10 runs each.
  • INT4 throughput 14.4 TOPS vs INT8 10.9 TOPS (1024x1024x4096) — 1.32x from the 4-bit density (the 4x16x16 mac is an emulated mixed-precision sequence on AIE2P, which is why it is not the full 2x).
  • W4A8 llama-3.2-1B decode arc (subsequent commits): group-wise i4 scales, real C coherence fixes, 3.5x decode speedup, full README with repro commands.

Commits

The PR spans the devel series from the asymmetric INT4 mmul (0fef28b) through the W4A8 llama decode + docs (57853b8). Happy to split if preferred.

bong-water-water-bong added 16 commits August 31, 2026 17:11
- aie_kernels/aie2p/mm.cc: software-pipeline the k-loop in
  matmul_vectorized_2x2_mmul — prologue loads k-step 0, the loop body
  consumes the previous step's A/B tiles while prefetching the next, and
  an epilogue MACs the final step. Hides load->vmac latency (verified
  bit-exact vs numpy int32 on NPU Strix Halo; ~7.8 TOPS at 2048^3 i8->i32).
- iron/operators/gemm/op.py: per-dtype tile-multiple validation matching
  the kernel static_asserts; _kernel_dtype_flag selects the {combo}_ONLY
  define (bf16_f32_ONLY under prio_accuracy since design.py resolves
  matmul_bf16_f32); reject integer outputs narrower than the 32-bit
  accumulator (i8->i8/i8->i16/i16->i16 truncate).
- iron/operators/gemm/design.py: merge duplicate npu1 entry; add i8/i16
  MAC dims for npu1/npu2.
- bench_int8.py / int8_bench.py / run_int8_gemm.py: NPU INT8 GEMM
  benchmarks (bit-exactness + TOPS, 2048^3 and shape sweeps).

Known limits (upstream design, not this kernel): first dispatch after an
xclbin reload in a multi-shape process can race the zero kernel (retry
exact); N=8192 with 8 columns exceeds the aie.dma_bd stride range
(per-column C slice 2^21 > 2^20) — use partition_N for very wide outputs.
…T8 bench

- aie_kernels/aie2p/mm.cc: chess_loop_range min no longer overstates the
  software-pipelined k-loop trip count (colA-1). The previous fixed (4,)
  hint mis-scheduled small K tiles (e.g. DIM_K=32, s=8 -> 3 trips). The
  hint is now colA-1 clamped to [1,4]; colA==1 keeps a statically-empty
  loop. Verified bit-exact on NPU at 2048^3 (8.42 TOPS best, slightly
  better than the flat hint).
- bench_int8_gemm.py: single parameterized harness replacing the three
  scratch scripts (bench_int8.py, int8_bench.py, run_int8_gemm.py) —
  shapes/reps/partition/tiles/seed flags, bit-exactness + TOPS, and an
  N-partition path for wide outputs past the aie.dma_bd stride cap.
- Known flake (not this kernel): rare (~5-10%) transient wrong result on
  a dispatch after multiple distinct xclbins were compiled in one process
  (XRT/amdxdna context race on Strix Halo; matches the hw_context retry
  documented in iron/common/sequence.py). Warm-up + verify retry handles it.
- iron/operators/gemm/op.py: GEMM docstring documents the rare (~5%)
  transient wrong result on NPU2 after several distinct xclbins compile in
  one process (zero/accumulate write races first submit on a fresh
  context; self-heals next dispatch), and the production guidance: warm-up
  once, verify first result, retry once on mismatch.
- bench_int8_gemm.py: --verify-retry re-runs once on a result mismatch and
  reports the retry outcome, so the transient flake is not read as a
  kernel failure.
One full-ELF OperatorSequence replaces the six per-op dispatches per
layer (gate/up GEMM, SiLU, mul, down GEMM, residual add). Weights and
activations round-trip through the shared fused buffers; the residual
is read back with a reshape (the fused 'x' subview is flat 1-D).

Verified on NPU2 (Ryzen AI MAX+ 395):
- prefill logits bit-identical to the separate-op path (corr 1.0)
- run-to-run bit-identical (deterministic)
- decode text identical ('SCENE I. The King' for 7-token prompt)
- TTFT 2.62-2.65s vs 2.72s separate (~3% prefill saving)

Also ignore build_elf_*/ artifact dirs.
Prefill GEMMs/elementwise ops ran at M=max_seq_len (2048) regardless of
the actual prompt (7 tokens) -- ~293x wasted compute per op. Split the
sizing: prefill ops now build at prefill_len = ceil(seq_len/512)*512
(GEMM tile constraints: M%(tile_m*4)==0, N%(tile_n*8)==0 for bf16, 8
cols), while decode ops and KV caches keep max_seq_len.

AIELlamaOperators/AIELlamaBuffers take prefill_len (default: prompt_len,
back-compat). main() derives it from the tokenized prompt length.

Measured (7-token prompt, Ryzen AI MAX+ 395):
- TTFT 2.72s -> 0.945s (separate ops), 0.921s (fused FFN) -- ~2.9x
- prefill logits bit-identical to the 2048-built run (corr 1.0)
- corr 0.9963 vs fresh CPU reference (top-1 agrees); decode text identical
…t-exact, half B bandwidth

Adds dtype_b='i4' to GEMM: B is packed (K, N//2) int8 (two nibbles per
byte, low nibble first) and the kernel uses the AIE2P 4x16x16 mmul
(1024 MACs/instr vs 512 for int8xint8 8x8x8). The int4_t empty-struct
sizeof==1 trap is handled with a B_ADV pointer-advance correction in
mm.cc (all manual B arithmetic halves the element count for int4).

design.py: packed-B tiling + L2->L1 fifo dims (verified: the generated
BD is [4,4,16,8]/[512,8,32,1], the mac sees B(kk',nn)=B4[16kk+kk',16nn+nn]).
op.py: dtype_b field, i8_i4_ONLY kernel flag, pack_i4 static method,
packed arg spec. bench_int8_gemm.py: --b-i4 flag.

Verified on NPU2 (gfx1151, 8 AIE cols, 64x64x64 tiles), bit-exact vs numpy:
  i8xi4 2048^3: 9.52 TOPS (vs i8xi8 8.02, +19%) — and B bytes halved
  i8xi4 1024^3: 4.27, 512^3: 1.27 (all exact=True bad=0 max_abs=0)
…e through i8xi4)

Quantizes real safetensors weights to INT4 (per-output-neuron scales) and
runs them through the asymmetric i8xi4 NPU GEMM with INT8 activations.
Bit-exact int math on the NPU (== CPU int reference); quantization loss vs
the bf16 reference: q_proj corr 0.9885, gate_proj corr 0.9902 with top-4
identical. This is the proof for threading i4 weights into the llama
pipeline (W4A8 path).
… per-token i8 acts)

llama_w4a8_npu.py runs all 16 layers of llama-3.2-1B with the NPU doing the
7 heavy GEMMs/layer as asymmetric i8xi4 (INT4-packed weights, per-output-
neuron scales) and per-token INT8 activations; host does embed/rmsnorm/rope/
attention-math/silu/lm_head. Validated vs the bf16 CPU reference:

  prompt: The capital of France is -> corr 0.936, top1 exact, top5 4/5
  prompt: What is 2 plus 2?        -> corr 0.937, top1 exact, top5 5/5

Fixes along the way: per-weight buffer sets (shape-keyed op pool overwrote B
bindings), warmup + npu_time sync for the XRT first-dispatch readback flake,
per-token (not per-tensor) activation scales (naive per-tensor: corr 0.55-0.75;
per-token: 0.94), padded-row zeroing so softmax/scale can't NaN.
Adds KV-cached incremental decode to llama_w4a8_npu.py: per token, the
7 layer GEMMs run on the NPU as i8xi4 with real_m=1 (row 0 = the token,
padding zeroed — weight bandwidth is halved by i4, the wasted M compute
is sub-ms). Host does rope/attention/silu against the bf16 KV cache
seeded by the W4A8 prefill.

Verified vs the bf16 reference: first decoded token exact (" Paris" for
"The capital of France is"); subsequent tokens degrade as W4A8 error
compounds through the decode loop (per-token scales drift). The decode
mechanism is correct — quality is bounded by the quantization scheme
(next lever: group-wise weight scales / keeping early-layer residual in
bf16).

Also fixed in the decode path: per-head attention batching shapes
([H,S] scores via unsqueeze(1)), KV append dim (unsqueeze(1) -> [G,1,hd]),
and the harness-state mutation gotcha when comparing vs the reference.
… isolation

- W4A8_GROUPS=N splits the i4 weight K-dim into N chunks, each with its own
  per-column scales, run as per-group i8xi4 GEMMs and dequantized per group
  (exact int path unchanged). Quality ladder on llama-3.2-1B prefill:
    G=1 : corr 0.937   (per-column scales)
    G=8 : corr 0.966
    G=16: corr 0.973   top5 5/5 (both prompts top1 exact)
    G=32: corr 0.972   (plateau)
  vs all-i8 weights: corr 0.9965 — i.e. the WEIGHTS are the W4A8 error
  bottleneck; activations (per-token i8) are nearly lossless.
- W4A8_MIX_LEN=N keeps the first N layers at i8 weights (0.937->0.966 at
  N=8) as a cheap partial-precision alternative.
- Fix: per-group A/C buffer pairs — the shared-buffer group path read stale
  C when the same op was called back-to-back with different B (the
  first-dispatch readback race again); each group now owns its buffers.
The "early EOS" and gibberish decode were NOT quantization — they were the
XRT readback flake: whenever the A-buffer content changed since the previous
kernel call, the first run returned STALE C (measured: all-rows corr 0.01,
the next call 0.994). The generated token's row always had new A data, so
its C came back stale/zero and the whole decode derailed.

Fix: always re-run each op and keep the second result (replaces the
warmup-once heuristic). Cost ~2x dispatches (sub-ms each).

Result — W4A8 llama-3.2-1B on XDNA2, group-wise i4 (G=16) + per-token i8:

  prompt "The capital of France is" ->
    "Paris is the capital of France. The city is located in the north of the
     country and is the largest city in the..."
  prompt "What is 2 plus 2?" -> corr 0.973, top1 exact, top5 5/5

Both prompts: prefill top1 exact, KV-cached decode generates coherent text.
The W4A8 pipeline is end-to-end correct; the decode quality is now bounded
by quantization, not by the runtime race.
Root cause of the "stale C" bug (ab47c82 worked around it with a double
call): the iron runtime's to('npu') upload is driven by a per-range
coherence map, and only WRITES IT KNOWS ABOUT are transferred. Writing the
A tensor via `tensor.numpy()[:] = ...` is an *unmediated* host write — the
runtime never marks the range dirty — so after the first dispatch the
buffer stays marked "on npu" and every later kernel runs on the STALE
previous A. The stale C was the correct computation of the wrong input.

Fix: `with tensor.overwrite() as buf: buf[:] = ...` records the write in
the coherence map, so the next to('npu') uploads it. Verified: the
previously-failing second-call test (all-rows corr 0.01) is now 0.9931
with a SINGLE call — the double-call workaround is removed, halving
dispatches (decode ~2x faster).

Full-model result unchanged (correctness was already right after ab47c82):
prefill corr 0.968, top1 exact; KV decode generates fluent text
("Paris is the capital of France...").
The dequant was the hidden host cost: `C * sx_full * s_w` over the full
[M_PAD, N] buffer in float64 was ~10 ms per GEMM call (and xG for the
group-wise path — 16 groups x full-N float64 multiply). Profiled:
op() dispatch+kernel 1.7 ms, to_torch 0.1 ms, dequant ~10 ms.

Fix: dequant in float32 and touch only the real_m rows (the padded rows
are zero anyway; decode has real_m=1 so the dequant is a 256x cut; prefill
real_m=256 unchanged work but float32). The returned tensor stays
[M_PAD, N] so the forward's view() shapes hold.

Measured (llama-3.2-1B, G=16 group-wise i4):
  big GEMM call: 12.2 ms -> 2.5 ms
  decode:        ~4500 ms/token -> ~1280 ms/token (3.5x)
  prefill corr:  0.9696 (unchanged), top1 exact
  decode text:   "Paris is the capital of France. The city is located in
                  the north of the country, on the river Seine."
…dder

Per-group activation scales (each K-group quantized with its own per-token
scale) now work correctly — the earlier 0.69 regression was the stale-A
bug, not the math. Knob: W4A8_GROUP_ACTS=1.

Quality ladder measured (llama-3.2-1B prefill logits corr vs bf16):

  i4 G=16                 0.973   top1 exact
  i4 G=16 + group acts    0.974
  mix 4 i8 layers + G=16  0.970   top5 5/5
  mix 8 i8 layers + G=16  0.977   (best)
  all i8 weights          0.9965  (bound — i4 noise floor is ~0.97-0.98)

The i4 quantization error is the limit; group scales are already
fine-grained (K=128/group). Recommended: W4A8_MIX_LEN=8 W4A8_GROUPS=16
(corr 0.977, top1 exact, fluent decode).
bong-water-water-bong added 3 commits September 1, 2026 13:49
W4A8_OPS_MIX=ffn_i8|attn_i8 isolates which op family keeps i8 weights.
Measurement (llama-3.2-1B prefill corr vs bf16, all layers):

  all i4 G=16           0.973
  attn i8 + FFN i4      0.977   (attention i8 barely helps)
  FFN i8 + attn i4      0.992   (FFN i8 is nearly the whole fix)

So the FFN projections (gate/up/down) carry the i4 quantization error;
the attention projections (q/k/v/o) tolerate i4 almost for free.

New recommended config: W4A8_OPS_MIX=ffn_i8 W4A8_GROUPS=8

  corr 0.983-0.990 (both prompts), top1 exact, top5 5/5
  decode 684 ms/token (attn G=8) — faster than the old all-i4 G16 (903)
  "The capital of France is" -> "Paris is the capital of France. It is
   the largest city in France and the most populous metropolitan area
   in the European Union..."
  "What is 2 plus 2?" -> corr 0.983, top1 exact (the G16 near-tie
   mismatch is gone at G8)

Also adds W4A8_ZP (asymmetric zero-point i4; measured no gain on llama —
weights aren't column-biased enough — kept as a disabled knob).
…ken)

Attention group curve with FFN i8: G8 0.9899, G4 0.9889, G2 0.9828,
G1 0.9787. G4 is the sweet spot (6x fewer dispatches than all-i4 G16:
19/layer vs 112). Decode 559 ms/token (8x faster than the thread start),
prefill corr 0.989 top1 exact top5 5/5, fluent factually-correct decode.
Fine-grained attention isolation (FFN i8 + attention, G4):

  all attn i4          0.989
  q,k i8 + v,o i4      0.990
  q,k,v i8 + o i4      0.992-0.993   <- final recommended
  q,k,o i8 + v i4      0.990

The attention projections split cleanly: q/k/v (which feed the scores)
want i8; the output projection o tolerates i4 at G4.

Final recommended config:
  W4A8_OPS_MIX=ffn_i8 W4A8_ATTN_I8=q,k,v W4A8_GROUPS=4
  - prefill corr 0.992-0.993 (vs 0.9965 all-i8 bound — the gap is just o i4)
  - top1 exact, both prompts
  - decode 554 ms/token
  - "The capital of France is" -> "Paris is the most visited city in the
    world. It is..."

Dead ends this round: NPU_RUNTIME=hrx (iron HRX runtime needs a newer
libhrx than installed — version mismatch, abandoned); the HRX-vs-XRT
dispatch-overhead question stays open.
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