Skip to content

feat: rust port - #117

Open
starpit wants to merge 2 commits into
torch-spyre:mainfrom
starpit:rust
Open

feat: rust port#117
starpit wants to merge 2 commits into
torch-spyre:mainfrom
starpit:rust

Conversation

@starpit

@starpit starpit commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

This includes, under rust/

  • a port to Rust
  • cublas acceleration
  • metal acceleration
  • an KTIR-to-KTIR optimizer that covers a few important cases
  • 4 test fixtures that have KTIR/MLIR for full forwards for the following scenarios:
    • smollm-135m decode
    • smollm-135m prefill m=8
    • llama-3.2-1b decode
    • llama-3.2-1b prefill m=32
  • goldens verified vs transformers on these fixtures
  • a direct Python↔Rust differential conformance harness: the example programs run
    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.
  • unit and also e2e tests (the latter run benches and goldens validation)

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.

@starpit
starpit marked this pull request as draft June 15, 2026 23:15
@starpit
starpit force-pushed the rust branch 14 times, most recently from 9f4b9b2 to 3b15b3a Compare June 16, 2026 16:36
@lchu6

lchu6 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Here is my view of this effort:

  1. For ktir-cpu, correctness/reference behavior matters more than raw speed (if raw speed even matter), so this repo benefit less/much-less than some other torch-spyre repos.
  2. It is still a good addition - it is 100% isolated so I am not scared to adopt it. and it can be useful for big validation workloads and future cross-repo reuse.
  3. My real concern is maintenance and semantic drift. How can we make sure this is guarded by Python parity, and who is the owner for that.

@fabianlim wdyt?

@starpit

starpit commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

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.

config Rust (ms/pass) Python (ms/pass) speedup
smollm2-135m decode 17.6 2,536.3 144×
smollm2-135m prefill (m=8) 45.6 60,397.4 1324×
llama-3.2-1b decode 71.6 31,777.6 444×
llama-3.2-1b prefill (m=32) 169.1 977,688.3 5782×

Python is the per-node reference interpreter after the now-merged #124 (vectorized ktdp.load offsets, ravel-not-flatten allocation reads, O(log n) allocation lookup) — so these are the de-gratuitized numbers; the gap is still 144–5782×. llama-3.2-1b prefill needs a raised LX cap (KTIR_LX_MB) to run under the faithful 2 MB LX per-node reference.

(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 H heads unrolled in the node body — the BLAS GEMVs are near-free; the cost was the const/splat/access_tile plumbing around them). A structural m=1 attention recognizer + a fused CPU dispatch — per head, QKᵀ GEMV → softmax → scores·V GEMV (f32 accumulate, GQA + per-position context mask + 1/√d scale + context/diagonal split) — collapses it to ~3·H BLAS+softmax primitives, golden-faithful (max-abs identical to the decomposed oracle). Resident decode: llama 97.4→71.6 (1.35×), smollm2 33.9→17.6 (2.05×) on the M5. Because it removes ~20k op-dispatches/token of pure CPU plumbing, it wins more on slower-CPU devices: real vLLM llama-3.2-1b decode on an M1 Max went 5.4→11 tok/s — clearing the CPU bottleneck lets the M1 Max's ~2.5× memory bandwidth win the (now-dominant) weight-streaming GEMMs and pull ahead of the M5. KTIR_NO_FUSE_ATTN restores the decomposed path. Prefill is unchanged (the fused path is m=1-only).

Update (NAX matmul kernel): the prefill numbers reflect a vectorized matmul2d threadgroup loader — wide 4-element coalesced device loads + threadgroup stores replacing the per-element div/mod + per-element bounds checks in the staging path (the GEMMs were loader-bound, streaming weights at only ~12 GB/s). llama, with the largest GEMMs, gains most (prefill 196.6→169.1, −14%); smollm2's tiny m=8 GEMMs are in the noise. Bit-identical output (golden unchanged). The NAX kernels are also now AOT-precompiled (embedded metallibs, with the mandatory -mmacosx-version-min=26.2 workaround for the SDK-26.5 matmul2d half-K miscompile, JIT fallback retained) — but that's startup-only and does not affect these steady-state numbers.

Note: this single-pass ms/pass bench marshals sources once, so it does not capture the biggest serving-loop fix — a resident-weight-caching bug where set_sources re-decoded + re-uploaded the whole ~2 GB weight set every decode token in the real serving loop (vLLM calls set_sources per token to thread the KV cache). Keeping only the provably-immutable weights resident took real vLLM decode from 1.7 → 4.8 tok/s (~88% of weights now stay resident across steps), and the fused m=1 attention above took it further. The table numbers are steady-state with weights already resident.

@fabianlim

Copy link
Copy Markdown
Collaborator

@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 examples/ compile-time dependency, sparse checkout ergonomics for Python contributors, and some bench scripts with hardcoded local paths).

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?

cc @kiszk @lchu6 @nwang-ibm

@starpit
starpit force-pushed the rust branch 3 times, most recently from ef6786b to 3454977 Compare June 18, 2026 18:58
@starpit
starpit force-pushed the rust branch 6 times, most recently from 6bb04ac to f4f58cc Compare June 18, 2026 21:32
@starpit
starpit marked this pull request as draft June 18, 2026 21:46
@starpit
starpit force-pushed the rust branch 12 times, most recently from 79c1b64 to 7b8a73f Compare June 22, 2026 12:42
@starpit
starpit marked this pull request as ready for review June 22, 2026 12:50
@starpit
starpit marked this pull request as draft June 22, 2026 13:21
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>
@starpit
starpit marked this pull request as ready for review June 22, 2026 19:39
- '.github/workflows/rust-conformance.yml'
pull_request:
paths:
- 'ktir_cpu/**'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@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.

cc: @mudhakar @raghukiran1224 @lchu6

# 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:

@fabianlim fabianlim Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 fabianlim left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.
@starpit
starpit requested a review from lasch as a code owner July 14, 2026 20:16
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.

3 participants