Skip to content

Pilot v2: bf16 tile GEMM (AMX/AVX-512) as an additional ndarray-backed candidate - #5

Merged
AdaWorldAPI merged 8 commits into
mainfrom
claude/ndarray-gemm-pilot-v2-b7f3
Sep 4, 2026
Merged

Pilot v2: bf16 tile GEMM (AMX/AVX-512) as an additional ndarray-backed candidate#5
AdaWorldAPI merged 8 commits into
mainfrom
claude/ndarray-gemm-pilot-v2-b7f3

Conversation

@AdaWorldAPI

Copy link
Copy Markdown
Owner

Pilot v2 — a distinct attempt from PR #4

This is a second, independent pilot, following on from #4 (ndarray_avx512_mmm_f32_16x8, draft). #4 benchmarked ndarray::simd::BlasLevel3::blas_gemm and found it 5-10x slower than tract's hand-tuned AVX-512 asm kernel — root cause: blas_gemm allocates a fresh Array and re-packs its B operand on every single tile call, going through a full generic BLAS-level3 entry point each time.

This pilot uses a different ndarray entry point instead: ndarray::hpc::bf16_tile_gemm (via the canonical ndarray::simd::* re-export), whose bf16_tile_gemm_16x16_packed primitive takes a pre-packed VNNI B and computes with zero allocation inside the tile primitive itself, dispatching at runtime to AMX TDPBF16PS → AVX-512 VDPBF16PS → a decode+FMA polyfill.

What's structurally different from pilot v1 — and what isn't

MatMatMulKer's fused-op interpreter calls a kernel's AddMatMul step once per output tile (16x16 here), carrying that tile's full K depth. So this kernel's per-call work — a bf16 truncation pass plus one PackedBf16B::pack VNNI interleave — is still real per-tile allocation and work, same as pilot v1, not something hoisted above the tile-walking loop. The module doc in linalg/src/x86_64/ndarray_bf16_gemm.rs says this plainly rather than overclaiming a fix. What is different: the per-call work here is a single direct VNNI pack straight into a zero-allocation tile primitive, not a generic BLAS-level3 call that re-derives packing and backend dispatch from scratch on every tile.

Precision — stated accurately, not glossed in either direction

  • The accumulate arithmetic (C += A·B) is bit-exact across all three bf16_tile_gemm tiers for bf16-exact-integer operands with accumulation below 2^24 — asserted with assert_eq! in ndarray's own tests, not a tolerance check. This kernel introduces no additional lossiness of its own beyond that.
  • The real precision cost is the one-time f32→bf16 truncation of the input operands (7-bit mantissa vs f32's 23-bit) before they ever reach a tile primitive. This is genuine, user-visible precision loss for a general inference engine.
  • This is not "approximate GEMM" (the arithmetic itself isn't approximate) and it is not "bit-exact for real workloads" (real model weights are not bf16-exact integers, so the tier-parity exactness above doesn't extend to them).

Testing

test_mmm_kernel!'s exact-bit macro family assumes f32-exact output and can't pass here by construction, so this kernel gets a dedicated relative-tolerance test (bf16_tolerance::matches_naive_f32_reference_within_bf16_tolerance) run through the same MatMatMulKer fused-op path (AddMatMul + Store) the real dispatcher uses, against a naive f32 reference, with inputs deliberately chosen to be exactly bf16-representable so the tolerance measures accumulation/tier drift rather than re-measuring the truncation the doc already documents. A dispatch_stays_default test (same pattern as #4) pins that default kernel selection is unchanged.

Benchmarks (real, measured on this host)

Sapphire-Rapids-class Xeon with AVX-512 + AMX (amx_tile/amx_bf16 confirmed via /proc/cpuinfo). RUSTFLAGS="-C target-cpu=native", CARGO_PROFILE_BENCH_DEBUG=0, cargo bench -p tract-linalg --bench ndarray_bf16_gemm.

Tier that ran: AMX TDPBF16PS (confirmed via ndarray::simd::bf16_tile_gemm_tier()).

shape asm_16x8 ndarray_bf16_16x16 ratio
512x512x512 2.65 ms 11.21 ms ~4.2x slower
1024x1024x1024 22.8 ms 110.6 ms ~4.85x slower

Still slower than the hand-tuned asm kernel — the per-tile allocation/pack cost dominates here too, even though the inner tile primitive itself is allocation-free. Reported honestly, not fabricated or rounded favorably.

AMX correctness caveat (Gotcha 14, ndarray/.claude/AMX_GOTCHAS.md): on an oversubscribed VM, AMX tile state can silently corrupt under host CPU contention, with no crash — just wrong numbers. This sandbox's dedicated-CPU status is unknown. The AMX-tier numbers above are what was measured here, with this caveat, and are not presented as certified/production-verified correctness.

Scope

Purely additive, same as #4: no existing asm kernel, .S.j2 file, or dispatch preference is touched. retain_best ties the new candidate with the asm kernels on preference; every x86_64 dispatch tier still names its own asm kernels explicitly. No cutover is implied by this PR.

Requires the companion [patch.crates-io] path-patch to the local ndarray fork checkout (same setup as pilot v1) — not yet a permanent dependency change.

🍍

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht


Generated by Claude Code

… second additional candidate

PR #4's ndarray_avx512_mmm_f32_16x8 went through BlasLevel3::blas_gemm, which
allocates and re-packs B on every tile call, so it lost badly to the hand-tuned
asm kernel. This pilot tries a different ndarray entry point instead:
hpc::bf16_tile_gemm (via ndarray::simd), whose tile primitive takes a
pre-packed VNNI B and runs with zero allocation inside, dispatching at runtime
to AMX TDPBF16PS, AVX-512 VDPBF16PS, or a decode+FMA polyfill.

ndarray_avx512_bf16_mmm_f32_16x16 registers additively at the fixed 16x16 tile
geometry the primitive requires, truncates its operands to bf16, and calls
bf16_tile_gemm_16x16_packed once per AddMatMul step. That step is still called
once per output tile (that's how MatMatMulKer invokes any kernel body), so the
per-call pack is real work, not something hoisted above the tile loop -- the
module doc spells this out rather than overclaiming a structural fix. The
accumulate arithmetic itself is bit-exact across tiers for bf16-exact
operands; the actual precision cost is the one-time f32->bf16 truncation of
the inputs, which is real and stated plainly, not glossed as approximate math.

Tested with a dedicated relative-tolerance test rather than the exact-bit
test_mmm_kernel! macros, since those assume f32-exact output. Default
dispatch is unchanged (dispatch_stays_default test), and no existing kernel,
asm file, or preference is touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_e6ec0055-70f7-45db-93db-ac4e0560c7ad)

…ilot-v2-b7f3

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@AdaWorldAPI
AdaWorldAPI marked this pull request as ready for review September 4, 2026 12:43
linalg/src/lib.rs compiles the x86_64 module tree under
`feature = "foreign-inventory"` on any host arch, to enumerate x86_64
kernel names as metadata for cross-compiled builds -- but `ndarray` is
only a Cargo dependency on x86_64. The new kernel's unconditional
`use ndarray::simd::*` broke aarch64-apple-darwin CI. Gate the
ndarray-backed implementation and its test modules to
target_arch = "x86_64", with a stub for other arches that is never
reached at runtime since MMMRustKernel!(x86_64; ...) marks the real
kernel unbuilt there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19f79ba059

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread linalg/src/x86_64/mmm.rs Outdated
Comment thread linalg/src/x86_64/ndarray_bf16_gemm.rs Outdated
Comment thread linalg/src/x86_64/ndarray_bf16_gemm.rs
Comment thread linalg/src/x86_64/ndarray_bf16_gemm.rs Outdated
…ilot-v2-b7f3

# Conflicts:
#	linalg/Cargo.toml
The symbolic-N fallback in core::ops::einsum::kernel_selection::
strategize picks the largest-nr kernel per packing group, bypassing
preferred/boost entirely. This kernel's nr=16 exceeds every existing
f32 AVX-512 kernel's nr (max 12), so a real f32 model with a dynamic N
dimension could have silently landed on this bf16-truncating kernel.
Register it via MMMRustKernel!'s lower-level form, which skips the
inventory::submit! that makes a kernel discoverable by
MmmDispatch::native() -- the kernel stays directly constructible for
this pilot's own bench/tests, but is never selected automatically.
Rewrote dispatch_stays_default to assert non-discoverability for both
a concrete and a symbolic N, and trimmed the module doc to the current
contract instead of narrating pilot-v1 history and benchmark numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht
AdaWorldAPI pushed a commit that referenced this pull request Sep 4, 2026
Same defensive fix as pilot v2 (PR #5): the symbolic-N fallback in
core::ops::einsum::kernel_selection::strategize picks the largest-nr
kernel per packing group, bypassing preferred/boost entirely. This
kernel's nr=8 currently ties rather than exceeds the existing max
(avx512_mmm_f32_16x12's nr=12), but relying on that ordering to hold
is fragile and inconsistent with the "purely additive, no behavior
change" guarantee this pilot claims. Register it via MMMRustKernel!'s
lower-level form, which skips the inventory::submit! that makes a
kernel discoverable by MmmDispatch::native() -- the kernel stays
directly constructible for this pilot's own bench/tests, but is never
selected automatically. Rewrote dispatch_stays_default to assert
non-discoverability for both a concrete and a symbolic N.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht
MMMKernel! unconditionally generates test_mmm_kernel!'s bit-exact
suite for any registered <f32> kernel, regardless of whether it goes
through the inventory-submitting sugar or the raw form this pilot's
kernel uses -- the earlier dispatch-exclusion fix didn't touch test
generation. This kernel's accumulate path truncates operands to bf16,
so it cannot pass an exact-vs-f32-reference comparison by
construction, and CI caught the resulting failures
(x86_64::mmm::test_ndarray_avx512_bf16_mmm_f32_16x16::{frame,fuse}
::prop, fuse::packed_packed_bug_3) that a too-narrow local test filter
had missed.

Added an additive lossy_no_exact_tests flag to MMMKernel! (default
behavior unchanged for every other kernel) and set it for this one;
its own bf16_tolerance module remains its real correctness test.
Re-ran the benchmark after the fix to confirm the registration/test
change didn't touch the compute path: numbers are unchanged within
noise from the previously reported run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht
…ilot-v2-b7f3

# Conflicts:
#	linalg/Cargo.toml
#	linalg/src/x86_64/mmm.rs
#	linalg/src/x86_64/mod.rs

Copy link
Copy Markdown
Owner Author

Pushed a merge + a real correctness-of-CI fix (61ec53c, on top of fe9a74c):

Merge conflict: #4 merged to main while this PR was open, touching the same registration files (linalg/Cargo.toml, linalg/src/x86_64/mmm.rs, linalg/src/x86_64/mod.rs). Not a real conflict — both PRs' kernel registrations coexist; merged both in.

New bug found and fixed: CI failed with real, new test failures — x86_64::mmm::test_ndarray_avx512_bf16_mmm_f32_16x16::{frame,fuse}::prop and fuse::packed_packed_bug_3. Root cause: MMMKernel!'s expansion unconditionally generates test_mmm_kernel!'s bit-exact test suite for any <f32>-typed kernel, regardless of whether it goes through the inventory-submitting sugar or the raw form this PR already uses to skip automatic dispatch — my earlier fix addressed dispatch, not test generation. This kernel truncates to bf16 by design, so it can't pass an exact-vs-f32-reference comparison. (My own local test run before pushing used too narrow a filter and never exercised this auto-generated module path — CI caught what I missed.)

Added an additive lossy_no_exact_tests(true) flag to MMMKernel!/MMMRustKernel! (default behavior unchanged for every other kernel in this crate) and set it on this kernel; its own bf16_tolerance module remains the real correctness test.

Re-ran the benchmark after the fix to confirm the registration/test-generation change didn't touch the compute path — it doesn't:

shape asm_16x8 ndarray_bf16_16x16
512³ ~97 Gelem/s ~22.5 Gelem/s
1024³ ~98 Gelem/s ~19 Gelem/s

Unchanged within noise from the previously reported run. Still ~4-5x slower than the hand-tuned asm kernel — no cutover implied.

Verified locally: cargo fmt --all -- --check clean, cargo clippy -p tract-linalg --all-targets -- -D warnings shows only the same 2 pre-existing unrelated errors, cargo check --features foreign-inventory clean, full cargo test -p tract-linalg --lib is 4444/4444 passing (both this PR's and #4's kernels present).


Generated by Claude Code

…against the asm baseline into raw tile-primitive throughput versus per-tile conversion/packing overhead.

The new harness calls ndarray's bf16_tile_gemm_16x16_packed directly, outside MatMatMulKer, with operands pre-converted and pre-packed for one case and only the A operand converted at runtime for another, so the AMX arithmetic itself can be measured apart from PR #5's kernel body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht

Copy link
Copy Markdown
Owner Author

Gap decomposition — where the bf16-AMX kernel's slowdown actually lives

Added linalg/benches/amx_bf16_gap_decomposition.rs (commit eed2f66), a standalone criterion harness that calls ndarray::simd::bf16_tile_gemm_16x16_packed directly (bypassing MatMatMulKer entirely) to isolate the AMX tile arithmetic from PR #5's per-tile conversion/packing. Built with RUSTFLAGS="-C target-cpu=native" and CARGO_PROFILE_BENCH_DEBUG=0 per this session's build discipline. Real measured numbers, both shapes, same Sapphire-Rapids-class host as PR #5's original numbers:

Case 512³ time 512³ Gelem/s 1024³ time 1024³ Gelem/s
B0 asm avx512_mmm_f32_16x8 (baseline) 2.71 ms 99.1 23.9 ms 89.7
B1 raw AMX tile primitive, A+B fully pre-converted+pre-packed outside the timed loop 0.833 ms 322.3 5.17 ms 415.3
B2 raw AMX primitive, A converted f32→bf16 inside the timed loop (weight B stays pre-packed) 0.913 ms 294.0 5.46 ms 393.0
B3 PR #5 kernel as-is (ndarray_avx512_bf16_mmm_f32_16x16, existing ndarray_bf16_gemm.rs case) 11.87 ms 22.6 114.8 ms 18.7

Conclusion: the AMX tile primitive itself is fast — the gap is entirely in the kernel body's per-tile conversion/packing

B1 is 3.3–4.6x faster than the asm baseline, not slower — the raw TDPBF16PS tile arithmetic is not the bottleneck at all. B2 (runtime-converting only the A/activation operand, with B/weights pre-packed once) stays within ~10% of B1, so the runtime activation conversion is cheap too.

The entire ~14x gap between B1 (0.83 ms) and B3 (11.87 ms) at 512³, and ~22x at 1024³ (5.17 ms vs 114.8 ms), is attributable to add_mat_mul_bf16's current per-AddMatMul-call behavior: truncating both A and B to bf16 and VNNI-packing B again on every 16x16 output-tile call, discarding all of that work between tiles instead of hoisting it above the panel loop. This matches PR #4's diagnosed pattern exactly — it's tract's packing/plan layer (one-shot pack at prepare-time, not per-tile) that needs the fix, not the AMX kernel or the ndarray primitive.

Side-check: f32_to_bf16_batch_rne conversion instruction

Per the task's read-only check: f32_to_bf16_batch_rne (ndarray/src/simd_avx512.rs) does not use VCVTNEPS2BF16/VCVTNE2PS2BF16. It's a hand-written AVX-512-F-only bit-manipulation implementation (f32_to_bf16_x16_rne: shift/bias-add/blend for RNE + NaN/subnormal handling), explicitly documented as matching _mm512_cvtneps_pbh bit-exact while requiring only the AVX-512-F baseline rather than the dedicated AVX-512-BF16 instruction — a deliberate compatibility tradeoff (works on Skylake-X+, not just avx512bf16-capable hosts), not an oversight. B2 above shows this manual path is already fast enough that it isn't the bottleneck either.

No production kernel or packing/plan changes included, per scope — this is diagnosis only. cargo fmt --all -- --check clean; cargo clippy -p tract-linalg --all-targets -- -D warnings shows only the 2 pre-existing unrelated errors (chunks_exact_to_as_chunks in generic/reduce.rs, needless_borrow in x86_64/mmm.rs), nothing new from this change.

🍍


Generated by Claude Code

AdaWorldAPI pushed a commit that referenced this pull request Sep 4, 2026
Same class of bug as PR #5's earlier fix: linalg/src/lib.rs compiles
the x86_64 module tree under feature = "foreign-inventory" on any
host arch, to enumerate x86_64 kernel names as metadata for
cross-compiled builds, but ndarray is only a Cargo dependency on
x86_64. ndarray_amx_native_pack.rs and ndarray_bf16_native_gemm.rs
use ndarray types throughout rather than in one or two functions, so
rather than per-item stubs (this file's other pilot kernels' pattern)
both get a whole-module #![cfg(target_arch = "x86_64")] gate, and the
mmm.rs registration that references their symbols is gated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wrzeZAdwGYTCKoxamwQht
@AdaWorldAPI
AdaWorldAPI merged commit d8a60c1 into main Sep 4, 2026
54 of 65 checks passed
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.

2 participants