feat: rust port - #117
Conversation
9f4b9b2 to
3b15b3a
Compare
|
Here is my view of this effort:
@fabianlim wdyt? |
|
Fwiw, all of the Python tests are ported. Performance comparo — whole-model forward, ms/pass (Apple M5, f16; one warm-up excluded). Rust = the resident GPU executor (production default: prefill computes only the last token's logits, as generation does); Python = the per-node reference interpreter, with the #124 perf fixes now merged.
Python is the per-node reference interpreter after the now-merged #124 (vectorized (Prefill numbers are the production last-token default — only the final position's logits are computed, the same token generation samples, validated exact against the real model. The all-rows prefill path (every position, used by the comprehensive golden) is llama 186.6 / smollm2 45.8 ms. Python numbers unchanged.) Update (fused m=1 attention): decode attention had been running as a ~1500-op interpreter storm per layer (the Update (NAX matmul kernel): the prefill numbers reflect a vectorized Note: this single-pass |
|
@starpit — before we proceed with merging, I'd like to suggest we align on a landing plan first. I've opened issue #128 which documents the proposed commit sequence and flags a few items that need to be resolved (CI fixes, the Can you take a look at #128 and let us know if the proposed approach works for you, or if there's a different split you had in mind? |
ef6786b to
3454977
Compare
6bb04ac to
f4f58cc
Compare
79c1b64 to
7b8a73f
Compare
Faithful Rust port of the KTIR (RFC-0682) emulator: parser, ktdp dialect, SPMD grid scheduler, per-node reference interpreter, plus a production resident GPU executor (persistent HBM, weights uploaded once, fused [1,1] GPU/AMX offload + native head-parallel attention) for fast LLM prefill/decode on Apple M5 (NAX matmul2d tensor engine). Golden-exact vs the real HF model (next-token argmax within 3 logits). Resident decode is correct AND fast (real vllm decode ~1.7 -> 4.8 tok/s): * RESIDENT WEIGHT CACHING. set_sources keeps only PROVABLY-immutable model weights resident across decode steps — tids written by neither the forward (node outputs) nor the current set_sources — so the ~2 GB weight set is no longer re-decoded every token, while the KV cache (a forward-grown operand) is correctly re-read. ~88% of weights stay resident. * f16 GPU weight buffers, gated to engines that actually compiled the f16 pipelines (NAX); non-NAX Metal devices stay f32 and matmul_unified never unwraps a missing pipeline (graceful K-loop fallback). * lm_head last-token mode, RoPE tile-coalescing, and a re-tiling-aware compute-tile dataflow executor (behind KTIR_TILE_DATAFLOW). * ktdp.load/store drop the per-op AccessTile clone (no affine-map clone on the load hot path), cutting interpreter handler time on decode. * The comm-free multi-core grid runs SERIAL by default — the unsafe worker-pool path (shared-HBM allocator race) is opt-in (KTIR_PARALLEL_CORES) until fixed. Further decode/prefill speedups: * FUSED m=1 DECODE ATTENTION. Decode attention ran as a ~1500-op interpreter storm per layer (the H heads unrolled in the node body — const/splat/ access_tile plumbing around near-free BLAS GEMVs). A structural m=1 attention recognizer + a fused CPU dispatch (per head: QK^T GEMV -> softmax -> scores.V GEMV, f32 accumulate, with GQA, the per-position context mask, the 1/sqrt(d) scale, and the context/diagonal split) collapses it to ~3*H BLAS+softmax primitives. Golden-faithful (argmax-preserving; max-abs identical to the decomposed oracle). Resident decode: M5 llama 1.35x / smollm2 2.05x; M1 Max llama 5.4 -> 11 tok/s (the CPU plumbing hits older cores hardest, so it wins more there). KTIR_NO_FUSE_ATTN restores the decomposed path. * AOT-precompiled NAX kernels (embedded metallibs, compiled -mmacosx-version-min=26.2 to dodge the SDK-26.5 matmul2d half-K miscompile, with a runtime JIT fallback) + a vectorized matmul2d threadgroup loader. * Faster interpreter decode paths: slice/concat block-copy (vs per-element unravel), flat_memory_offsets odometer + small-rank specialized dot, decode_gather dtype-hoist. f16-B read path added to the pre-M5 simdgroup matmul so non-NAX prefill streams f16 weights. PYTHON <-> RUST CONFORMANCE — 100%, differentially proven: * A DIRECT differential harness (rust/crates/ktir-emulator/examples/ktir_diff_run.rs + rust/crates/ktir-emulator/tests/equiv/diff_py_vs_rust.py) runs the SAME seeded inputs through the Python reference KTIRInterpreter AND Rust execute_function and diffs outputs HEAD-TO-HEAD — not both-vs-a-hardcoded-answer-key (which is all the port_*.rs parity tests do; they never run Python). Across ALL 19 shared example programs (measured against origin/main): 17 are BIT-EXACT (max-abs Python-vs-Rust = 0) and 2 are matched-failure fixtures (both interpreters raise the same error category). Zero Rust-vs-Python gaps. Gated per-push by the full corpus in .github/workflows/rust-conformance.yml (cheap: tiny programs, numpy-only Python, no model weights). * Closing the divergences the harness surfaced required real interpreter work: non-identity indirect subscripts (index_view[%grid0 + %d0]) carried through parser/IR/interpreter (indexed_add, paged_attention); the ktdp.inter_tile_produce / inter_tile_reduce ring all-reduce collective (TileFuture, reusing the ring comm machinery — ring_reduce, ring_reduce_multi_group); and an f16-SUBNORMAL arith.truncf bug the harness caught (half-value at the normal/subnormal boundary), fixed to round through the table-backed codec. * The port tracks origin/main: the element-index base_ptr (torch-spyre#110), multi-dim linalg.reduce (torch-spyre#106), comm ops inside scf.for/scf.if (torch-spyre#133), and LX-liveness (torch-spyre#134/torch-spyre#118) changes are all ported, conformance held at 100% through the rebase. Two more bugs the harness caught en route: a Metal GEMM weight reader that read the pointer SSA as a stick not an element index (an e2e-golden-breaker), and a fusion rename that shared one reduce accumulator across layers (RMSNorm sum-of-squares grew unboundedly) — fixed by prefixing the outs_var attr so each fused reduce gets a fresh accumulator. * Hex-float constants: arith.constant 0xFC00 : f16 now parses as the IEEE bit pattern (-inf), not the integer 64512 — a pre-existing parser bug the unconditional outs-fold exposed (softmax/attention -inf accumulators overflowed to NaN). The parser is now type-aware (hex + float type => bit pattern, matching MLIR/Python). The differential harness was hardened in the same pass: a non-finite (NaN/inf) diff is now ALWAYS a divergence — it previously read as "0 PASS" because max(0, nan)==0 and nan>tol is False. * METAL FAST-PATH conformance. The differential harness also runs the example programs ON the production Metal path — execute_segmented/ResidentExecutor (new resident_runner.rs drives a single-function kernel through the segmented executor via scalar-specialization + a native-grid ProgramSpec) and the execute_function GPU path — forcing every Metal offload (the NAX/simdgroup matmul2d GEMM gate, the fused map-window kernel, fused attention). Because NAX rounds f16 inputs to bf16, this is TOLERANCE-BANDED, not bit-exact: a first-principles band 4*f16_ulp(|v|) + 2^-8*|v| (f16 output quant + bf16 input rounding). All 9 Metal-eligible programs conform within band — vector_add/softmax/softmax_wide/ layernorm (map kernel), matmul/sdpa/paged_attention (GEMM), vector_add_dynamic/ indexed_add (map, GPU path) — each with a MANDATORY OffloadProof>0 (a silent CPU fallback is a FALSE pass that FAILS). A gated descend routes scf.for-nested maps to the Metal map kernel ONLY under KTIR_FORCE_GPU_MAP, so the golden/production path is byte-identical (golden 6/6 unforced). A fault injector proves the check catches a real Metal divergence. Zero kernel divergence found. Gated test: tests/metal_conformance.rs + the KTIR_DIFF_RESIDENT / KTIR_DIFF_GPU harness modes. Tests: real-model golden (all-rows + last-token), a multi-step decode guard asserting both correctness (reused executor == fresh) and weight residency, layernorm e2e, the full ported emulator suite, and the Python<->Rust differential conformance harness. CI: rust workspace under rust/, plus .github/workflows/ci-rust.yml and .github/workflows/rust-conformance.yml. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Nick Mitchell <nickm@us.ibm.com>
| - '.github/workflows/rust-conformance.yml' | ||
| pull_request: | ||
| paths: | ||
| - 'ktir_cpu/**' |
There was a problem hiding this comment.
@starpit as written in #128, I will need to insist to not have the tests triggered only on rust paths. My reason is very strong, I simply do not want to put burden on ktir-cpu developers, to fix conformance discrepancies between ktir-cpu and rust. If we had this, then everytime we update ktir-cpu such that it goes out of sync with rust, then the CI will break, and we cannot merge our PRs until we reconcile the differences
My suggestion was that we have a workflow-dispatch that we can trigger an adhoc CI-run on main or a particular commit to test for breakages.
| # max-abs were measured locally on an Apple M5 (the production target). CI here | ||
| # guards against REGRESSION of the GPU path on whatever Apple GPU the runner | ||
| # has; the M5 NAX-tier numbers are validated on the dev machine. | ||
| gpu-diff: |
There was a problem hiding this comment.
torch-spyre is a spyre project. im not sure we want to run GPU Ci tests in our organization. Im concerned about billing, im not sure how GPU runners are charged to our organization.
That being said, from the above comment, im confused with the above comment, this runs on MacOS, so its running on an Apple silicon GPU?
Regardless, macos runners I believe are 10X more expensive than linux boxes
fabianlim
left a comment
There was a problem hiding this comment.
We need to resolve python developer burden and CI concerns before this can be merged. This projects main goal is to focus on correctness and latency modeling of ktir-cpu, which is evolving at a very fast pace. We are very appreciative for this contribution, but we cannot have the rust port in a critical CI path, which will impede the original goal of this repo. I hope you understand and try to accomodate all the concerns in #128 . I noticed alot of requests like i) staging the merge, and ii) introducing the sparse checkout (or figuring out an alternative if that does not work) is rejected.
Pls understand that since this project is high-velocity, im very cautious in introducing such a big change. This repo is used in evaluating our upstream triton repos and many others, the blast radius will be very big if something goes wrong.
The flash cap-tiling pass only ever fired for large-query attention, and even then iterated the full static cap. In the small-query/long-context regime that real decode and chunked prefill actually are (query rows m = 1..32, context length up to `cap`), it either did nothing (LX overflow) or did O(cap) work regardless of how much context was valid. Three fixes: 1. KV-tile-aware trigger + block sizing. `attention_needs_flash` keys on the `[m, cap]` scores tile, which is tiny when m is small — so flash never fired and the whole `[cap, d]` context K/V read blew the per-core LX budget (`LX capacity exceeded`). Add `ReRolledIsland::context_bytes()` (`cap*d`), fire flash when EITHER the scores tile OR the context K/V tile is too large, and size the KV block so BOTH `[m, blk]` and `[blk, d]` fit (`FLASH_CONTEXT_TILE_MAX`). Folds the old scores-only `choose_block_budgeted` into the KV-aware one (no dead code). 2. Finite running-max seed (NaN fix). The tiled online-softmax seeded its running max with the mask's `-inf`. A fresh prefill's prefix context is empty, so every context block is fully mask-additive `-inf`, giving `exp(-inf - -inf) = NaN`. Seed a finite floor instead: a fully-masked block yields `exp(-inf - floor) = 0` (contributes nothing), valid blocks are unchanged. This path was effectively dead before, so the bug was latent. 3. Runtime loop bound: O(actual context), not O(cap). The context mask `[1, cap]` is 0 on valid columns and `-inf` past `valid_len`, so `exp(mask)` is a 1/0 valid-column indicator; sum it (widened to f32 — an f16 sum saturates past 2048 and would undercount at a block boundary) to recover `valid_len`, and run `ceil(valid_len / blk)` blocks instead of `cap / blk`. A 32-token prefill chunk now runs 1 block, not `cap/blk`. Validated end-to-end on Llama-3.2-3B via the KTIR host runner: flash fires on all 28 layers (was 0), no LX overflow / NaN / deadlock at cap 3072/4096, output bit-matches the untiled cap-256 baseline, a ~700-token context answers correctly, and native-attention time drops ~12x (15.9s -> 1.3s on a ~500-token prompt). All 14 `flash_attn` unit tests pass.
This includes, under rust/
through both the Python reference interpreter and the Rust port and are diffed
head-to-head — bit-exact (max-abs 0) on the CPU/AMX interpreter across 19 programs,
and tolerance-banded on the Metal fast path (NAX/simdgroup GEMM + fused map, each
with a mandatory offload proof so a silent CPU fallback fails). Gated per-push in CI.
The rust/PERFORMANCE.md file describes the massive gains possible on an M5, compared to the current Python interpreter. on llama 3.2 1b, 500x on decode, 1000x on prefill.
Seperately, I can describe how one can generate other test fixtures.