diff --git a/DECODE_OPTIMIZATION_RESEARCH.md b/DECODE_OPTIMIZATION_RESEARCH.md new file mode 100644 index 00000000..dc33686b --- /dev/null +++ b/DECODE_OPTIMIZATION_RESEARCH.md @@ -0,0 +1,140 @@ +# Nemotron-Nano-30B Decode Optimization — Research & Synthesis (GB10 / sm_121) + +**Date:** 2026-06-05 +**Author:** research synthesis (web + reasoning), no code touched, spark untouched. +**Question:** vLLM gets **55.8 tok/s @ 20.9 GB**; we get **54.2 tok/s @ 18.4 GB** at byte-matched NVFP4 recipe. vLLM reads MORE bytes yet runs faster → its kernels are more byte-efficient. What is it doing that we aren't, and what else can push decode higher? + +--- + +## TL;DR — the one finding that matters + +The gap is **not** in the dense GEMV path and **not** in precision. At batch=1 the dense layers (lm_head, attn projections, Mamba in/out proj, shared expert) are **memory-bound and tensor cores do NOT help** — this is now firmly established (see §A). Our scalar-dequant GEMV is the structurally correct choice there, and our `lm_head` already runs at 99% of roofline. + +The gap is concentrated in **two places vLLM does structurally differently**: + +1. **The MoE expert GEMM.** With top-6 experts × intermediate rows, the MoE GEMM has *enough M* to use Blackwell **tensor cores via TMA-warp-specialized grouped GEMM** (FlashInfer/CUTLASS `b12x`). This is the ONE matmul in the decode where M>1, so it's the one place tensor cores legitimately win — and it's also our single biggest token share (45.6%) and our worst-efficiency kernels (shared-expert 48–53%, gather 71–74%). The public CUTLASS data shows the *exact same FP4 MoE grouped-GEMM* going **14.6 → 39 tok/s purely by switching `compute_120a → compute_120f` to enable TMA warp-specialized tactics** — a 2.7× kernel-efficiency swing with zero precision change [cutlass#3096]. +2. **Kernel fusion that collapses dispatch count.** vLLM fuses (a) the whole MoE — *token dispatch + W1 GEMM + SwiGLU + W2 GEMM into a single kernel* (FlashInfer `b12x_fused_moe`), and (b) RoPE+Quant+Q-write on the attention side, "eliminating two intermediate memory round-trips" [vllm-blackwell-blog, flashinfer-docs]. We run ~390 kernels/token with our gather-up / silu / gather-down as *separate* dispatches, each round-tripping the intermediate activation through DRAM. + +Everything else (FP8-vs-INT8, vectorized loads, cp.async, persistent kernels, megakernel) is either already-proven-flat for us or structurally a non-win at batch=1. Speculative decode (EAGLE3/MTP) is the one *additional* lever that is **net-positive at batch=1** and that we appear to have prematurely dismissed (see §3) — but it's a quality/infra cost, not a pure-speed freebie. + +--- + +## §A — The settled fact: tensor cores do NOT help batch-1 dense GEMV + +This kills a whole class of "are we leaving the FP8/FP4 MMA path on the table?" ideas for the *dense* layers, so it's worth nailing down before the ranked list. + +- A dedicated NVFP4 batch-1 GEMV study (M=7168, K=16384, L=1) found the best hand-written CUDA-core kernel hit **26.7 µs vs 8.6 µs speed-of-light (~32% of SoL)**, and the author's explicit conclusion: *"The kernel is fundamentally memory-bound. FP4 data is tiny (4 bits per element)… FMA latency hiding is irrelevant when every cycle is waiting on memory."* Even the top competitors only reached ~2× SoL, and a plain PyTorch call scored 22.4 µs — i.e. custom tensor-core tricks bought almost nothing [amandeepsp-nvfp4-gemv]. +- Blackwell's `tcgen05.mma` accumulates in **TMEM** and *always needs ≥4 epilogue warps to drain a 128-wide tile*; the practical minimum tile is **BLOCK_M=128**. At M=1 you pad 1→128 and waste ~99% of the tensor-core ALU [gau-nernst-tcgen05, vllm batch-1 GEMV note]. vLLM itself ships a runtime-dispatched custom op specifically because *"Triton's GEMV pads M=1 up to the tensor-core tile and wastes most of the ALU at batch size 1."* + +**Implication for us:** our `lm_head` at 99% and the dense projections near roofline are *already at the ceiling*. Do not chase an FP8/FP4 tensor-core GEMV for lm_head / attn-proj / shared-up/down / Mamba-proj. That door is closed by physics, and we'd be re-proving what vLLM and the GEMV-hackathon already proved. + +The MoE expert GEMM is the **exception**: top-6 routing gives it real M, so tensor cores are legitimately on the table there (§1). + +--- + +## LIKELY-REAL WINS (ranked) + +### 1. Fuse the MoE into a single grouped-GEMM kernel (dispatch + W1 + SwiGLU + W2) — **HIGHEST PAYOFF** +**What:** Replace our separate `moe_gather_up` → `silu` → `moe_gather_down` dispatches with one fused kernel that does token-dispatch, the up-GEMM, SwiGLU, and the down-GEMM in a single launch, keeping the intermediate activation in shared memory / registers instead of round-tripping it to DRAM. This is exactly FlashInfer `b12x_fused_moe` / `cutlass_fused_moe` [flashinfer-docs, vllm-pr#40082]. + +**Why it helps given our profiling:** MoE is **45.6% of our token** and contains our two worst-efficiency kernels (shared 48–53%, gather 71–74%). The 71–74% gather efficiency is precisely the "scatter penalty + intermediate round-trip" that fusion removes — the up-output never hits DRAM, so the down-GEMM doesn't re-read it, and the dispatch/scatter is amortized inside one kernel. The vLLM fused path also uses a `block_id → (expert_id, offset)` indirection table so variable-sized expert batches run *in one launch without padding waste* — directly attacking our dynamic-scatter cost. + +**Expected impact:** This is where vLLM's "reads more bytes but goes faster" advantage lives. Removing the intermediate activation round-trip on up→down (0.69 GB of gather traffic + the silu pass) plus dispatch fusion is plausibly **5–10%** end-to-end. The profiling map's own "path-to-75" math already credits ~1.0 ms (≈7%) to fixing gather efficiency alone; fusion is the mechanism that gets there. + +**Effort:** HIGH. This is the big structural rewrite — a grouped-GEMM with in-kernel SwiGLU in the `#[kernel]` DSL. Note the playbook already shows `moe_down_swiglu_accum_int4_chain8` exists (PR #201), so a fused down+swiglu is partially precedented; the harder part is the up+swiglu+down single-launch grouped form. +**Risk:** MEDIUM. Correctness of grouped-GEMM offset math + SwiGLU numerics (playbook: silu wants 2e-4 / f16 5e-2 tols). CUDA-graph-safety must be preserved. +**Sources:** [vllm-pr#40082], [flashinfer-docs cutlass_fused_moe], [vllm-fused-moe-design]. + +### 2. Multi-warp the shared-expert (already #1 on the in-tree list) + apply the *same* coalescing to gather +**What:** The profiling map's #1 ranked item — `shared_up_q4` / `shared_down_acc` are single-warp-per-row at 48–53%; mirror the multi-warp `rows_per_tg` coalesced kernel to target 80%+. Blocked earlier by an f32-vs-f16 scale-type mismatch in the accum fusion — fix the dtype (playbook §"f32-vs-f16 scale type" is a recurring trap). + +**Why it helps:** Pure kernel-efficiency, no precision change, clearest measured headroom (the *weakest* kernels in the whole token). This is the lowest-risk real win and is *independent* of the big MoE fusion in §1 — do it first as a down-payment. +**Expected impact:** −0.4–0.5 ms/tok (≈3%) per the in-tree estimate. +**Effort:** LOW–MEDIUM. **Risk:** LOW (the dtype fix is understood). +**Source:** in-tree `PROFILING_32K_DECODE.md` ranked item #1. + +### 3. Use TMA-warp-specialized tactics for the MoE grouped-GEMM (the `compute_120f` lesson) +**What:** This is the *mechanism* behind why vLLM's MoE is byte-efficient, and it's CUDA-toolchain-level, not algorithm-level. The public CUTLASS thread shows the identical FP4 MoE grouped GEMM on desktop Blackwell going **14.6 tok/s (`compute_120a`, CUDA 12.8) → 39 tok/s (`compute_120f`, CUDA 13.0)** — a **2.7×** swing — purely because `compute_120f` lets the autotuner select **TMA warp-specialized grouped-GEMM kernels** instead of falling back to non-TMA variants [cutlass#3096]. For us this means: if/when our MoE GEMM uses tensor cores at all (per §1, M is large enough there), the build must target the `f`-suffix arch and use TMA (async bulk copy) to feed the MMA. Note: **TMA/cp.async is only worth it for the tensor-core MoE tile** — the playbook already proved `simdgroup_async_copy` / cp.async-style prefetch is *dead* for batch-1 GEMV (amortizes only over kilo-FLOP tiles), so do NOT apply it to the dense GEMVs. + +**Why it helps:** This is the single largest *documented* kernel-efficiency multiplier on this exact hardware+model class, and it's specific to the one matmul where tensor cores apply. +**Expected impact:** Large *if* we adopt a tensor-core MoE GEMM (it's the enabling toolchain piece for §1's grouped GEMM, not a standalone lever). On its own, on our current scalar-MoE, it does nothing. +**Effort:** Coupled to §1 (need a tensor-core grouped GEMM first). **Risk:** sm_121 toolchain fragility — note vLLM hit `nvidia-cutlass-dsl==4.5.0 generates faulty PTX for SM121`, must pin 4.4.2; and `compute_120f` needs CUDA 13.0 [vllm-pr#40082, cutlass#3096]. +**Sources:** [cutlass#3096], [vllm-pr#40082]. + +### 4. Fuse the attention pre-amble: RoPE + Quant + (K/Q write) into one kernel +**What:** vLLM fuses "RoPE + Quant + Q Write (Decode)" into a single kernel, "eliminating two intermediate memory round-trips" [vllm-blackwell-blog]. Our equivalent rope/quant/kv-write are separate tiny dispatches. + +**Why it helps:** Each is "≈0 ms real work" per our profiler, BUT we run ~390 kernels/token and the *dispatch + DRAM round-trip* of the intermediate is the real cost, not the FLOPs. Collapsing 3 dispatches→1 removes two activation round-trips and two launch latencies per layer. With CUDA graphs the launch cost is already low, so the win here is the round-trip elimination, modest but cheap. +**Expected impact:** 1–3% (small per-op, but it's per-layer × 23+ layers). +**Effort:** LOW–MEDIUM. **Risk:** LOW. **Source:** [vllm-blackwell-blog]. + +--- + +## NET-POSITIVE BUT QUALITY/INFRA-COSTED (re-examine — we may have been wrong) + +### 5. Speculative decode (EAGLE3 / MTP) — **contradicts our "net-loss at batch=1" prior** +**What:** A small draft proposes k tokens; the target verifies all k in one forward pass. At B=1 the GPU is bandwidth-bound with **idle compute**, so the verification pass costs ~the same as one normal token while yielding >1 accepted token. + +**Why our prior may be wrong:** The evidence says spec-decode is **net-positive precisely at B=1** and *loses* benefit as batch grows (opposite of the usual intuition): *"At B=1, even a modest acceptance rate yields meaningful latency gains because the GPU has substantial idle capacity"* [hf-eagle3]. Concrete measured single-stream numbers on a comparable hybrid/MoE-scale model: **120 → 168 tok/s (1.39×) at 40% acceptance, 2.4 accepted/verify, k=6**, with a 277 MB draft against a 30B-class target [hf-eagle3]. The "7× weight reads" overhead (6 draft + 1 target) is dwarfed by the tiny draft size and absorbed by spare bandwidth. + +**The catch (why it's costed, not free):** +- Needs a **trained draft head / MTP module** for *this* model (Nemotron-3-Nano ships MTP-style heads in some configs — worth checking the checkpoint). Without one, you train/distill it = real infra cost. +- **Acceptance rate is the whole ballgame.** At 40% accept you get 1.39×; below ~25% it can go net-negative. Reasoning models with long deterministic spans tend to accept *well*, which is favorable here. +- Verification pass on a **hybrid Mamba+MoE** model is more complex than on a dense transformer: the **Mamba2 state must be advanced/rolled-back per draft token** (the SSM is sequential), which is the non-trivial engineering piece and a place our hybrid architecture is harder than vLLM's dense-transformer spec path. +- At our 273 GB/s (vs the 3.35 TB/s box in the cited result) the *bandwidth slack* is tighter, so the realized speedup will be lower than 1.39× — but still plausibly net-positive at decent acceptance. + +**Expected impact:** **+15–35%** single-stream tok/s *if* acceptance ≥40% and the Mamba-state rollback is solved — by far the largest single lever, but the only one that changes the token-count math instead of the per-token cost. +**Effort:** HIGH (draft head + Mamba2 state checkpoint/rollback in the decode loop). **Risk:** MEDIUM–HIGH (acceptance-rate dependent; Mamba rollback correctness). +**Recommendation:** **Re-open this.** Verify whether the NVFP4 checkpoint ships an MTP head, and prototype acceptance-rate measurement before committing — that single number decides go/no-go. +**Sources:** [hf-eagle3], [modular-specdec], [sglang-specdec]. + +### 6. Sub-4-bit experts (Q3 / NVFP4-with-tighter-grouping) — the path past 75, quality-costed +**What:** Drop experts to ~3-bit. The experts dominate MoE bytes; trimming 4→3 bit cuts the largest byte share. +**Why/impact:** Mechanically ~+15–20% (matches the in-tree note that 75 needs Q3). **Quality cost is real** and no engine ships it at 32K for this model. Mark as a quality-traded option, not a clean win. +**Effort:** MEDIUM (quant recipe + QAD-style recovery). **Risk:** HIGH on quality. **Source:** in-tree map; [nvidia-nemotron-nvfp4] (NVIDIA themselves stop at 4-bit experts + 8-bit elsewhere via QAD, i.e. they judged sub-4-bit not worth the quality hit). + +--- + +## PROBABLY FLAT / DEAD-ENDS (do NOT re-litigate — with the *why*) + +| Idea | Why it's flat for us | +|---|---| +| **FP8/FP4 tensor-core GEMV for dense layers** (lm_head, attn-proj, shared, Mamba-proj) | Batch-1 dense GEMV is memory-bound; tensor cores need M≥128, you pad 1→128 and waste the ALU. Proven by the NVFP4-GEMV study (~32% SoL even hand-tuned) and tcgen05's 4-epilogue-warp/BLOCK_M=128 floor. Our lm_head is already 99%. §A. | +| **cp.async / TMA prefetch on dense GEMVs** | Amortizes only over kilo-FLOP tensor-core tiles. Playbook already measured `simdgroup_async_copy` dead at 2–4 KB matvec X-loads. Only valid inside the §1/§3 tensor-core MoE tile. | +| **Persistent / megakernel for the whole decode** | Already tried: cooperative-launch megakernel can't fit GB10's 48 SMs. Persistent-kernel for *all* of decode is the same class. (Fusing *within* MoE per §1 is different and fine.) | +| **Vectorized uint4 loads, MT_GEMV_2ROW/VEC, rpt sweeps, multiwarp-shared(prev attempt), gather cache-streaming hints** | All measured flat or worse in-tree (uint4 −34%, gather rpt flat because bottleneck is scatter not warp count). | +| **TG-memory cache for X-broadcast in matvec** | Playbook: −33pp on M-series; L1 already absorbs the redundancy. Same logic on GB10's L1/L2. | +| **INT8 → FP8(E4M3) for the Q8 layers** | Both are 8-bit = same byte budget = same bandwidth at batch-1. NVIDIA's E4M3 vs our INT8/f32-scale is a numerical/quality nuance, **not** a speed lever (the baseline doc already says "minor diff"). Don't expect tok/s from it. | +| **`concat_mla_k` / MLA KV tricks** | vLLM's MLA-specific KV kernel — Nemotron-Nano is **not** MLA (it's hybrid Mamba2 + standard GQA attention on a minority of layers). Not applicable. | + +--- + +## On the Mamba2 decode step specifically (§4 of the brief) +Mamba is only **15% of our token** and our in/out-proj already run 64–76%. The public Mamba2 fusion wins (PyTorch blog, vLLM SSD) are **all prefill chunked-scan** — there is **no published faster single-step selective-scan kernel** for decode; the decode path is already a cheap conv1d + per-channel state update [pytorch-mamba2, vllm-mamba2]. The only decode-relevant note: vLLM split conv1d into separate prefill/decode kernels to avoid mixed-batch slowdown — not relevant to our pure-decode bench. **Verdict: low priority.** The Mamba in/out-proj headroom (64–76%) is ordinary GEMV multi-warp tuning, same bucket as §2, not a Mamba-algorithm change. (Caveat in §5: if we do spec-decode, the Mamba *state rollback* becomes the hard part — that's the one place Mamba decode work pays off.) + +--- + +## Recommended sequence (most-confident first) +1. **§2 multi-warp shared-expert** (fix the f16 scale dtype) — low risk, clear headroom, ~3%, down-payment. +2. **§4 fuse rope+quant+kv-write** — low risk, ~1–3%, cheap. +3. **§1 fused MoE grouped-GEMM (dispatch+up+swiglu+down)** + **§3 TMA/`compute_120f` toolchain** — the big structural win that mirrors vLLM; ~5–10%. This is the one that closes the "vLLM reads more, goes faster" gap. +4. **§5 spec-decode acceptance-rate spike** — measure acceptance on the real checkpoint *before* building; if ≥40% and Mamba-rollback tractable, it's the largest lever (+15–35%) and the realistic path *past* parity to a genuine win. +5. **§6 Q3 experts** — only if a quality budget exists; the documented route to ~75 but quality-costed. + +§1+§3 together should plausibly take us from 54.2 to **parity-plus** at matched precision (closing the 3% and adding a few). §5 is what turns "at parity" into "beats vLLM" on single-stream. The dense-GEMV tensor-core fantasy (§A) is dead — stop looking there. + +--- + +## Sources +- vLLM Blackwell WideEP blog (RoPE+Quant fusion, NVFP4 MoE dispatch, FlashInfer TRTLLM-Gen GEMM): https://vllm.ai/blog/2026-02-03-dsr1-gb200-part1 +- vLLM PR #40082 — FlashInfer b12x fused MoE + FP4 dense GEMM for SM120/121 (fuses dispatch+W1+SwiGLU+W2; CuTe-DSL warp-MMA adaptive tiling for small-M decode; +1.8% 1P / +6.0% 8P on DGX Spark Qwen3-30B-A3B-NVFP4; pin cutlass-dsl 4.4.2): https://github.com/vllm-project/vllm/pull/40082 +- CUTLASS issue #3096 — FP4 MoE grouped GEMM on desktop Blackwell, 14.6→39 tok/s via compute_120f TMA warp-specialized tactics; Marlin W4A16 baseline 46–49 tok/s: https://github.com/NVIDIA/cutlass/issues/3096 +- "Twelve Attempts at an FP4 Kernel" (batch-1 NVFP4 GEMV is memory-bound, ~32% SoL, tensor cores don't help): https://amandeepsp.github.io/blog/nvfp4-blackwell-gemv/ +- tcgen05 for dummies (TMEM, ≥4 epilogue warps / BLOCK_M=128 floor → unsuitable for M=1): https://gau-nernst.github.io/tcgen05/ +- FlashInfer cutlass_fused_moe docs (single-call fused MoE, block_id→expert mapping, variable expert batches no padding): https://docs.flashinfer.ai/generated/flashinfer.fused_moe.cutlass_fused_moe.html +- vLLM fused-MoE design / moe_align_block_size (persistent grouped GEMM, indirect token addressing, padding-to-block): https://docs.vllm.ai/en/latest/design/moe_kernel_features/ +- EAGLE3 single-stream B=1 (1.39× @ 40% accept, 120→168 tok/s; net-positive at B=1, shrinks with batch): https://huggingface.co/blog/lujangusface/tw-eagle3-gpu +- Modular spec-decode / SGLang spec-decode (B=1 bandwidth-bound idle-compute rationale): https://docs.modular.com/max/serve/speculative-decoding/ , https://sgl-project.github.io/advanced_features/speculative_decoding.html +- PyTorch "Accelerating Mamba2 with Kernel Fusion" (prefill SSD only, 1.5–2.5×; no decode single-step win): https://pytorch.org/blog/accelerating-mamba2-with-kernel-fusion/ +- vLLM Mamba2 conv1d prefill/decode split (PR #17146), SM121 Triton crash note (#37431): https://github.com/vllm-project/vllm/pull/17146 +- Nemotron-3-Nano-30B-A3B-NVFP4 model card / paper (128 experts, top-6 + 1 shared, 3.5B active, QAD 4-bit experts): https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-NVFP4 , https://arxiv.org/pdf/2512.20848 diff --git a/PROFILING_32K_DECODE.md b/PROFILING_32K_DECODE.md new file mode 100644 index 00000000..b5817023 --- /dev/null +++ b/PROFILING_32K_DECODE.md @@ -0,0 +1,66 @@ +# Nemotron-Nano-30B-A3B — 32K Decode Profiling Map (GB10 / ASUS GX10) + +**Goal:** 75+ tok/s decode @ 32,768 ctx. **Current:** 68.2 tok/s graph-batched (14.63 ms/token), full-quality Q4, argmax 1234. + +**Config:** `NEMOTRON_FAKECTX=32768 NEMOTRON_GRAPH=1 NEMOTRON_DEVROUTER=1 NEMOTRON_Q4CACHE=1 NEMOTRON_F16KV=1`, GB10 sm_121, LPDDR5X 273 GB/s peak. + +**Method:** per-op CUDA-event profiler (`NEMOTRON_PROFILE=1`, eager, sync-around-each-op). ⚠️ The profiler synchronizes per op, so **ms/tok is sync-inflated for tiny elementwise ops** (silu/rope/rms_norm/conv read ~0 bytes → their "8 ms" is sync overhead, real ≈0). **Trust the `GB/s` column and the ablation, not the per-op ms.** + +## The reference: achievable bandwidth = ~189 GB/s +`lm_head` is a big contiguous Q4 GEMV and runs at **187 GB/s ≈ 99% of the achievable roofline**. So **189 GB/s is reachable on this hardware** — any kernel below it has headroom that is *kernel efficiency*, not a hardware wall. + +## Per-kernel map (real, bandwidth-bound work) + +| kernel | GB read/tok | eff GB/s | % of 189 achievable | calls/tok | verdict | +|---|---|---|---|---|---| +| **lm_head** | 0.176 | **187** | **99%** | 1 | ✅ saturated — no headroom | +| m_in_proj (Mamba) | 0.319 | 144 | 76% | 23 | 🟡 mild headroom | +| moe_gather_up | 0.345 | 140 | 74% | 23 | 🟡 **scatter penalty** | +| moe_gather_down | 0.346 | 134 | 71% | 23 | 🟡 **scatter penalty** | +| m_out_proj (Mamba) | 0.127 | 120 | 64% | 23 | 🟡 headroom | +| shared_up_q4 | 0.115 | 101 | 53% | 23 | 🔴 **single-warp, big headroom** | +| shared_down_acc | 0.115 | 90 | 48% | 23 | 🔴 **single-warp, big headroom** | +| sdpa_2pass | 0.202 | 78 | 41% | 6 | 🟡 KV-read, latency-bound | +| silu/rope/rms_norm/conv/ssm/router | ~0 | — | — | 12–52 | sync-artifact, real ≈0 ms | + +**host overhead:** 0.75 ms/tok (eager; graphs remove most of it). + +**Confirmation run (warmer box) — ranking reproduced, efficiencies thermal-sensitive:** +`lm_head` **186.8 GB/s = 99%** (stable), moe_gather_up/down **119/115 = 63%/61%**, m_in_proj **121 = 64%**, m_out_proj **118 = 63%**, shared_up_q4 **77 = 41%**, shared_down_acc **68 = 36%**, sdpa_2pass **58 = 31%**. Absolute % drifts with temperature but `lm_head`≈99% and the under-performer ranking are invariant. **Headroom is real and likely *larger* than the first table suggests.** + +## Category ablation (skip-based ground truth) +- **MoE total: 7.3 ms (45.6%)** — gather up/down + gate + shared experts +- **Attn + lm_head + norms: 6.3 ms (39.4%)** — q/k/v/o proj, sdpa, lm_head +- **Mamba: 2.4 ms (15.0%)** — in/out proj, conv1d, ssm_step + +## KEY INSIGHT — this is NOT a hardware wall +`lm_head` proves 189 GB/s is achievable. The dominant kernels run well under it: +- **shared-expert (up 53% / down 48%)**: weakest. Cause = single-warp-per-row (no multi-warp `rows_per_tg`). Fix = mirror the multi-warp coalesced kernel → target 80%+. +- **moe_gather up/down (74%/71%)**: the top-6-of-128 **dynamic scatter** (6 experts at different offsets) costs ~25% vs lm_head's contiguous read. Levers: cache-streaming hints (`ld.global.cs`), better expert-block locality, or compaction. +- **m_out_proj (64%) / m_in_proj (76%)**: mild headroom. + +## Path-to-75 math (clean, no precision loss) +Token = 14.63 ms. Pulling the underperformers toward the proven 189 GB/s: +- shared-expert 0.23 GB @ ~95 → @ 170 GB/s: saves ~1.0 ms +- moe_gather 0.69 GB @ ~137 → @ 175 GB/s: saves ~1.0 ms +- → ~12.6 ms = **~79 tok/s**. **75 is reachable on efficiency alone.** + +## Ruled out (measured, no gain) +- SDPA 2-pass BC4 / TILED variants: **much worse** (56 ms vs 14 ms) +- SDPA split-K block sweep (64–512): flat +- `MT_MOE_RPT` 1–4 on gather: flat (gather bottleneck is scatter, not warp count) +- `--use_fast_math`: no change +- `MT_GEMV_2ROW`, `MT_GEMV_VEC`: crash +- uint4 vectorized loads: −34% (starves the pipe) +- f16-KV: +2 tok/s (banked) + +## Ranked opportunities (next work) +1. **shared_up_q4 + shared_down_acc → multi-warp (`rows_per_tg`)** — 48–53% → 80%+, est. **−0.4–0.5 ms/tok**. Lowest risk, clearest headroom. *(blocked earlier by f32-vs-f16 scale type mismatch in the accum fusion — fix the dtype.)* +2. **moe_gather scatter efficiency** — 71–74% → 85%+. Try `ld.global.cs` cache-streaming hints (inline PTX) + expert-block locality. Biggest share of the token (45%), so highest absolute payoff if the scatter penalty is partly cache-pollution. +3. **m_out_proj** (64%) — multi-warp / config tune. + +## Banked optimizations (in the 68.2) +CUDA graphs (+6.5%), Q4 disk cache (setup 120 s→20 s), MoE rpt2 default, parallel dequant, f16-KV, FMAD-on, `__ldg`/`__restrict__`/`__expf` codegen. + +--- +*Generated from the in-tree `NEMOTRON_PROFILE=1` per-op profiler (ffai-modeltests/src/lib.rs). Re-run: `NEMOTRON_PROFILE=1 NEMOTRON_FAKECTX=32768 NEMOTRON_DECODE=24 ... nemotron_decode_bench`.* diff --git a/PROFILING_PREFILL.md b/PROFILING_PREFILL.md new file mode 100644 index 00000000..c2a52000 --- /dev/null +++ b/PROFILING_PREFILL.md @@ -0,0 +1,26 @@ +# Nemotron-Nano-30B BATCHED PREFILL — per-op profiling map + +- Device: GB10 sm_121 (GB10 Blackwell) +- S (prompt tokens): 2048 +- Clean batched throughput: **74.3 tok/s** (13.46 ms/tok) +- Profiled pass wall (sync-bracketed, inflated): 28.465s; summed op time: 13.536s +- vLLM-on-GB10 reference: pp2048@d0=6395, @d8192=4993, @d32768=2734 tok/s +- Tensor-core peak assumed: 1000 TFLOP/s (bf16 dense) + +| op | ms | % | calls | TFLOP/s | %peak | +|---|---:|---:|---:|---:|---:| +| moe_experts | 7137.65 | 52.7% | 69 | 0.790 | 0.08% | +| proj_gemm | 3764.71 | 27.8% | 70 | 1.121 | 0.11% | +| moe_shared | 1680.60 | 12.4% | 46 | 1.119 | 0.11% | +| ssm_scan | 673.69 | 5.0% | 23 | 0.147 | 0.01% | +| sdpa_prefill | 202.75 | 1.5% | 6 | 1.017 | 0.10% | +| moe_router | 32.37 | 0.2% | 23 | 1.001 | 0.10% | +| slice/cast | 31.36 | 0.2% | 140 | — | — | +| rms_norm | 12.01 | 0.1% | 52 | — | — | +| lm_head | 1.01 | 0.0% | 1 | 0.699 | 0.07% | + +## Gap analysis +- `proj_gemm`/`lm_head` running far below %peak → projection GEMMs not at tensor-core roofline (f32 matmul, not bf16-MMA; dequant overhead separate). +- `ssm_scan` high % → the sequential-in-T `ssm_step_record` is the Mamba bottleneck → Milestone B: chunked/parallel SSD scan. +- `moe_experts`/`moe_shared` high % with many calls → per-token MoE gather loop → Milestone B: Q4 batched-expert GEMM over S. +- `host_conv` time is CPU (host-bridged) → move causal conv on-device for S-batched. diff --git a/README.md b/README.md index 840b5255..82b642cc 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ A minimal, dependency-light LLM inference library for Apple Silicon, built on pr **Just really f*cking fast AI on your Mac!** 🚀 +## Architecture + +FFAI is a Rust + Swift inference engine spanning 35 model families, with resident decode across four GPU backends (Apple Metal, NVIDIA CUDA, AMD HIP, and Vulkan) via [metaltile](https://github.com/thewafflehaus/metaltile)'s `#[kernel]` DSL. The diagram below traces the engine stack from model loading through the per-token dispatch loop down to the shared kernel layer. + +![FFAI architecture](docs/architecture.png) + +## Scope & naming + +FFAI began as an Apple/Metal-focused inference engine. It now runs across NVIDIA (GB10), AMD, and Vulkan-class GPUs via metaltile, so the Apple-specific name no longer reflects the multi-backend scope. A rename is under discussion to match the broadened reach — the candidate name is still TBD and nothing is decided. The current name (FFAI) continues to apply until any rename is settled. + ## Status Early bootstrap — the dense-text, hybrid, vision-language, and audio model waves have all landed; end-to-end inference runs real HuggingFace checkpoints across every shipped family. diff --git a/Sources/FFAI/Device.swift b/Sources/FFAI/Device.swift index 44b2ac59..8f5eb6b8 100644 --- a/Sources/FFAI/Device.swift +++ b/Sources/FFAI/Device.swift @@ -46,14 +46,188 @@ public final class Device: @unchecked Sendable { self.commandQueue = commandQueue } + // ─── Scratch slab — generic transient-buffer allocator ──────────── + // + // `Device.makeBuffer` is the default path for persistent buffers. + // For transients that live for the duration of a forward sub-block + // — and would otherwise hammer Metal's internal driver pool with + // hundreds of `makeBuffer(length:)` calls per token — there's a + // **scratch slab**: a single pre-allocated `MTLBuffer` that callers + // slice into via offset bumps. `device.allocScratch(bytes:)` returns + // `(buffer, offset)`; `Tensor.scratch(shape:dtype:)` wraps the slice + // as a Tensor; `device.resetScratch()` rewinds the offset to 0. + // + // Wrap a sub-block in `device.withScratch { ... }`: it flips + // `scratchModeActive` on (so plain `Tensor.empty` routes through the + // slab) and rewinds the offset at scope exit. State that CARRIES + // OVER between scratch scopes (e.g., the mHC 4-channel residual) + // must NOT live in scratch — allocate it with the default + // `Device.makeBuffer` instead. + public var scratchSlabBytes: Int = 256 * 1024 * 1024 // 256 MB cap + private var scratchBuffer: MTLBuffer? + private var scratchOffset: Int = 0 + + /// When `true`, `Tensor.empty(...)` routes through the scratch slab + /// instead of allocating a fresh MTLBuffer. Set by + /// `withScratch { ... }` so callers don't need to switch every + /// allocation site over to `Tensor.scratch` explicitly. + public var scratchModeActive: Bool = false + + // ─── Allocation counters (diagnostic) ──────────────────────────── + public var bufferAllocCount: Int = 0 + public var bufferAllocBytes: Int = 0 + public var scratchAllocCount: Int = 0 + public var scratchAllocBytes: Int = 0 + + // ─── Dequant-intermediate scratch (persistent reusable buffer) ──── + // + // GGUF dequant kernels need 1-2 large transient buffers per call + // (e.g., IQ2_XXS expert tensor: ~524 MB qs intermediate + ~32 MB + // d_f32 scales). Caller commits + waits the dequant cmd buffer + // BEFORE returning, so the intermediate is safely reusable + // across calls. These slabs grow lazily to the largest size + // requested. + private var dequantIntermediateBuffers: [String: MTLBuffer] = [:] + private let scratchLock = NSLock() + + /// Returns a pre-allocated MTLBuffer ≥ `minBytes` keyed by `tag`. + /// Thread-safe: multiple parallel staging tasks may call with + /// distinct slot-keyed tags concurrently. + public func intermediateScratch(tag: String, minBytes: Int) -> MTLBuffer { + scratchLock.lock() + defer { scratchLock.unlock() } + let need = max(minBytes, 64) + if let buf = dequantIntermediateBuffers[tag], buf.length >= need { + return buf + } + let alloc = max(need, (dequantIntermediateBuffers[tag]?.length ?? 0) * 2) + guard let buf = mtlDevice.makeBuffer(length: alloc, options: .storageModeShared) else { + fatalError("Device.intermediateScratch: failed to allocate \(alloc)-byte slab") + } + dequantIntermediateBuffers[tag] = buf + return buf + } + + /// Process RSS in KB via a `ps` shell-out. Slow (~10 ms per call) + /// but works without entitlements. Use sparingly — only at + /// per-sub-block instrumentation points. + public static func currentRssKB() -> Int { + let pid = ProcessInfo.processInfo.processIdentifier + let task = Process() + task.launchPath = "/bin/ps" + task.arguments = ["-o", "rss=", "-p", "\(pid)"] + let pipe = Pipe() + task.standardOutput = pipe + do { try task.run() } catch { return -1 } + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let s = + String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "0" + return Int(s) ?? 0 + } + + /// Allocate `bytes` from the scratch slab (lazily creating the slab + /// on first use). 16-byte aligned. Fatal if the slab overflows — + /// caller should size `scratchSlabBytes` to fit one sub-block of + /// transients. + public func allocScratch(bytes: Int) -> (buffer: MTLBuffer, offset: Int) { + if scratchBuffer == nil { + scratchBuffer = mtlDevice.makeBuffer( + length: scratchSlabBytes, options: .storageModeShared) + guard scratchBuffer != nil else { + fatalError("Device.allocScratch: failed to allocate \(scratchSlabBytes)-byte slab") + } + } + let aligned = (scratchOffset + 15) & ~15 + if aligned + bytes > scratchSlabBytes { + fatalError( + "Device.allocScratch: slab overflow — needed \(aligned + bytes), have \(scratchSlabBytes). Caller should resetScratch() between sub-blocks or grow scratchSlabBytes." + ) + } + scratchOffset = aligned + bytes + scratchAllocCount += 1 + scratchAllocBytes += bytes + return (scratchBuffer!, aligned) + } + + /// Reset the scratch slab offset to 0. **Every Tensor sliced into + /// the slab via `Tensor.scratch(...)` becomes invalid after this + /// call** — all sub-block-local transients must be done with. + public func resetScratch() { + scratchOffset = 0 + } + + /// Convenience scope wrapper — runs the body with + /// `scratchModeActive = true` (so `Tensor.empty` transparently + /// uses the scratch slab), then resets the slab at scope exit. + /// Any Tensor sliced into the slab inside the body is INVALID + /// once `body` returns — carry-over state must be copied to a + /// persistent buffer (or allocated via `Tensor.empty` while + /// `scratchModeActive == false`) before the scope exits. + public func withScratch(_ body: () throws -> T) rethrows -> T { + let wasActive = scratchModeActive + scratchModeActive = true + defer { + if !wasActive { + scratchModeActive = false + resetScratch() + } + } + return try body() + } + /// Allocate a fresh shared-storage MTLBuffer of the given byte length. public func makeBuffer(length: Int) -> MTLBuffer { guard let buf = mtlDevice.makeBuffer(length: length, options: .storageModeShared) else { fatalError("Device.makeBuffer(length: \(length)) returned nil") } + bufferAllocCount += 1 + bufferAllocBytes += length return buf } + /// Ensure the scratch slab is at least `bytes`, reallocating if needed. + /// SAFE ONLY when no scratch slices are live (`scratchOffset == 0`) — + /// call at the top of a forward pass before any `allocScratch`. The slab + /// is a single reused buffer (not a per-call allocation), so growing it + /// for a large prefill chunk is bounded, not a leak. Decode keeps 256 MB. + public func ensureScratchSlab(_ bytes: Int) { + if let buf = scratchBuffer, buf.length >= bytes { return } + precondition( + scratchOffset == 0, + "ensureScratchSlab: cannot resize with \(scratchOffset) bytes of live slices") + scratchSlabBytes = bytes + scratchBuffer = mtlDevice.makeBuffer(length: bytes, options: .storageModeShared) + guard scratchBuffer != nil else { + fatalError("ensureScratchSlab: failed to allocate \(bytes)-byte slab") + } + } + + // Cache of 4-byte scalar-argument buffers, keyed by value. Kernel + // scalar args (rmsNorm eps, RoPE start/step, …) were allocating a + // fresh 4-byte MTLBuffer on EVERY op call — ~5 rmsNorms/layer × + // 43 layers = ~220 tiny allocations per token. Over a long + // (e.g. 32k) decode that churned millions of buffers and eventually + // tripped `makeBuffer returned nil`. Scalars are ~constant, so cache + // one reusable buffer per value. + nonisolated(unsafe) private var scalarBufCache: [Float: MTLBuffer] = [:] + private let scalarBufLock = NSLock() + public func scalarBuffer(_ value: Float) -> MTLBuffer { + scalarBufLock.lock() + defer { scalarBufLock.unlock() } + if let b = scalarBufCache[value] { return b } + guard let b = mtlDevice.makeBuffer(length: 4, options: .storageModeShared) else { + fatalError("Device.scalarBuffer: makeBuffer(4) returned nil") + } + var v = value + memcpy(b.contents(), &v, 4) + scalarBufCache[value] = b + bufferAllocCount += 1 + bufferAllocBytes += 4 + return b + } + /// Make a new MTLCommandBuffer. public func makeCommandBuffer() -> MTLCommandBuffer { guard let cb = commandQueue.makeCommandBuffer() else { diff --git a/Sources/FFAI/KVCache/AURACodebook.swift b/Sources/FFAI/KVCache/AURACodebook.swift index 0d2227b6..3e373f84 100644 --- a/Sources/FFAI/KVCache/AURACodebook.swift +++ b/Sources/FFAI/KVCache/AURACodebook.swift @@ -20,7 +20,7 @@ // the coordinate distribution of unit-sphere vectors converges to a // near-Gaussian, so a fixed Lloyd-Max table is near-optimal. // -// The reference values here are mined from llama.cpp's `k_quants` +// The reference values here are mined from the reference C++ `k_quants` // tables (empirically optimal for unit-norm Gaussian data at d=128) // and scaled to other head dims by √(128 / dim) — a heuristic that // approximates the analytic 1/√d Beta-variance scaling from the @@ -246,6 +246,56 @@ public enum AURACodebook { return base.map { $0 * scale } } + /// Allocate a codebook tensor in the requested activation dtype. + /// AURA cache stores codebook in the same dtype as the model + /// activations so both encode + decode kernels (which take + /// `Tensor` for the codebook) read directly with no per-call + /// cast. The Lloyd-Max values themselves are computed in Float; + /// narrow dtypes (`bf16`/`f16`) round at the CPU-side host conversion. + public static func centroidsTensor( + dim: Int, bits: Int, dtype: DType, device: Device = .shared + ) -> Tensor { + let values = centroids(dim: dim, bits: bits) + return writeFloatsToTensor(values, shape: [values.count], dtype: dtype, device: device) + } + + /// Allocate a boundaries tensor in the requested activation dtype. + /// Post-metaltile #226, `aura_encode` takes `boundaries: Tensor` + /// — kernel-side bandwidth win (Π + boundaries dominate the encode + /// kernel's memory traffic). Lloyd-Max boundary values are computed + /// in Float; narrow dtypes (bf16/f16) round at the host-side + /// conversion. The bf16/f16 rounding (~1e-3) sits well below the + /// 2-4-bit quant bin so the matched-norm correction stays stable. + public static func boundariesTensor( + dim: Int, bits: Int, dtype: DType, device: Device = .shared + ) -> Tensor { + let values = boundaries(dim: dim, bits: bits) + return writeFloatsToTensor(values, shape: [values.count], dtype: dtype, device: device) + } + + /// CPU-side host conversion from `[Float]` into a tensor of the + /// requested float dtype. Used by `centroidsTensor` and any caller + /// that needs Lloyd-Max-precise values landed into narrow storage. + private static func writeFloatsToTensor( + _ values: [Float], shape: [Int], + dtype: DType, device: Device + ) -> Tensor { + let t = Tensor.empty(shape: shape, dtype: dtype, device: device) + switch dtype { + case .f32: + t.copyIn(from: values) + case .f16: + t.copyIn(from: values.map { Float16($0) }) + case .bf16: + t.copyIn(from: values.map { UInt16(truncatingIfNeeded: $0.bitPattern >> 16) }) + default: + fatalError( + "AURACodebook.centroidsTensor: unsupported dtype \(dtype); " + + "AURA cache supports f32 / f16 / bf16") + } + return t + } + /// Bytes-per-token after AURA packing at this bit width and dim. /// `ceil(dim * bits / 32) * 4` for the packed u32 array, plus 4 /// bytes for the f32 per-token norm. Excludes any per-vector DC diff --git a/Sources/FFAI/KVCache/AURAQuantizedKVCache.swift b/Sources/FFAI/KVCache/AURAQuantizedKVCache.swift index a66db3f3..1aa626cb 100644 --- a/Sources/FFAI/KVCache/AURAQuantizedKVCache.swift +++ b/Sources/FFAI/KVCache/AURAQuantizedKVCache.swift @@ -107,16 +107,21 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable { /// Π^T in the activation dtype, used to un-rotate the SDPA output /// before `oProj`. Aliases `rotationT` when `dtype == .f32`. public let rotationDtypeT: Tensor - public let kCodebook: Tensor // [2^keyBits] f32 - public let kBoundaries: Tensor // [2^keyBits-1] f32 - public let vCodebook: Tensor // [2^valueBits] f32 - public let vBoundaries: Tensor // [2^valueBits-1] f32 + /// Codebook in the cache dtype. Encode + decode kernels read + /// directly with no per-call cast — the dtype unification landed + /// when the single-pass `aura_flash_sdpa` kernel was migrated to + /// `Tensor` (matches the production C++ TQ+ fork pattern: fp16- + /// stored norms / codebook, f32-at-use via cast-at-load). + public let kCodebook: Tensor // [2^keyBits] dtype + public let kBoundaries: Tensor // [2^keyBits-1] dtype — Lloyd-Max thresholds (Tensor per metaltile #226) + public let vCodebook: Tensor // [2^valueBits] dtype + public let vBoundaries: Tensor // [2^valueBits-1] dtype // Per-cache compressed storage. public let kPacked: Tensor // [nKVHeads, maxSeq, kPackedWidth] u32 public let vPacked: Tensor // [nKVHeads, maxSeq, vPackedWidth] u32 - public let kNorms: Tensor // [nKVHeads, maxSeq] f32 - public let vNorms: Tensor // [nKVHeads, maxSeq] f32 + public let kNorms: Tensor // [nKVHeads, maxSeq] dtype — encode writes T, decode reads T + public let vNorms: Tensor // [nKVHeads, maxSeq] dtype // Shared working buffers — bulk-dequant target; reused across layers. public let sharedWorkingK: Tensor // [nKVHeads, maxSeq, headDim] dtype @@ -192,11 +197,18 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable { "AURAQuantizedKVCache: rotationDtype/rotationDtypeT dtype must match cache dtype \(dtype)" ) precondition( - kCodebook.dtype == .f32 && kBoundaries.dtype == .f32, - "AURAQuantizedKVCache: K codebook/boundaries must be f32") + kCodebook.dtype == dtype, + "AURAQuantizedKVCache: K codebook dtype must match cache dtype \(dtype)") precondition( - vCodebook.dtype == .f32 && vBoundaries.dtype == .f32, - "AURAQuantizedKVCache: V codebook/boundaries must be f32") + kBoundaries.dtype == dtype, + "AURAQuantizedKVCache: K boundaries dtype must match cache dtype \(dtype) " + + "— metaltile #226 unified rotation/boundaries to Tensor") + precondition( + vCodebook.dtype == dtype, + "AURAQuantizedKVCache: V codebook dtype must match cache dtype \(dtype)") + precondition( + vBoundaries.dtype == dtype, + "AURAQuantizedKVCache: V boundaries dtype must match cache dtype \(dtype)") precondition( sharedWorkingK.shape == [nKVHeads, maxSeq, headDim], "AURAQuantizedKVCache: sharedWorkingK shape mismatch") @@ -232,9 +244,9 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable { self.vPacked = Tensor.empty( shape: [nKVHeads, maxSeq, vPackedWidth], dtype: .u32, device: device) self.kNorms = Tensor.empty( - shape: [nKVHeads, maxSeq], dtype: .f32, device: device) + shape: [nKVHeads, maxSeq], dtype: dtype, device: device) self.vNorms = Tensor.empty( - shape: [nKVHeads, maxSeq], dtype: .f32, device: device) + shape: [nKVHeads, maxSeq], dtype: dtype, device: device) // Codec is purely additive in atomic_or terms, so packed slots // MUST start zeroed. Norms slots get overwritten per encode but @@ -377,7 +389,10 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable { let inputBytesPerHead = headDim * dtype.byteSize let packedBytesPerSlot = packedWidth * 4 // u32 let packedBytesPerHead = maxSeq * packedBytesPerSlot - let normBytesPerHead = maxSeq * 4 // f32 + // Norms are stored in the cache dtype post-unification — stride + // tracks the activation dtype's byte size, not the legacy 4 (f32). + let normByteSize = dtype.byteSize + let normBytesPerHead = maxSeq * normByteSize for h in 0 ..< nKVHeads { let inputView = Tensor( @@ -390,10 +405,16 @@ public final class AURAQuantizedKVCache: KVCacheProtocol, @unchecked Sendable { shape: [1, packedWidth], dtype: .u32) let normsView = Tensor( buffer: norms.buffer, - offset: norms.offset + h * normBytesPerHead + pos * 4, - shape: [1], dtype: .f32) + offset: norms.offset + h * normBytesPerHead + pos * normByteSize, + shape: [1], dtype: dtype) + // metaltile #226: aura_encode now takes rotation+boundaries + // as Tensor. Use the activation-dtype copy of Π + // (`rotationDtype`) instead of the legacy f32 `rotation` + // field — the f32 field is kept around for any future + // kernel that wants f32 rotations, but the encoder no + // longer does. Ops.auraEncode( - input: inputView, rotation: rotation, + input: inputView, rotation: rotationDtype, boundaries: boundaries, codebook: codebook, packedOut: packedView, normsOut: normsView, rows: 1, dim: headDim, packedWidth: packedWidth, bits: bits, diff --git a/Sources/FFAI/KVCache/AURAScheme.swift b/Sources/FFAI/KVCache/AURAScheme.swift index 029d4467..439b36b7 100644 --- a/Sources/FFAI/KVCache/AURAScheme.swift +++ b/Sources/FFAI/KVCache/AURAScheme.swift @@ -63,6 +63,61 @@ public struct AURAScheme: Sendable, Equatable, Hashable { /// near-baseline quality on tested attention-only models. public static let aura4v2 = AURAScheme(keyBits: 4, valueBits: 2) + /// Production K-protected recipe — 8-bit K + 4-bit V. Matches + /// canonical TQ+'s `q8_0-K + turbo4-V` shape; on Qwen3-0.6B-4bit + /// the FFAI KLD harness measures mean_kld=0.029 + same-top=89% + /// (vs aura4v4's 1.24 / 47%, a 43× quality improvement at 50% + /// size cost). The K-side precision is what dominates attention + /// quality (softmax exponentiates K-score errors); V can be + /// aggressive cheaply. + public static let aura8v4 = AURAScheme(keyBits: 8, valueBits: 4) + + /// Sibling of `aura8v4` — 8-bit K + 2-bit V. Tightest size at + /// preserved K precision. + public static let aura8v2 = AURAScheme(keyBits: 8, valueBits: 2) + + /// Auto-asymmetric-policy resolver. Mirrors canonical TQ+'s + /// `TURBO_AUTO_ASYMMETRIC` env behavior: when the model has a + /// high GQA fan-out (gqaFactor ≥ 6), shared K rows get + /// "amplified" by the softmax across many Q heads — small K + /// quantization errors compound across the GQA group. The + /// production fix is to keep K at the highest available precision + /// (8-bit Lloyd-Max in AURA-land, q8_0 in canonical TQ+). + /// + /// Behavior: + /// - If `gqaFactor < 6`, return `requested` unchanged. + /// - If `gqaFactor ≥ 6` and `requested.keyBits < 8`, return a + /// scheme with keyBits bumped to 8 (V untouched). + /// - If `gqaFactor ≥ 6` and `requested.keyBits == 8`, return + /// `requested` unchanged (already protected). + /// + /// Pure resolver — always applies the policy when conditions are + /// met. **The policy itself is not opt-in here**; the opt-in lives + /// at the call site (model loaders gate this on + /// `FFAI_AURA_AUTO_ASYM=1`, and a per-load `LoadOptions` flag will + /// replace the env knob in a follow-up). Tests + future API + /// callers that want the canonical TQ+ behaviour can invoke this + /// directly without env coupling. + /// + /// Canonical-source mapping: TURBO_AUTO_ASYMMETRIC in + /// the reference C++ KV-cache implementation. Threshold = 6 + /// matches that reference. + public static func autoAsymmetric( + requested: AURAScheme, gqaFactor: Int + ) -> AURAScheme { + if gqaFactor < 6 { return requested } + if requested.keyBits >= 8 { return requested } + return AURAScheme(keyBits: 8, valueBits: requested.valueBits) + } + + /// True when the caller has opted into the auto-asymmetric policy + /// via `FFAI_AURA_AUTO_ASYM=1`. Read once at module load. Default + /// OFF — Eric's "no magic by default" stance: the caller must + /// explicitly request the policy. + public static let autoAsymmetricOptedIn: Bool = { + ProcessInfo.processInfo.environment["FFAI_AURA_AUTO_ASYM"] == "1" + }() + /// Parse a CLI / config string. Accepts: /// /// - `aura` — the stability-first default (aura4v4). diff --git a/Sources/FFAI/Loader/GGUF/GGUFDequant.swift b/Sources/FFAI/Loader/GGUF/GGUFDequant.swift new file mode 100644 index 00000000..a56d9890 --- /dev/null +++ b/Sources/FFAI/Loader/GGUF/GGUFDequant.swift @@ -0,0 +1,290 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// GGUF on-disk → GPU-resident dequant pipeline. For each supported +// quant format, splits the packed on-disk blocks into the GPU-resident +// tensor layout the metaltile dequant kernel expects, then dispatches +// the kernel and returns a host-readable `Tensor`. +// +// The CPU split is a one-pass scan over the raw GGUF bytes — fp16 +// scales are converted to f32 by host code so the kernel doesn't have +// to bit-cast inside the DSL. + +import Foundation +import QuartzCore +import Metal + +public enum GGUFDequant { + // ─── Q8_0 ────────────────────────────────────────────────────────── + + /// Block: `[fp16 d (2 B); int8 qs[32] (32 B)]` = 34 B per 32 values. + static let q8_0BlockBytes = 34 + static let q8_0BlockValues = 32 + + /// Split each on-disk Q8_0 block into the GPU-resident tensors the + /// kernel expects: a contiguous `[n_blocks * 32]` byte buffer of + /// int8 quants (kernel sign-reconstructs via `select`) and a + /// `[n_blocks]` f32 buffer of fp16-converted block super-scales. + static func dequantQ8_0( + rawBlocks: Data, nValues: Int, outDtype: DType, + on cmd: MTLCommandBuffer, device: Device, + into out: Tensor? = nil, slot: String = "default" + ) -> Tensor { + precondition(nValues % q8_0BlockValues == 0) + let nBlocks = nValues / q8_0BlockValues + precondition( + rawBlocks.count >= nBlocks * q8_0BlockBytes, + "GGUFDequant.Q8_0: rawBlocks too short for \(nBlocks) blocks") + + // Write the CPU splits DIRECTLY into the intermediate + // MTLBuffers (skip per-call Swift arrays). + let qsBytesCount = nBlocks * q8_0BlockValues + let scBytes = nBlocks * 4 + let qsBuf = device.intermediateScratch(tag: "gguf_dequant_u8_\(slot)", minBytes: qsBytesCount) + let scBuf = device.intermediateScratch(tag: "gguf_dequant_f32_\(slot)", minBytes: scBytes) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt8.self) + let scPtr = scBuf.contents().assumingMemoryBound(to: Float.self) + rawBlocks.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress! + for b in 0.. Tensor { + precondition(nValues % q2_KBlockValues == 0) + let nBlocks = nValues / q2_KBlockValues + precondition( + rawBlocks.count >= nBlocks * q2_KBlockBytes, + "GGUFDequant.Q2_K: rawBlocks too short for \(nBlocks) blocks") + + // Write the 4 CPU splits DIRECTLY into the intermediate + // MTLBuffers (skip the per-call 524 MB / 128 MB / 32 KB Swift + // arrays that wasn't getting returned to the OS). + let qsBytes = nBlocks * 16 * 4 + let scBytes = nBlocks * 16 + let dBytes = nBlocks * 4 + // Q2_K uses BOTH a u32 + a u8 + two f32 buffers; tag them + // separately so the u32 / u8 / f32 slabs from IQ2_XXS / + // Q8_0 (same tags) are reused too. + let qsBuf = device.intermediateScratch(tag: "gguf_dequant_u32_\(slot)", minBytes: qsBytes) + let scBuf = device.intermediateScratch(tag: "gguf_dequant_u8_\(slot)", minBytes: scBytes) + // Q2_K needs TWO f32 buffers (d + dmin) — different tag for + // the second so they don't overwrite each other. + let dBuf = device.intermediateScratch(tag: "gguf_dequant_f32_\(slot)", minBytes: dBytes) + let dminBuf = device.intermediateScratch(tag: "gguf_dequant_f32_dmin_\(slot)", minBytes: dBytes) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt32.self) + let scPtr = scBuf.contents().assumingMemoryBound(to: UInt8.self) + let dPtr = dBuf.contents().assumingMemoryBound(to: Float.self) + let dminPtr = dminBuf.contents().assumingMemoryBound(to: Float.self) + rawBlocks.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress! + let qsBytes = UnsafeMutableRawPointer(qsPtr).assumingMemoryBound(to: UInt8.self) + let chunks = 32 + let blocksPerChunk = (nBlocks + chunks - 1) / chunks + DispatchQueue.concurrentPerform(iterations: chunks) { c in + let start = c * blocksPerChunk + let end = min(start + blocksPerChunk, nBlocks) + guard start < end else { return } + for b in start.. Tensor { + precondition(nValues % iq2_xxsBlockValues == 0) + let nBlocks = nValues / iq2_xxsBlockValues + precondition( + rawBlocks.count >= nBlocks * iq2_xxsBlockBytes, + "GGUFDequant.IQ2_XXS: rawBlocks too short for \(nBlocks) blocks") + + let qsBytes = nBlocks * 16 * 4 + let dBytes = nBlocks * 4 + let qsBuf = device.intermediateScratch(tag: "gguf_dequant_u32_\(slot)", minBytes: qsBytes) + let dBuf = device.intermediateScratch(tag: "gguf_dequant_f32_\(slot)", minBytes: dBytes) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt32.self) + let dPtr = dBuf.contents().assumingMemoryBound(to: Float.self) + let _tStage0 = CACurrentMediaTime() + rawBlocks.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress! + let qsBytes = UnsafeMutableRawPointer(qsPtr).assumingMemoryBound(to: UInt8.self) + let chunks = 32 + let blocksPerChunk = (nBlocks + chunks - 1) / chunks + DispatchQueue.concurrentPerform(iterations: chunks) { c in + let start = c * blocksPerChunk + let end = min(start + blocksPerChunk, nBlocks) + guard start < end else { return } + for b in start.. (grid: Tensor, signs: Tensor) { + cacheLock.lock() + defer { cacheLock.unlock() } + if let cached = iq2xxsTablesCache, cached.device === device { + return (cached.grid, cached.signs) + } + // MUST be dedicated, persistent buffers — NOT the shared + // "gguf_dequant_u8" scratch slot that makeU8Tensor uses. grid + // (2048 B) and ksigns (128 B) have different sizes but the same + // scratch tag, so routing both through makeU8Tensor made the + // ksigns copy overwrite the first 128 bytes of the grid buffer + // (corrupting grid entries for keys 0..15 → every IQ2_XXS dequant + // that hit a low grid key produced wrong weights). These tables + // are static and live for the whole process, so give each its own + // owned MTLBuffer. + let grid = persistentU8Tensor(bytes: GGUFIQ2XXSTables.grid, device: device) + let signs = persistentU8Tensor(bytes: GGUFIQ2XXSTables.ksigns, device: device) + iq2xxsTablesCache = (grid, signs, device) + return (grid, signs) + } + + // ─── Buffer construction helpers ─────────────────────────────────── + + /// Dedicated, owned u8 buffer for a static lookup table (IQ2_XXS + /// grid / ksigns). Unlike `makeU8Tensor` this does NOT use the shared + /// scratch slot, so two tables of different sizes can't alias. + private static func persistentU8Tensor(bytes: [UInt8], device: Device) -> Tensor { + let buf = device.makeBuffer(length: max(bytes.count, 1)) + bytes.withUnsafeBufferPointer { src in + if let base = src.baseAddress { + buf.contents().copyMemory(from: UnsafeRawPointer(base), byteCount: bytes.count) + } + } + return Tensor(buffer: buf, offset: 0, shape: [bytes.count], dtype: .u8) + } + + private static func makeU8Tensor(bytes: [UInt8], device: Device) -> Tensor { + // The IQ2_XXS lookup tables (grid + ksigns) are static and + // SHARED across every IQ2_XXS dequant — those go through the + // long-lived `iq2xxsTablesCache` (see `iq2xxsTables`), not + // through this helper. The TRANSIENT u8 buffers that DO flow + // through here are the per-dequant `qs_signed` (Q8_0) and + // `scales` (Q2_K) intermediates — both safely reusable per + // call boundary (caller commits + waits the dequant cmd + // buffer before returning). + let need = max(bytes.count, 1) + let buf = device.intermediateScratch(tag: "gguf_dequant_u8", minBytes: need) + bytes.withUnsafeBufferPointer { src in + buf.contents().copyMemory( + from: UnsafeRawPointer(src.baseAddress!), byteCount: bytes.count) + } + return Tensor(buffer: buf, offset: 0, shape: [bytes.count], dtype: .u8) + } + + private static func makeU32Tensor(values: [UInt32], device: Device) -> Tensor { + // Use the persistent dequant intermediate buffer keyed by + // "u32". Caller (GGUFTensorBundle.tensor) commits + waits the + // dequant kernel before returning, so the same buffer is + // safely reusable for the next dequant call. Saves ~524 MB + // per IQ2_XXS expert tensor of fresh-MTLBuffer churn that + // Metal's driver wasn't pooling efficiently. + let bytes = max(values.count * 4, 4) + let buf = device.intermediateScratch(tag: "gguf_dequant_u32", minBytes: bytes) + values.withUnsafeBufferPointer { src in + buf.contents().copyMemory( + from: UnsafeRawPointer(src.baseAddress!), byteCount: values.count * 4) + } + return Tensor(buffer: buf, offset: 0, shape: [values.count], dtype: .u32) + } + + private static func makeF32Tensor(values: [Float], device: Device) -> Tensor { + let bytes = max(values.count * 4, 4) + let buf = device.intermediateScratch(tag: "gguf_dequant_f32", minBytes: bytes) + values.withUnsafeBufferPointer { src in + buf.contents().copyMemory( + from: UnsafeRawPointer(src.baseAddress!), byteCount: values.count * 4) + } + return Tensor(buffer: buf, offset: 0, shape: [values.count], dtype: .f32) + } + nonisolated(unsafe) public static var profStageIq2: Double = 0 + nonisolated(unsafe) public static var profEncodeIq2: Double = 0 +} diff --git a/Sources/FFAI/Loader/GGUF/GGUFIQ2XXSTables.swift b/Sources/FFAI/Loader/GGUF/GGUFIQ2XXSTables.swift new file mode 100644 index 00000000..7a393504 --- /dev/null +++ b/Sources/FFAI/Loader/GGUF/GGUFIQ2XXSTables.swift @@ -0,0 +1,122 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// IQ2_XXS i-quant lookup tables — the canonical IQ2_XXS reference +// constants. These tables are uploaded once at runtime init and +// shared by every IQ2_XXS dequant dispatch. + +import Foundation + +enum GGUFIQ2XXSTables { + /// `iq2xxs_grid[256]` from ggml-quants.c. 256 entries, each a `u64` + /// encoding 8 small-positive-integer magnitudes (bytes are in + /// `{0x08, 0x19, 0x2b}` = `{8, 25, 43}`). The dequant kernel + /// indexes a row by `aux_idx_byte`, then reads byte `lane_in_octet` + /// as the absolute magnitude — the sign comes from `ksigns`. + /// + /// Returns the table as a `[u8]` of length 2048 in little-endian + /// layout so the metaltile kernel's `Tensor grid[256*8]` lookup + /// works directly (`grid[key*8 + lane]`). + static let grid: [UInt8] = { + var bytes = [UInt8]() + bytes.reserveCapacity(256 * 8) + for entry in gridU64 { + withUnsafeBytes(of: entry.littleEndian) { raw in + bytes.append(contentsOf: raw) + } + } + return bytes + }() + + /// `ksigns_iq2xs[128]` from ggml-quants.c. Maps a 7-bit sign-field + /// index to an 8-bit mask; bit `l` of the returned byte says + /// whether octet `l` flips sign. + static let ksigns: [UInt8] = [ + 0, 129, 130, 3, 132, 5, 6, 135, 136, 9, 10, 139, 12, 141, 142, 15, + 144, 17, 18, 147, 20, 149, 150, 23, 24, 153, 154, 27, 156, 29, 30, 159, + 160, 33, 34, 163, 36, 165, 166, 39, 40, 169, 170, 43, 172, 45, 46, 175, + 48, 177, 178, 51, 180, 53, 54, 183, 184, 57, 58, 187, 60, 189, 190, 63, + 192, 65, 66, 195, 68, 197, 198, 71, 72, 201, 202, 75, 204, 77, 78, 207, + 80, 209, 210, 83, 212, 85, 86, 215, 216, 89, 90, 219, 92, 221, 222, 95, + 96, 225, 226, 99, 228, 101, 102, 231, 232, 105, 106, 235, 108, 237, 238, 111, + 240, 113, 114, 243, 116, 245, 246, 119, 120, 249, 250, 123, 252, 125, 126, 255, + ] + + private static let gridU64: [UInt64] = [ + 0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, + 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x08080808082b0808, + 0x08080808082b082b, 0x08080808082b2b08, 0x08080808082b2b2b, 0x0808080819080819, + 0x0808080819081908, 0x0808080819190808, 0x0808080819192b08, 0x08080808192b0819, + 0x08080808192b1908, 0x080808082b080808, 0x080808082b08082b, 0x080808082b082b2b, + 0x080808082b2b082b, 0x0808081908080819, 0x0808081908081908, 0x0808081908190808, + 0x0808081908191919, 0x0808081919080808, 0x080808192b081908, 0x080808192b192b08, + 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b082b082b, 0x0808082b2b08082b, + 0x0808190808080819, 0x0808190808081908, 0x0808190808190808, 0x08081908082b0819, + 0x08081908082b1908, 0x0808190819080808, 0x080819081908082b, 0x0808190819082b08, + 0x08081908192b0808, 0x080819082b080819, 0x080819082b081908, 0x080819082b190808, + 0x080819082b2b1908, 0x0808191908080808, 0x080819190808082b, 0x0808191908082b08, + 0x08081919082b0808, 0x080819191908192b, 0x08081919192b2b19, 0x080819192b080808, + 0x080819192b190819, 0x0808192b08082b19, 0x0808192b08190808, 0x0808192b19080808, + 0x0808192b2b081908, 0x0808192b2b2b1908, 0x08082b0808080808, 0x08082b0808081919, + 0x08082b0808082b08, 0x08082b0808191908, 0x08082b08082b2b08, 0x08082b0819080819, + 0x08082b0819081908, 0x08082b0819190808, 0x08082b081919082b, 0x08082b082b082b08, + 0x08082b1908081908, 0x08082b1919080808, 0x08082b2b0808082b, 0x08082b2b08191908, + 0x0819080808080819, 0x0819080808081908, 0x0819080808190808, 0x08190808082b0819, + 0x0819080819080808, 0x08190808192b0808, 0x081908082b081908, 0x081908082b190808, + 0x081908082b191919, 0x0819081908080808, 0x0819081908082b08, 0x08190819082b0808, + 0x0819081919190808, 0x0819081919192b2b, 0x081908192b080808, 0x0819082b082b1908, + 0x0819082b19081919, 0x0819190808080808, 0x0819190808082b08, 0x08191908082b0808, + 0x08191908082b1919, 0x0819190819082b19, 0x081919082b080808, 0x0819191908192b08, + 0x08191919192b082b, 0x0819192b08080808, 0x0819192b0819192b, 0x08192b0808080819, + 0x08192b0808081908, 0x08192b0808190808, 0x08192b0819080808, 0x08192b082b080819, + 0x08192b1908080808, 0x08192b1908081919, 0x08192b192b2b0808, 0x08192b2b19190819, + 0x082b080808080808, 0x082b08080808082b, 0x082b080808082b2b, 0x082b080819081908, + 0x082b0808192b0819, 0x082b08082b080808, 0x082b08082b08082b, 0x082b0819082b2b19, + 0x082b081919082b08, 0x082b082b08080808, 0x082b082b0808082b, 0x082b190808080819, + 0x082b190808081908, 0x082b190808190808, 0x082b190819080808, 0x082b19081919192b, + 0x082b191908080808, 0x082b191919080819, 0x082b1919192b1908, 0x082b192b2b190808, + 0x082b2b0808082b08, 0x082b2b08082b0808, 0x082b2b082b191908, 0x082b2b2b19081908, + 0x1908080808080819, 0x1908080808081908, 0x1908080808190808, 0x1908080808192b08, + 0x19080808082b0819, 0x19080808082b1908, 0x1908080819080808, 0x1908080819082b08, + 0x190808081919192b, 0x19080808192b0808, 0x190808082b080819, 0x190808082b081908, + 0x190808082b190808, 0x1908081908080808, 0x19080819082b0808, 0x19080819192b0819, + 0x190808192b080808, 0x190808192b081919, 0x1908082b08080819, 0x1908082b08190808, + 0x1908082b19082b08, 0x1908082b1919192b, 0x1908082b192b2b08, 0x1908190808080808, + 0x1908190808082b08, 0x19081908082b0808, 0x190819082b080808, 0x190819082b192b19, + 0x190819190819082b, 0x19081919082b1908, 0x1908192b08080808, 0x19082b0808080819, + 0x19082b0808081908, 0x19082b0808190808, 0x19082b0819080808, 0x19082b0819081919, + 0x19082b1908080808, 0x19082b1919192b08, 0x19082b19192b0819, 0x19082b192b08082b, + 0x19082b2b19081919, 0x19082b2b2b190808, 0x1919080808080808, 0x1919080808082b08, + 0x1919080808190819, 0x1919080808192b19, 0x19190808082b0808, 0x191908082b080808, + 0x191908082b082b08, 0x1919081908081908, 0x191908191908082b, 0x191908192b2b1908, + 0x1919082b2b190819, 0x191919082b190808, 0x191919082b19082b, 0x1919191908082b2b, + 0x1919192b08080819, 0x1919192b19191908, 0x19192b0808080808, 0x19192b0808190819, + 0x19192b0808192b19, 0x19192b08192b1908, 0x19192b1919080808, 0x19192b2b08082b08, + 0x192b080808081908, 0x192b080808190808, 0x192b080819080808, 0x192b0808192b2b08, + 0x192b081908080808, 0x192b081919191919, 0x192b082b08192b08, 0x192b082b192b0808, + 0x192b190808080808, 0x192b190808081919, 0x192b191908190808, 0x192b19190819082b, + 0x192b19192b081908, 0x192b2b081908082b, 0x2b08080808080808, 0x2b0808080808082b, + 0x2b08080808082b2b, 0x2b08080819080819, 0x2b0808082b08082b, 0x2b08081908081908, + 0x2b08081908192b08, 0x2b08081919080808, 0x2b08082b08190819, 0x2b08190808080819, + 0x2b08190808081908, 0x2b08190808190808, 0x2b08190808191919, 0x2b08190819080808, + 0x2b081908192b0808, 0x2b08191908080808, 0x2b0819191908192b, 0x2b0819192b191908, + 0x2b08192b08082b19, 0x2b08192b19080808, 0x2b08192b192b0808, 0x2b082b080808082b, + 0x2b082b1908081908, 0x2b082b2b08190819, 0x2b19080808081908, 0x2b19080808190808, + 0x2b190808082b1908, 0x2b19080819080808, 0x2b1908082b2b0819, 0x2b1908190819192b, + 0x2b1908192b080808, 0x2b19082b19081919, 0x2b19190808080808, 0x2b191908082b082b, + 0x2b19190819081908, 0x2b19191919190819, 0x2b192b082b080819, 0x2b192b19082b0808, + 0x2b2b08080808082b, 0x2b2b080819190808, 0x2b2b08082b081919, 0x2b2b081908082b19, + 0x2b2b082b08080808, 0x2b2b190808192b08, 0x2b2b2b0819190808, 0x2b2b2b1908081908, + ] +} diff --git a/Sources/FFAI/Loader/GGUF/GGUFModelViews.swift b/Sources/FFAI/Loader/GGUF/GGUFModelViews.swift new file mode 100644 index 00000000..404f931a --- /dev/null +++ b/Sources/FFAI/Loader/GGUF/GGUFModelViews.swift @@ -0,0 +1,92 @@ +// Copyright 2026 Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +// +// Zero-copy GPU-resident weight views over the GGUF tensor-data region. +// +// Wrap the tensor-data region in a HANDFUL of overlapping page-aligned +// `newBufferWithBytesNoCopy` views. Each view is <= maxBufferLength; +// adjacent views overlap by (maxTensorBytes + page) so EVERY tensor lies +// wholly inside one view — a hot kernel takes one `(MTLBuffer, innerOffset)`. +// +// CRITICAL: this wraps the reader's EXISTING mmap (GGUFReader.mmapBase) — +// it does NOT create a second mapping. A second mmap of the 86 GB file +// would double resident memory (the no-copy views pin every page they +// touch), causing pageouts. Views hold no-copy buffers over pages the +// reader already owns; we never munmap here. + +import Foundation +import Metal + +#if canImport(Darwin) +import Darwin +#endif + +/// A set of overlapping no-copy MTLBuffer views over the reader's mmap. +public final class GGUFModelViews { + public struct View { + public let buffer: MTLBuffer + public let fileOffset: Int // absolute file byte offset this view starts at + public let length: Int + } + + public let views: [View] + + /// Wrap the reader's EXISTING mmap (no second mapping) in overlapping + /// no-copy GPU views. + /// - Parameters: + /// - mmapBase: the reader's stable, page-aligned mmap base pointer. + /// - fileSize: mapped byte count. + /// - dataStart: absolute file offset where tensor data begins. + /// - maxTensorBytes: largest tensor byte length (sets view overlap). + public init?(mmapBase: UnsafeRawPointer, fileSize: Int, dataStart: Int, + maxTensorBytes: Int, device: Device) { + let page = Int(getpagesize()) + guard UInt(bitPattern: mmapBase) & UInt(page - 1) == 0 else { return nil } // page-aligned + + // Page-align the cover start down to a page boundary at/below dataStart. + let coverStart = dataStart & ~(page - 1) + let coverLen = fileSize - coverStart + guard coverLen > 0, maxTensorBytes > 0 else { return nil } + let regionBase = UnsafeMutableRawPointer(mutating: mmapBase.advanced(by: coverStart)) + + // Cap views at <4 GiB so kernels can address any inner byte offset + // with u32. The overlap invariant keeps every tensor wholly inside a + // view, so the max in-view index is <= viewSize < 2^32. (Capping is + // simpler than a u64 offset + the full maxBufferLength.) + let maxBuffer = Swift.min(device.mtlDevice.maxBufferLength, 4_000_000_000) & ~(page - 1) + if maxBuffer == 0 { return nil } + + // Overlap so every tensor fits wholly in one view (overlap invariant). + let overlap = ((maxTensorBytes + page - 1) & ~(page - 1)) + page + guard maxBuffer > overlap else { return nil } + let step = maxBuffer - overlap + + var built: [View] = [] + var off = 0 + while off < coverLen { + let viewBytes = Swift.min(maxBuffer, coverLen - off) + guard let buf = device.mtlDevice.makeBuffer( + bytesNoCopy: regionBase.advanced(by: off), + length: viewBytes, + options: [.storageModeShared], + deallocator: nil) // reader owns the mmap; we never unmap + else { return nil } + buf.label = "gguf_model_view_\(built.count)" + built.append(View(buffer: buf, fileOffset: coverStart + off, length: viewBytes)) + if viewBytes == coverLen - off { break } + off += step + } + self.views = built + } + + /// Return the view (buffer + inner byte offset) that wholly contains + /// the file byte range `[absStart, absStart+length)`. + public func view(absStart: Int, length: Int) -> (buffer: MTLBuffer, offset: Int)? { + for v in views { + if absStart >= v.fileOffset && absStart + length <= v.fileOffset + v.length { + return (v.buffer, absStart - v.fileOffset) + } + } + return nil + } +} diff --git a/Sources/FFAI/Loader/GGUF/GGUFReader.swift b/Sources/FFAI/Loader/GGUF/GGUFReader.swift new file mode 100644 index 00000000..044e11c2 --- /dev/null +++ b/Sources/FFAI/Loader/GGUF/GGUFReader.swift @@ -0,0 +1,528 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// GGUF v3 file reader — header + metadata KV + tensor info table. +// +// All scalars are little-endian. Parses lazily where possible: the +// tensor info table is decoded eagerly (small + needed for the load +// dispatch), but tensor data stays mmap'd; the loader copies / dequants +// individual tensors on demand. +// +// Adapts the canonical GGUF v3 binary spec. Pure Swift; no FFI. + +import Foundation + +/// One opened GGUF file — header + metadata KV + tensor info, plus a +/// memory-mapped handle to the raw bytes for on-demand tensor reads. +public final class GGUFReader { + /// Backing file URL. + public let url: URL + /// File version (must be 3 — earlier versions throw at parse). + public let version: UInt32 + /// Tensor-data section alignment (default 32, may be overridden by + /// the `general.alignment` metadata key). + public let alignment: UInt64 + /// Tensor-data section absolute file offset. + public let tensorDataOffset: UInt64 + /// Metadata KV block. + public let metadata: [String: GGUFValue] + /// Tensor info table (ordered as stored on disk). + public let tensorInfos: [GGUFTensorInfo] + /// Name → index into `tensorInfos` for O(1) lookup. + public let tensorIndex: [String: Int] + /// Memory-mapped backing data. Held to keep the mapping alive + /// for the lifetime of any tensor read. + private let mapped: Data + + /// Stable base pointer of the mmap'd file. Valid for the reader's + /// lifetime (the `mapped` Data holds the mapping; mmap pages don't + /// move). Used to wrap zero-copy GPU views over the EXISTING mapping + /// — never a second mmap (that would double resident memory). + /// Returns nil if the Data isn't a contiguous mmap (e.g. tiny test + /// Data); callers fall back to the streaming read path. + public lazy var mmapBase: UnsafeRawPointer? = { + return mapped.withUnsafeBytes { $0.baseAddress } + }() + public var mmapByteCount: Int { mapped.count } + + /// When true, skip the post-read `MADV_FREE` that evicts mmap + /// pages from RSS. For a model that fits resident (84 GB on a + /// 128 GB Mac), evicting expert quant pages after each read forces + /// every subsequent token to re-fault ~3 GB from disk, collapsing + /// steady-state decode to ~2 tps. Keep pages resident instead. + /// Default ON (resident) — set FFAI_MADV_FREE=1 to restore the + /// streaming behaviour for memory-constrained runs. + static let keepResident: Bool = + ProcessInfo.processInfo.environment["FFAI_MADV_FREE"] != "1" + + // ─── Init ────────────────────────────────────────────────────────── + + public convenience init(url: URL) throws { + // `.mappedIfSafe` lets Foundation fall back to a regular read + // if the filesystem doesn't support mmap (network shares, + // some FUSE mounts). Worst case is a slow first read; we + // tolerate it. + let data = try Data(contentsOf: url, options: .mappedIfSafe) + try self.init(url: url, data: data) + } + + /// In-memory init — useful for tests that synthesise a GGUF + /// header in `Data` without writing a temp file. + public init(url: URL, data: Data) throws { + self.url = url + self.mapped = data + var cursor = GGUFCursor(data: data) + + // ── Header ── + let magic = try cursor.readBytes(GGUFConstants.magic.count, at: "magic") + guard magic == GGUFConstants.magic else { + throw GGUFError.badMagic + } + let version: UInt32 = try cursor.readLE(at: "version") + guard version == GGUFConstants.supportedVersion else { + throw GGUFError.unsupportedVersion(version) + } + self.version = version + let tensorCount: UInt64 = try cursor.readLE(at: "tensor_count") + let metadataCount: UInt64 = try cursor.readLE(at: "metadata_kv_count") + + // ── Metadata KV block ── + var metadata: [String: GGUFValue] = [:] + metadata.reserveCapacity(Int(metadataCount)) + for _ in 0..() + seenNames.reserveCapacity(Int(tensorCount)) + var index: [String: Int] = [:] + index.reserveCapacity(Int(tensorCount)) + for i in 0.. Data { + guard let idx = tensorIndex[name] else { + throw GGUFError.missingMetadataKey("tensor:\(name)") + } + let info = tensorInfos[idx] + let start = Int(tensorDataOffset + info.dataOffset) + let end = start + info.byteLength + return mapped.subdata(in: start..(bitPattern: aStart) { + let total = aEnd - aStart + var acc: UInt8 = 0, off = 0 + while off < total { acc = acc &+ p[off]; off += pg } + Self.prefetchSink = acc + } + } + } + nonisolated(unsafe) static var prefetchSink: UInt8 = 0 + + public func withRawBytesSlice( + named name: String, byteStart relStart: Int, byteLength: Int, + _ body: (UnsafeBufferPointer) throws -> T + ) throws -> T { + guard let idx = tensorIndex[name] else { + throw GGUFError.missingMetadataKey("tensor:\(name)") + } + let info = tensorInfos[idx] + precondition( + relStart + byteLength <= info.byteLength, + "withRawBytesSlice: range overflows tensor (start=\(relStart) + len=\(byteLength) > total=\(info.byteLength))") + let start = Int(tensorDataOffset + info.dataOffset) + relStart + let length = byteLength + return try mapped.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress!.advanced(by: start) + let result = try body(UnsafeBufferPointer(start: base, count: length)) + if !Self.keepResident { + let pageMask = Int(getpagesize()) - 1 + let pageAlignedStart = Int(bitPattern: UnsafeRawPointer(base)) & ~pageMask + let endAddr = Int(bitPattern: UnsafeRawPointer(base)) + length + let pageAlignedEnd = (endAddr + pageMask) & ~pageMask + let advLen = pageAlignedEnd - pageAlignedStart + _ = madvise( + UnsafeMutableRawPointer(bitPattern: pageAlignedStart), + advLen, MADV_FREE) + } + return result + } + } + + /// Zero-copy access to a tensor's raw bytes via a closure. The + /// closure receives an `UnsafeBufferPointer` pointing INTO + /// the original mmap — no copy, no anonymous RAM allocation. The + /// pointer is valid only inside the closure scope. + /// + /// After the closure returns, the mmap pages are advised to the + /// kernel as `MADV_DONTNEED` — for layer-streaming forward, each + /// layer's ~1.5 GB of raw quant blocks is read once then never + /// touched again, so keeping those pages resident is pure RSS + /// pressure. The kernel reclaims them lazily under memory + /// pressure even without the hint, but the explicit hint lets us + /// stay well under the 128 GB unified-memory ceiling during the + /// 43-layer forward. + public func withRawBytes( + named name: String, _ body: (UnsafeBufferPointer) throws -> T + ) throws -> T { + guard let idx = tensorIndex[name] else { + throw GGUFError.missingMetadataKey("tensor:\(name)") + } + let info = tensorInfos[idx] + let start = Int(tensorDataOffset + info.dataOffset) + let length = info.byteLength + return try mapped.withUnsafeBytes { raw in + let base = raw.bindMemory(to: UInt8.self).baseAddress!.advanced(by: start) + let result = try body(UnsafeBufferPointer(start: base, count: length)) + // Darwin: MADV_FREE tells the kernel these pages are + // safely reclaimable. Unlike POSIX_MADV_DONTNEED (which + // is a no-op on Darwin), MADV_FREE actually evicts the + // pages from RSS — important when streaming 43 layers + // through a 86 GB GGUF on a 128 GB Mac, where holding + // every layer's 1.5 GB of raw quant pages resident would + // overflow before reaching the LM head. + if !Self.keepResident { + let pageMask = Int(getpagesize()) - 1 + let pageAlignedStart = Int(bitPattern: UnsafeRawPointer(base)) & ~pageMask + let endAddr = Int(bitPattern: UnsafeRawPointer(base)) + length + let pageAlignedEnd = (endAddr + pageMask) & ~pageMask + let advLen = pageAlignedEnd - pageAlignedStart + _ = madvise( + UnsafeMutableRawPointer(bitPattern: pageAlignedStart), + advLen, MADV_FREE) + } + return result + } + } + + /// Convenience: get a metadata value, casted to a specific type. + /// Returns nil if absent or the type doesn't match. + public func metadataString(_ key: String) -> String? { + if case .string(let s) = metadata[key] { return s } + return nil + } + + public func metadataUInt32(_ key: String) -> UInt32? { + switch metadata[key] { + case .uint32(let v): return v + case .int32(let v) where v >= 0: return UInt32(v) + case .uint64(let v) where v <= UInt32.max: return UInt32(v) + default: return nil + } + } + + public func metadataFloat(_ key: String) -> Float? { + switch metadata[key] { + case .float32(let v): return v + case .float64(let v): return Float(v) + default: return nil + } + } + + public func metadataBool(_ key: String) -> Bool? { + if case .bool(let b) = metadata[key] { return b } + return nil + } + + public func metadataStringArray(_ key: String) -> [String]? { + if case .array(.string(let arr)) = metadata[key] { return arr } + return nil + } + + /// Integer array accessor — coerces any of the integer-typed GGUF + /// array kinds (i32 / u32 / i64 / u64 / i16 / u16 / i8 / u8) to + /// `[Int]`. Used for per-layer parameter arrays like + /// `deepseek4.attention.compress_ratios`. + public func metadataIntArray(_ key: String) -> [Int]? { + switch metadata[key] { + case .array(.int32(let a)): return a.map { Int($0) } + case .array(.uint32(let a)): return a.map { Int($0) } + case .array(.int64(let a)): return a.map { Int($0) } + case .array(.uint64(let a)): return a.map { Int($0) } + case .array(.int16(let a)): return a.map { Int($0) } + case .array(.uint16(let a)): return a.map { Int($0) } + case .array(.int8(let a)): return a.map { Int($0) } + case .array(.uint8(let a)): return a.map { Int($0) } + default: return nil + } + } + + // ─── Value-type decoder (internal) ──────────────────────────────── + + private static func readValue(cursor: inout GGUFCursor, key: String) throws -> GGUFValue { + let tag: UInt32 = try cursor.readLE(at: "value-type tag (key=\(key))") + guard let kind = GGUFValueType(rawValue: tag) else { + throw GGUFError.unknownValueType(tag, key: key) + } + return try readScalarOrArray(cursor: &cursor, kind: kind, key: key) + } + + private static func readScalarOrArray( + cursor: inout GGUFCursor, kind: GGUFValueType, key: String + ) throws -> GGUFValue { + switch kind { + case .uint8: return .uint8(try cursor.readLE(at: "u8 (\(key))")) + case .int8: return .int8(Int8(bitPattern: try cursor.readLE(at: "i8 (\(key))"))) + case .uint16: return .uint16(try cursor.readLE(at: "u16 (\(key))")) + case .int16: + let raw: UInt16 = try cursor.readLE(at: "i16 (\(key))") + return .int16(Int16(bitPattern: raw)) + case .uint32: return .uint32(try cursor.readLE(at: "u32 (\(key))")) + case .int32: + let raw: UInt32 = try cursor.readLE(at: "i32 (\(key))") + return .int32(Int32(bitPattern: raw)) + case .uint64: return .uint64(try cursor.readLE(at: "u64 (\(key))")) + case .int64: + let raw: UInt64 = try cursor.readLE(at: "i64 (\(key))") + return .int64(Int64(bitPattern: raw)) + case .float32: + let raw: UInt32 = try cursor.readLE(at: "f32 (\(key))") + return .float32(Float(bitPattern: raw)) + case .float64: + let raw: UInt64 = try cursor.readLE(at: "f64 (\(key))") + return .float64(Double(bitPattern: raw)) + case .bool: + let b: UInt8 = try cursor.readLE(at: "bool (\(key))") + return .bool(b != 0) + case .string: + return .string(try cursor.readString(at: "string (\(key))")) + case .array: + let elemTag: UInt32 = try cursor.readLE(at: "array-elem-type (\(key))") + guard let elemKind = GGUFValueType(rawValue: elemTag) else { + throw GGUFError.unknownValueType(elemTag, key: "\(key)[]") + } + let n: UInt64 = try cursor.readLE(at: "array-len (\(key))") + return .array(try readArrayElements(cursor: &cursor, kind: elemKind, count: n, key: key)) + } + } + + private static func readArrayElements( + cursor: inout GGUFCursor, kind: GGUFValueType, count: UInt64, key: String + ) throws -> GGUFArrayValue { + let n = Int(count) + switch kind { + case .uint8: + var out = [UInt8](); out.reserveCapacity(n) + for _ in 0.. [UInt8] { + guard offset + count <= data.count else { + throw GGUFError.truncated(at: where_) + } + let slice = data[offset..(at where_: String) throws -> T { + let bytes = MemoryLayout.size + guard offset + bytes <= data.count else { + throw GGUFError.truncated(at: where_) + } + var value: T = 0 + for i in 0.. String { + let length: UInt64 = try readLE(at: "\(where_) length") + let bytes = try readBytes(Int(length), at: where_) + guard let s = String(bytes: bytes, encoding: .utf8) else { + throw GGUFError.stringNotUTF8(at: where_) + } + return s + } +} diff --git a/Sources/FFAI/Loader/GGUF/GGUFTensorBundle.swift b/Sources/FFAI/Loader/GGUF/GGUFTensorBundle.swift new file mode 100644 index 00000000..1550f03e --- /dev/null +++ b/Sources/FFAI/Loader/GGUF/GGUFTensorBundle.swift @@ -0,0 +1,1292 @@ +// Copyright 2026 Tom Turney (@TheTom) +import QuartzCore +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// `GGUFTensorBundle` — adapter that exposes a single .gguf file (or a +// directory containing one) as a tensor namespace for the DeepSeek-V4 +// GGUF loader path. +// +// NOTE: this is a *parallel* loader for the DSv4 GGUF path, NOT a +// drop-in replacement for `SafeTensorsBundle`. It deliberately does +// not (yet) implement `SafeTensorsBundle`'s public surface +// (`has` / `allKeys` / `prefixed` / `withAddedPrefix` / +// `quantizedTriplet`), and the standard `Model.load` family dispatch +// only ever constructs `SafeTensorsBundle`. DSv4 reaches this bundle +// via the separate `DeepSeekV4Variant.loadModelFromGGUF` entry point. +// Unifying the two behind a shared `TensorBundle` protocol so the +// dispatcher can take either format is future work, deferred until the +// DSv4 forward path lands. +// +// **Status:** The reader (header + KV + tensor-info +// table) is fully implemented in `GGUFReader.swift` and works end-to- +// end on real DeepSeek-V4-Flash IQ2_XXS GGUFs. `tensor(named:)` decodes +// the on-disk bytes into the GPU-resident split that metaltile's GGUF +// dequant kernels expect (Q8_0 / Q2_K / IQ2_XXS) — for formats whose +// dequant kernels haven't landed yet (every i-quant other than +// IQ2_XXS, the FP4 / TQ / MXFP4 variants), it throws +// `GGUFError.unsupportedDequant` so the loader fails fast instead of +// returning garbage. + +import Foundation +import Metal +import Tokenizers + +/// A single GGUF file presented as a tensor namespace for the +/// DeepSeek-V4 GGUF loader path. NOT a drop-in `SafeTensorsBundle` +/// replacement — it exposes a GGUF-specific surface (staged / resident +/// expert-slice dequant) and is consumed only via +/// `DeepSeekV4Variant.loadModelFromGGUF`, not the standard family +/// dispatch. See the file-header note for the shared-protocol +/// unification that's deferred to future work. +/// Resident pre-split IQ2_XXS expert weights for one MoE tensor — the +/// full `[nExperts × nblkPerExpert]` split buffers, filled lazily per +/// expert on first route. Subsequent tokens routing the same expert pay +/// zero staging. macOS storage-mode-shared MTLBuffers commit physical +/// pages on first write, so only the touched experts cost memory. +/// Capacity (distinct experts) of a packed resident pool per tensor. +/// The full 256-expert split would be ~85 GB across the model and +/// thrash against the 84 GB mmap; the working set actually touched +/// during a decode is far smaller, so we pack only touched experts into +/// `RESIDENT_POOL_CAP` slots (≈15 GB total) keyed by expert id. +let RESIDENT_POOL_CAP = 64 + +public final class ResidentIQ2Split: @unchecked Sendable { + public let qs: MTLBuffer + public let d: MTLBuffer + public let slotmap: MTLBuffer // [nExperts] u32: expert id → packed slot (GPU-resident) + public let nBlocksPerExpert: Int + public let mOut: Int + public let kIn: Int + public var slotOf: [Int: Int] = [:] // expert id → packed slot + public var nextSlot: Int = 0 + init(qs: MTLBuffer, d: MTLBuffer, slotmap: MTLBuffer, nBlocksPerExpert: Int, mOut: Int, kIn: Int) { + self.qs = qs; self.d = d; self.slotmap = slotmap; self.nBlocksPerExpert = nBlocksPerExpert + self.mOut = mOut; self.kIn = kIn + } +} + +public final class ResidentQ2KSplit: @unchecked Sendable { + public let qs: MTLBuffer + public let scales: MTLBuffer + public let d: MTLBuffer + public let dmin: MTLBuffer + public let slotmap: MTLBuffer + public let nBlocksPerExpert: Int + public let mOut: Int + public let kIn: Int + public var slotOf: [Int: Int] = [:] + public var nextSlot: Int = 0 + init( + qs: MTLBuffer, scales: MTLBuffer, d: MTLBuffer, dmin: MTLBuffer, slotmap: MTLBuffer, + nBlocksPerExpert: Int, mOut: Int, kIn: Int + ) { + self.qs = qs; self.scales = scales; self.d = d; self.dmin = dmin; self.slotmap = slotmap + self.nBlocksPerExpert = nBlocksPerExpert; self.mOut = mOut; self.kIn = kIn + } +} + +/// Resident Q8_0 weight split (whole tensor) for `Ops.gemvQ8` — kept at +/// 1 byte/weight (qs int8 + per-block f32 scale) rather than expanded to +/// f16, halving the bandwidth of the dense attn/shexp projections that +/// dominate decode GPU time. +public final class ResidentQ8: @unchecked Sendable { + public let qs: MTLBuffer // [nBlocks * 8] u32 (32 int8/block) + public let d: MTLBuffer // [nBlocks] f32 + public let mOut: Int + public let kIn: Int + init(qs: MTLBuffer, d: MTLBuffer, mOut: Int, kIn: Int) { + self.qs = qs; self.d = d; self.mOut = mOut; self.kIn = kIn + } +} + +public final class GGUFTensorBundle: @unchecked Sendable { + public let directory: URL + public let reader: GGUFReader + nonisolated(unsafe) var iq2SplitCache: [String: ResidentIQ2Split] = [:] + nonisolated(unsafe) var q2kSplitCache: [String: ResidentQ2KSplit] = [:] + // PERSISTENT prefill pools (cap = nExperts), built once at first prefill + // and reused warm across chunks/layers — the slotOf map persists so an + // expert is repacked only once (no per-chunk re-read). ~70GB when full; + // single-copy (mmap pages MADV_FREE'd after repack). This is the + // resident-weights path (opt-in via FFAI_PREFILL_RESIDENT). + nonisolated(unsafe) var iq2PrefillCache: [String: ResidentIQ2Split] = [:] + nonisolated(unsafe) var q2kPrefillCache: [String: ResidentQ2KSplit] = [:] + nonisolated(unsafe) var q8Cache: [String: ResidentQ8] = [:] + // RAW bulk-gather pools (interleaved blocks, no deinterleave) for the + // view-u16 bgemm — reliable makeBuffer GPU memory, cheap bulk-memcpy fill. + struct RawGatherEntry { var buffer: MTLBuffer; var slotOf: [Int: Int]; var nextSlot: Int } + nonisolated(unsafe) var rawGatherCache: [String: RawGatherEntry] = [:] + let gatherCacheLock = NSLock() + + public init(directory: URL) throws { + self.directory = directory + // Locate the .gguf file inside the directory. Conventional + // single-file layout; sharded GGUF is rare in 2026 (the + // 4-bit DSv4 file is 86 GB and ships as a single blob). + let contents = try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil + ) + let ggufs = contents.filter { $0.pathExtension == "gguf" }.sorted { + $0.lastPathComponent < $1.lastPathComponent + } + guard let url = ggufs.first else { + throw GGUFError.missingMetadataKey("any .gguf file in \(directory.path)") + } + // If the user has both a main weights GGUF and a sibling MTP-only + // GGUF (DSv4 ships this way), prefer the larger one for the + // weights bundle; the MTP heads load separately via the same + // reader once that path is wired. + let preferred = + ggufs.max(by: { + let lhs = + (try? FileManager.default.attributesOfItem(atPath: $0.path))?[.size] + as? Int ?? 0 + let rhs = + (try? FileManager.default.attributesOfItem(atPath: $1.path))?[.size] + as? Int ?? 0 + return lhs < rhs + }) ?? url + self.reader = try GGUFReader(url: preferred) + } + + /// Single-file convenience init when the caller already knows the + /// GGUF URL exactly (tests, the MTP-side load, ...). + public init(url: URL) throws { + self.directory = url.deletingLastPathComponent() + self.reader = try GGUFReader(url: url) + } + + /// Materialize a tensor from the GGUF as a host-side `Tensor`. + /// Supported on-disk formats: F32 / F16 / BF16 (direct copy) and + /// Q8_0 / Q2_K / IQ2_XXS (GPU dequant via the metaltile + /// `ffai_gguf_dequant_*` kernels). Other quant types raise + /// `GGUFError.unsupportedDequant` — they land in follow-ups as + /// the kernel surface grows. + /// + /// - Parameters: + /// - named: tensor name from the GGUF tensor info table + /// - outDtype: target activation dtype for the returned tensor. + /// `nil` defaults to f32 for quantized inputs and the on-disk + /// dtype for float inputs. + /// - device: the device whose command queue handles the dequant + /// dispatch. Defaults to `.shared`. + /// - persistent: when `true`, quantized-dequant output lands in a + /// per-tensor-name scratch slot (stable across calls) instead of + /// the shape-keyed shared "full" pool. REQUIRED for any weight + /// kept resident past the call (e.g. the DSv4 shared-expert + /// gate/up/down): otherwise two same-shape dequants (gate + up) + /// resolve to the SAME pooled buffer and the second overwrites + /// the first — silently aliasing every layer's shared expert to + /// the last-loaded tensor. + public func tensor( + named: String, outDtype: DType? = nil, device: Device = .shared, + persistent: Bool = false + ) throws -> Tensor { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + let shape = info.dimensions.map { Int($0) } + // NOTE: `reader.rawBytes(named:)` is NOT called at the top of + // this function — that API uses `Data.subdata` which copies + // for slices ≥16 KB. For DSv4 expert tensors (~half a GB raw + // each, 3 per layer), pre-fetching the bytes here duplicates + // ~1.5 GB / layer into anonymous RAM. Each case below reads + // bytes lazily — the f16/f32/bf16 path uses `rawBytes` only + // because the data is small and immediately copied to an + // MTLBuffer; the quant paths use `withRawBytes` for zero-copy + // access into the mmap. + + switch info.type { + case .f32, .f16, .bf16: + let srcDtype: DType = info.type == .f32 ? .f32 : (info.type == .f16 ? .f16 : .bf16) + let dstDtype = outDtype ?? srcDtype + if srcDtype == dstDtype { + // Fast path — direct byte copy straight from the mmap + // into the MTLBuffer. Uses `withRawBytes` (zero-copy + // view of the mapped region) rather than `rawBytes` + // (which `subdata`-copies the whole tensor into an + // anonymous heap Data first). For token_embd / output + // (each ~1 GB f16) that second copy was both wasteful + // and crashed the release build at the function-return + // boundary (1 GB temp Data churn). + let buf = try reader.withRawBytes(named: named) { src -> MTLBuffer in + let b = device.makeBuffer(length: max(src.count, srcDtype.byteSize)) + if let base = src.baseAddress, src.count > 0 { + b.contents().copyMemory(from: base, byteCount: src.count) + } + return b + } + return Tensor(buffer: buf, offset: 0, shape: shape, dtype: srcDtype) + } + let raw = try reader.rawBytes(named: named) + // Cross-dtype convert path — go through f32, then narrow + // to the destination dtype. Tensors hit here are small + // (norms, sinks, biases) so CPU conversion is fine; the + // bulk weights live on the q8_0 / q2_K / iq2_xxs paths + // below where the dequant kernel handles the dtype + // narrowing on-GPU. + return try Self.convertHalfPrecisionTensor( + raw: raw, srcDtype: srcDtype, dstDtype: dstDtype, + shape: shape, device: device) + + case .q8_0: + return try dequantWholeTensor( + named: named, shape: shape, nValues: Int(info.numElements), + outDtype: outDtype, persistent: persistent, device: device + ) { raw, nValues, dtOut, cmd, out in + _ = GGUFDequant.dequantQ8_0( + rawBlocks: raw, nValues: nValues, outDtype: dtOut, + on: cmd, device: device, into: out) + } + + case .q2_K: + return try dequantWholeTensor( + named: named, shape: shape, nValues: Int(info.numElements), + outDtype: outDtype, persistent: persistent, device: device + ) { raw, nValues, dtOut, cmd, out in + _ = GGUFDequant.dequantQ2_K( + rawBlocks: raw, nValues: nValues, outDtype: dtOut, + on: cmd, device: device, into: out) + } + + case .iq2_xxs: + let (grid, signs) = GGUFDequant.iq2xxsTables(device: device) + return try dequantWholeTensor( + named: named, shape: shape, nValues: Int(info.numElements), + outDtype: outDtype, persistent: persistent, device: device + ) { raw, nValues, dtOut, cmd, out in + _ = GGUFDequant.dequantIQ2_XXS( + rawBlocks: raw, nValues: nValues, outDtype: dtOut, + gridTensor: grid, signsTensor: signs, + on: cmd, device: device, into: out) + } + + case .i32, .i64, .i8: + // Integer tables (e.g. DSv4 ffn_gate_tid2eid hash-routing + // table) — carried through verbatim, no dequant. Bytes copy + // straight from the mmap into an MTLBuffer; consumers read + // them host-side via `toArray(as:)`. + let srcDtype: DType = info.type == .i64 ? .i64 : (info.type == .i8 ? .i8 : .i32) + let buf = try reader.withRawBytes(named: named) { src -> MTLBuffer in + let b = device.makeBuffer(length: max(src.count, srcDtype.byteSize)) + if let base = src.baseAddress, src.count > 0 { + b.contents().copyMemory(from: base, byteCount: src.count) + } + return b + } + return Tensor(buffer: buf, offset: 0, shape: shape, dtype: srcDtype) + + default: + throw GGUFError.unsupportedDequant(info.type, tensor: named) + } + } + + /// Shared driver for the whole-tensor block-quant cases of + /// `tensor(named:)` (Q8_0 / Q2_K / IQ2_XXS). Allocates the pooled + /// dequant output, wraps the tensor's mmap bytes zero-copy, runs the + /// caller's dequant kernel into the output on a fresh cmd buffer, + /// commits + waits, and reshapes. Only the kernel call differs + /// between quant types, so it's the closure; everything else + /// (pooling, zero-copy, sync) is identical and lives here. + /// + /// `dequant` receives `(rawBlocks, nValues, outDtype, cmd, out)` and + /// must encode its kernel into `cmd` writing into `out` — it must not + /// commit (this driver owns the cmd lifecycle). + private func dequantWholeTensor( + named: String, shape: [Int], nValues: Int, + outDtype: DType?, persistent: Bool, device: Device, + dequant: ( + _ rawBlocks: Data, _ nValues: Int, _ outDtype: DType, + _ cmd: MTLCommandBuffer, _ out: Tensor + ) -> Void + ) throws -> Tensor { + let dtOut = outDtype ?? .f32 + let out = Self.pooledDequantOutput( + nValues: nValues, dtype: dtOut, device: device, + tagSuffix: persistent ? named : "full") + let cmd = device.makeCommandBuffer() + try reader.withRawBytes(named: named) { ptr in + let zeroCopy = Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + count: ptr.count, deallocator: .none) + dequant(zeroCopy, nValues, dtOut, cmd, out) + } + cmd.commit() + cmd.waitUntilCompleted() + return out.reshaped(to: shape) + } + + // ─── Per-expert MoE dequant (staged / gathered) ────────────────── + // + // Per-expert dequant of a DSv4 IQ2_XXS expert ([4096, 2048] slice + // of [4096, 2048, 256]) is ~2 MB raw read + ~16 MB dequanted output, + // vs. ~530 MB raw + ~4 GB output for the full tensor — + // `n_experts / top_k_per_token = 256 / 6 ≈ 43×` less work per token + // when only the selected experts need materialization. + + /// Staging result for a parallel CPU pre-pass over one expert + /// slice. The GPU encode step (`encodeStagedExpertSlice`) runs on + /// the main thread later, reading these buffer refs. + public struct StagedExpertSlice { + public let outBuf: MTLBuffer + public let qsBuf: MTLBuffer + public let dBuf: MTLBuffer + public let scBuf: MTLBuffer? + public let dminBuf: MTLBuffer? + public let nValuesPerExpert: Int + public let nBlocks: Int + public let outDtype: DType + public let outShape: [Int] + public let infoType: GGUFTensorType + } + + /// CPU-only staging: do the block-byte unpack into intermediate + /// MTLBuffers. Thread-safe across calls with distinct `slot` tags + /// (each tag → its own pooled buffer set). + public func stageExpertSlice( + named: String, expertIdx: Int, nExperts: Int, + slot: String, outDtype: DType? = nil, device: Device = .shared + ) throws -> StagedExpertSlice { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + let nValuesTotal = Int(info.numElements) + let nValuesPerExpert = nValuesTotal / nExperts + let byteStart = (Int(info.byteLength) / nExperts) * expertIdx + let byteLen = Int(info.byteLength) / nExperts + let dtOut = outDtype ?? .f32 + let outShape = info.dimensions.dropLast().map { Int($0) } + switch info.type { + case .iq2_xxs: + let nBlocks = nValuesPerExpert / GGUFDequant.iq2_xxsBlockValues + let qsBytes = nBlocks * 16 * 4 + let dBytes = nBlocks * 4 + let qsBuf = device.intermediateScratch(tag: "gguf_dequant_u32_\(slot)", minBytes: qsBytes) + let dBuf = device.intermediateScratch(tag: "gguf_dequant_f32_\(slot)", minBytes: dBytes) + let outBuf = device.intermediateScratch( + tag: "dequant_out_\(dtOut)_\(nValuesPerExpert)_expert_\(slot)", + minBytes: nValuesPerExpert * dtOut.byteSize) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt32.self) + let dPtr = dBuf.contents().assumingMemoryBound(to: Float.self) + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let base = ptr.baseAddress! + let qsU8 = qsPtr.withMemoryRebound(to: UInt8.self, capacity: nBlocks * 64) { $0 } + // Inner serial (no nested concurrentPerform — outer + // already parallel across calls). + for b in 0 ..< nBlocks { + let blockBase = base.advanced(by: b * GGUFDequant.iq2_xxsBlockBytes) + let dBits = blockBase.withMemoryRebound(to: UInt16.self, capacity: 1) { $0.pointee } + dPtr[b] = Float(Float16(bitPattern: dBits)) + memcpy(qsU8.advanced(by: b * 64), blockBase.advanced(by: 2), 64) + } + } + return StagedExpertSlice( + outBuf: outBuf, qsBuf: qsBuf, dBuf: dBuf, scBuf: nil, dminBuf: nil, + nValuesPerExpert: nValuesPerExpert, nBlocks: nBlocks, + outDtype: dtOut, outShape: outShape, infoType: .iq2_xxs) + case .q2_K: + let nBlocks = nValuesPerExpert / GGUFDequant.q2_KBlockValues + let qsBytes = nBlocks * 16 * 4 + let scBytes = nBlocks * 16 + let dBytes = nBlocks * 4 + let qsBuf = device.intermediateScratch(tag: "gguf_dequant_u32_\(slot)", minBytes: qsBytes) + let scBuf = device.intermediateScratch(tag: "gguf_dequant_u8_\(slot)", minBytes: scBytes) + let dBuf = device.intermediateScratch(tag: "gguf_dequant_f32_\(slot)", minBytes: dBytes) + let dminBuf = device.intermediateScratch(tag: "gguf_dequant_f32_dmin_\(slot)", minBytes: dBytes) + let outBuf = device.intermediateScratch( + tag: "dequant_out_\(dtOut)_\(nValuesPerExpert)_expert_\(slot)", + minBytes: nValuesPerExpert * dtOut.byteSize) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt32.self) + let scPtr = scBuf.contents().assumingMemoryBound(to: UInt8.self) + let dPtr = dBuf.contents().assumingMemoryBound(to: Float.self) + let dminPtr = dminBuf.contents().assumingMemoryBound(to: Float.self) + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let base = ptr.baseAddress! + let qsU8 = qsPtr.withMemoryRebound(to: UInt8.self, capacity: nBlocks * 64) { $0 } + for b in 0 ..< nBlocks { + let blockBase = base.advanced(by: b * GGUFDequant.q2_KBlockBytes) + memcpy(scPtr.advanced(by: b * 16), blockBase, 16) + memcpy(qsU8.advanced(by: b * 64), blockBase.advanced(by: 16), 64) + let dBits = blockBase.advanced(by: 80).withMemoryRebound(to: UInt16.self, capacity: 1) { + $0.pointee + } + let dminBits = blockBase.advanced(by: 82).withMemoryRebound(to: UInt16.self, capacity: 1) { + $0.pointee + } + dPtr[b] = Float(Float16(bitPattern: dBits)) + dminPtr[b] = Float(Float16(bitPattern: dminBits)) + } + } + return StagedExpertSlice( + outBuf: outBuf, qsBuf: qsBuf, dBuf: dBuf, scBuf: scBuf, dminBuf: dminBuf, + nValuesPerExpert: nValuesPerExpert, nBlocks: nBlocks, + outDtype: dtOut, outShape: outShape, infoType: .q2_K) + default: + throw GGUFError.unsupportedDequant(info.type, tensor: named) + } + } + + /// Result of staging N routed IQ2_XXS experts into contiguous + /// slot-major split buffers for `Ops.moeGatherGemvIQ2XXS`. + public struct GatheredIQ2XXS { + public let qsAll: Tensor // [nSlots * nblkPerExpert * 16] u32 + public let dAll: Tensor // [nSlots * nblkPerExpert] f32 + public let nSlots: Int + public let mOut: Int // output rows per expert + public let kIn: Int // input dim + } + + /// CPU staging for the fused 6-expert IQ2_XXS gather GEMV. Reads the + /// quant bytes for each routed expert straight from the (resident) + /// mmap and lays the split (qs_u32 / d_f32) format down slot-major in + /// ONE pair of pooled buffers — so the whole role (gate or up) is a + /// single `moe_gather_gemv_iq2xxs` dispatch instead of 6×{dequant,gemv}. + public func stageGatherIQ2XXS( + named: String, expertIndices: [Int], nExperts: Int, + slot: String, device: Device = .shared + ) throws -> GatheredIQ2XXS { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + precondition(info.type == .iq2_xxs, "stageGatherIQ2XXS: \(named) is \(info.type)") + let nSlots = expertIndices.count + let nValuesPerExpert = Int(info.numElements) / nExperts + let nBlocksPerExpert = nValuesPerExpert / GGUFDequant.iq2_xxsBlockValues + // outShape = [m_out, k_in] (n_experts dim already dropped). + let dims = info.dimensions.map { Int($0) } // [k_in, m_out, n_experts] fast-first + let kIn = dims[0] + let mOut = dims[1] + let byteLenPerExpert = Int(info.byteLength) / nExperts + + let qsBytes = nSlots * nBlocksPerExpert * 16 * 4 + let dBytes = nSlots * nBlocksPerExpert * 4 + let qsBuf = device.intermediateScratch(tag: "gather_iq2_qs_\(slot)", minBytes: qsBytes) + let dBuf = device.intermediateScratch(tag: "gather_iq2_d_\(slot)", minBytes: dBytes) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt32.self) + let dPtr = dBuf.contents().assumingMemoryBound(to: Float.self) + let qsU8 = qsPtr.withMemoryRebound( + to: UInt8.self, capacity: nSlots * nBlocksPerExpert * 64 + ) { $0 } + + for (s, e) in expertIndices.enumerated() { + let byteStart = byteLenPerExpert * e + let qsSlotBlock = s * nBlocksPerExpert // first block index for this slot + try reader.withRawBytesSlice( + named: named, byteStart: byteStart, byteLength: byteLenPerExpert + ) { ptr in + let base = ptr.baseAddress! + // Parallel block-split: distinct dst ranges per block → + // thread-safe. The qs memcpy is the dominant decode-time + // CPU cost, so fan it across P-cores. + let blkBytes = GGUFDequant.iq2_xxsBlockBytes + let chunks = 16 + let per = (nBlocksPerExpert + chunks - 1) / chunks + DispatchQueue.concurrentPerform(iterations: chunks) { c in + let lo = c * per + let hi = min(lo + per, nBlocksPerExpert) + var b = lo + while b < hi { + let blockBase = base.advanced(by: b * blkBytes) + let dBits = blockBase.withMemoryRebound(to: UInt16.self, capacity: 1) { $0.pointee } + dPtr[qsSlotBlock + b] = Float(Float16(bitPattern: dBits)) + memcpy(qsU8.advanced(by: (qsSlotBlock + b) * 64), blockBase.advanced(by: 2), 64) + b += 1 + } + } + } + } + let qsAll = Tensor(buffer: qsBuf, offset: 0, shape: [nSlots * nBlocksPerExpert * 16], dtype: .u32) + let dAll = Tensor(buffer: dBuf, offset: 0, shape: [nSlots * nBlocksPerExpert], dtype: .f32) + return GatheredIQ2XXS(qsAll: qsAll, dAll: dAll, nSlots: nSlots, mOut: mOut, kIn: kIn) + } + + /// Result of staging N routed Q2_K experts into contiguous + /// slot-major split buffers for `Ops.moeGatherDownQ2K`. + public struct GatheredQ2K { + public let qsAll: Tensor // [nSlots * nblkPerExpert * 16] u32 + public let scalesAll: Tensor // [nSlots * nblkPerExpert * 16] u8 + public let dAll: Tensor // [nSlots * nblkPerExpert] f32 + public let dminAll: Tensor // [nSlots * nblkPerExpert] f32 + public let nSlots: Int + public let mOut: Int + public let kIn: Int + } + + /// CPU staging for the fused 6-expert Q2_K gather down-projection. + /// Lays the split (qs_u32 / scales_u8 / d_f32 / dmin_f32) format down + /// slot-major in one set of pooled buffers. + public func stageGatherQ2K( + named: String, expertIndices: [Int], nExperts: Int, + slot: String, device: Device = .shared + ) throws -> GatheredQ2K { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + precondition(info.type == .q2_K, "stageGatherQ2K: \(named) is \(info.type)") + let nSlots = expertIndices.count + let nValuesPerExpert = Int(info.numElements) / nExperts + let nBlocksPerExpert = nValuesPerExpert / GGUFDequant.q2_KBlockValues + let dims = info.dimensions.map { Int($0) } // [k_in, m_out, n_experts] + let kIn = dims[0] + let mOut = dims[1] + let byteLenPerExpert = Int(info.byteLength) / nExperts + + let qsBuf = device.intermediateScratch( + tag: "gather_q2k_qs_\(slot)", minBytes: nSlots * nBlocksPerExpert * 16 * 4) + let scBuf = device.intermediateScratch(tag: "gather_q2k_sc_\(slot)", minBytes: nSlots * nBlocksPerExpert * 16) + let dBuf = device.intermediateScratch(tag: "gather_q2k_d_\(slot)", minBytes: nSlots * nBlocksPerExpert * 4) + let dminBuf = device.intermediateScratch( + tag: "gather_q2k_dmin_\(slot)", minBytes: nSlots * nBlocksPerExpert * 4) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt32.self) + let scPtr = scBuf.contents().assumingMemoryBound(to: UInt8.self) + let dPtr = dBuf.contents().assumingMemoryBound(to: Float.self) + let dminPtr = dminBuf.contents().assumingMemoryBound(to: Float.self) + let qsU8 = qsPtr.withMemoryRebound(to: UInt8.self, capacity: nSlots * nBlocksPerExpert * 64) { $0 } + + for (s, e) in expertIndices.enumerated() { + let byteStart = byteLenPerExpert * e + let slotBlock = s * nBlocksPerExpert + try reader.withRawBytesSlice( + named: named, byteStart: byteStart, byteLength: byteLenPerExpert + ) { ptr in + let base = ptr.baseAddress! + let blkBytes = GGUFDequant.q2_KBlockBytes + let chunks = 16 + let per = (nBlocksPerExpert + chunks - 1) / chunks + DispatchQueue.concurrentPerform(iterations: chunks) { c in + let lo = c * per + let hi = min(lo + per, nBlocksPerExpert) + var b = lo + while b < hi { + let blockBase = base.advanced(by: b * blkBytes) + memcpy(scPtr.advanced(by: (slotBlock + b) * 16), blockBase, 16) + memcpy(qsU8.advanced(by: (slotBlock + b) * 64), blockBase.advanced(by: 16), 64) + let dBits = blockBase.advanced(by: 80).withMemoryRebound(to: UInt16.self, capacity: 1) { + $0.pointee + } + let dminBits = blockBase.advanced(by: 82).withMemoryRebound(to: UInt16.self, capacity: 1) { + $0.pointee + } + dPtr[slotBlock + b] = Float(Float16(bitPattern: dBits)) + dminPtr[slotBlock + b] = Float(Float16(bitPattern: dminBits)) + b += 1 + } + } + } + } + return GatheredQ2K( + qsAll: Tensor(buffer: qsBuf, offset: 0, shape: [nSlots * nBlocksPerExpert * 16], dtype: .u32), + scalesAll: Tensor(buffer: scBuf, offset: 0, shape: [nSlots * nBlocksPerExpert * 16], dtype: .u8), + dAll: Tensor(buffer: dBuf, offset: 0, shape: [nSlots * nBlocksPerExpert], dtype: .f32), + dminAll: Tensor(buffer: dminBuf, offset: 0, shape: [nSlots * nBlocksPerExpert], dtype: .f32), + nSlots: nSlots, mOut: mOut, kIn: kIn) + } + + /// Resident lazy-fill IQ2_XXS gather: returns the full + /// `[nExperts × nblk]` split buffers for `named`, ensuring the + /// requested `expertIndices` are filled. Experts fill once and are + /// reused across tokens — eliminating per-token staging for the + /// touched working set. Returns the buffers + dims; the caller + /// passes `expertIndices` to the kernel as `expert_ids`. + public func residentGatherIQ2XXS( + named: String, expertIndices: [Int], nExperts: Int, device: Device = .shared, + poolCap: Int? = nil, persist: Bool = false + ) throws -> (split: ResidentIQ2Split, qsAll: Tensor, dAll: Tensor, slots: [Int])? { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + let nValuesPerExpert = Int(info.numElements) / nExperts + let nBlocksPerExpert = nValuesPerExpert / GGUFDequant.iq2_xxsBlockValues + let dims = info.dimensions.map { Int($0) } + let byteLenPerExpert = Int(info.byteLength) / nExperts + // `poolCap` (prefill): use a FRESH, uncached pool sized to fit all + // requested experts — the cached RESIDENT_POOL_CAP=64 pool can't hold + // the >64 distinct experts a large prefill chunk routes to. Allocated + // per call, freed after the layer (not stored in the cache). + let effCap = poolCap ?? RESIDENT_POOL_CAP + + gatherCacheLock.lock() + let s: ResidentIQ2Split + if persist { + // PERSISTENT prefill pool: build once at effCap, reuse warm across + // chunks (slotOf persists → each expert repacked only once). + var split = iq2PrefillCache[named] + if split == nil { + split = ResidentIQ2Split( + qs: device.makeBuffer(length: effCap * nBlocksPerExpert * 16 * 4), + d: device.makeBuffer(length: effCap * nBlocksPerExpert * 4), + slotmap: device.makeBuffer(length: nExperts * 4), + nBlocksPerExpert: nBlocksPerExpert, mOut: dims[1], kIn: dims[0]) + memset(split!.slotmap.contents(), 0, nExperts * 4) + iq2PrefillCache[named] = split + } + s = split! + } else if poolCap != nil { + s = ResidentIQ2Split( + qs: device.makeBuffer(length: effCap * nBlocksPerExpert * 16 * 4), + d: device.makeBuffer(length: effCap * nBlocksPerExpert * 4), + slotmap: device.makeBuffer(length: nExperts * 4), + nBlocksPerExpert: nBlocksPerExpert, mOut: dims[1], kIn: dims[0]) + memset(s.slotmap.contents(), 0, nExperts * 4) + } else { + var split = iq2SplitCache[named] + if split == nil { + split = ResidentIQ2Split( + qs: device.makeBuffer(length: RESIDENT_POOL_CAP * nBlocksPerExpert * 16 * 4), + d: device.makeBuffer(length: RESIDENT_POOL_CAP * nBlocksPerExpert * 4), + slotmap: device.makeBuffer(length: nExperts * 4), + nBlocksPerExpert: nBlocksPerExpert, mOut: dims[1], kIn: dims[0]) + memset(split!.slotmap.contents(), 0, nExperts * 4) // misses → in-bounds slot 0 + iq2SplitCache[named] = split + } + s = split! + } + let slotmapPtr = s.slotmap.contents().assumingMemoryBound(to: UInt32.self) + // Resolve packed slots; signal a fall-back if the pool is full + // and a new expert appears (caller uses the staging path). + var slots: [Int] = [] + slots.reserveCapacity(expertIndices.count) + var toFill: [(slot: Int, expert: Int)] = [] + for e in expertIndices { + if let sl = s.slotOf[e] { slots.append(sl); continue } + guard s.nextSlot < effCap else { gatherCacheLock.unlock(); return nil } + let sl = s.nextSlot; s.nextSlot += 1; s.slotOf[e] = sl + slotmapPtr[e] = UInt32(sl) // mirror to GPU slotmap for the sync-free path + slots.append(sl); toFill.append((sl, e)) + } + gatherCacheLock.unlock() + + let qsPtr = s.qs.contents().assumingMemoryBound(to: UInt32.self) + // `nonisolated(unsafe)`: these pool pointers are captured by the + // `concurrentPerform` @Sendable closure below. Each iteration writes a + // disjoint slot range (base0 = slot * nBlocksPerExpert), so the writes + // never alias — the captures are data-race-free. The modifier asserts + // that to the compiler (Swift 6.1's region analysis can't prove it; 6.3 + // can, so this is a no-op there). + nonisolated(unsafe) let dPtr = s.d.contents().assumingMemoryBound(to: Float.self) + nonisolated(unsafe) let qsU8 = qsPtr.withMemoryRebound(to: UInt8.self, capacity: effCap * nBlocksPerExpert * 64) { $0 } + let blkBytes = GGUFDequant.iq2_xxsBlockBytes + // Parallel OVER experts (not blocks-within-expert): each expert's + // ~32k cold mmap blocks page-fault off the 86GB file; issuing many + // experts' faults concurrently gives the NVMe real queue depth + // (serial per-expert faults left the SSD idle between experts). + DispatchQueue.concurrentPerform(iterations: toFill.count) { fi in + let (slot, e) = toFill[fi] + let byteStart = byteLenPerExpert * e + let base0 = slot * nBlocksPerExpert + try? reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLenPerExpert) { ptr in + let base = ptr.baseAddress! + var b = 0 + while b < nBlocksPerExpert { + let blockBase = base.advanced(by: b * blkBytes) + let dBits = blockBase.withMemoryRebound(to: UInt16.self, capacity: 1) { $0.pointee } + dPtr[base0 + b] = Float(Float16(bitPattern: dBits)) + memcpy(qsU8.advanced(by: (base0 + b) * 64), blockBase.advanced(by: 2), 64) + b += 1 + } + } + } + let qsAll = Tensor(buffer: s.qs, offset: 0, shape: [effCap * nBlocksPerExpert * 16], dtype: .u32) + let dAll = Tensor(buffer: s.d, offset: 0, shape: [effCap * nBlocksPerExpert], dtype: .f32) + return (s, qsAll, dAll, slots) + } + + /// Resident lazy-fill Q2_K gather (down role). See `residentGatherIQ2XXS`. + public func residentGatherQ2K( + named: String, expertIndices: [Int], nExperts: Int, device: Device = .shared, + poolCap: Int? = nil, persist: Bool = false + ) throws -> ( + split: ResidentQ2KSplit, qsAll: Tensor, scalesAll: Tensor, dAll: Tensor, dminAll: Tensor, slots: [Int] + )? { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + let nValuesPerExpert = Int(info.numElements) / nExperts + let nBlocksPerExpert = nValuesPerExpert / GGUFDequant.q2_KBlockValues + let dims = info.dimensions.map { Int($0) } + let byteLenPerExpert = Int(info.byteLength) / nExperts + let effCap = poolCap ?? RESIDENT_POOL_CAP // prefill: fresh uncached pool sized to fit all experts + + gatherCacheLock.lock() + let s: ResidentQ2KSplit + if persist { + var split = q2kPrefillCache[named] + if split == nil { + split = ResidentQ2KSplit( + qs: device.makeBuffer(length: effCap * nBlocksPerExpert * 16 * 4), + scales: device.makeBuffer(length: effCap * nBlocksPerExpert * 16), + d: device.makeBuffer(length: effCap * nBlocksPerExpert * 4), + dmin: device.makeBuffer(length: effCap * nBlocksPerExpert * 4), + slotmap: device.makeBuffer(length: nExperts * 4), + nBlocksPerExpert: nBlocksPerExpert, mOut: dims[1], kIn: dims[0]) + memset(split!.slotmap.contents(), 0, nExperts * 4) + q2kPrefillCache[named] = split + } + s = split! + } else if poolCap != nil { + s = ResidentQ2KSplit( + qs: device.makeBuffer(length: effCap * nBlocksPerExpert * 16 * 4), + scales: device.makeBuffer(length: effCap * nBlocksPerExpert * 16), + d: device.makeBuffer(length: effCap * nBlocksPerExpert * 4), + dmin: device.makeBuffer(length: effCap * nBlocksPerExpert * 4), + slotmap: device.makeBuffer(length: nExperts * 4), + nBlocksPerExpert: nBlocksPerExpert, mOut: dims[1], kIn: dims[0]) + memset(s.slotmap.contents(), 0, nExperts * 4) + } else { + var split = q2kSplitCache[named] + if split == nil { + split = ResidentQ2KSplit( + qs: device.makeBuffer(length: RESIDENT_POOL_CAP * nBlocksPerExpert * 16 * 4), + scales: device.makeBuffer(length: RESIDENT_POOL_CAP * nBlocksPerExpert * 16), + d: device.makeBuffer(length: RESIDENT_POOL_CAP * nBlocksPerExpert * 4), + dmin: device.makeBuffer(length: RESIDENT_POOL_CAP * nBlocksPerExpert * 4), + slotmap: device.makeBuffer(length: nExperts * 4), + nBlocksPerExpert: nBlocksPerExpert, mOut: dims[1], kIn: dims[0]) + memset(split!.slotmap.contents(), 0, nExperts * 4) + q2kSplitCache[named] = split + } + s = split! + } + let slotmapPtr = s.slotmap.contents().assumingMemoryBound(to: UInt32.self) + var slots: [Int] = [] + var toFill: [(slot: Int, expert: Int)] = [] + for e in expertIndices { + if let sl = s.slotOf[e] { slots.append(sl); continue } + guard s.nextSlot < effCap else { gatherCacheLock.unlock(); return nil } + let sl = s.nextSlot; s.nextSlot += 1; s.slotOf[e] = sl + slotmapPtr[e] = UInt32(sl) + slots.append(sl); toFill.append((sl, e)) + } + gatherCacheLock.unlock() + + let qsPtr = s.qs.contents().assumingMemoryBound(to: UInt32.self) + // `nonisolated(unsafe)`: disjoint-slot writes captured by the + // `concurrentPerform` @Sendable closure — race-free (see the IQ2_XXS + // sibling above for the full rationale). + nonisolated(unsafe) let scPtr = s.scales.contents().assumingMemoryBound(to: UInt8.self) + nonisolated(unsafe) let dPtr = s.d.contents().assumingMemoryBound(to: Float.self) + nonisolated(unsafe) let dminPtr = s.dmin.contents().assumingMemoryBound(to: Float.self) + nonisolated(unsafe) let qsU8 = qsPtr.withMemoryRebound(to: UInt8.self, capacity: effCap * nBlocksPerExpert * 64) { $0 } + let blkBytes = GGUFDequant.q2_KBlockBytes + // Parallel OVER experts — see residentGatherIQ2XXS rationale (NVMe + // queue depth for the cold mmap page-faults). + DispatchQueue.concurrentPerform(iterations: toFill.count) { fi in + let (slot, e) = toFill[fi] + let byteStart = byteLenPerExpert * e + let base0 = slot * nBlocksPerExpert + try? reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLenPerExpert) { ptr in + let base = ptr.baseAddress! + var b = 0 + while b < nBlocksPerExpert { + let blockBase = base.advanced(by: b * blkBytes) + memcpy(scPtr.advanced(by: (base0 + b) * 16), blockBase, 16) + memcpy(qsU8.advanced(by: (base0 + b) * 64), blockBase.advanced(by: 16), 64) + let dBits = blockBase.advanced(by: 80).withMemoryRebound(to: UInt16.self, capacity: 1) { + $0.pointee + } + let dminBits = blockBase.advanced(by: 82).withMemoryRebound(to: UInt16.self, capacity: 1) { + $0.pointee + } + dPtr[base0 + b] = Float(Float16(bitPattern: dBits)) + dminPtr[base0 + b] = Float(Float16(bitPattern: dminBits)) + b += 1 + } + } + } + return ( + s, + Tensor(buffer: s.qs, offset: 0, shape: [effCap * nBlocksPerExpert * 16], dtype: .u32), + Tensor(buffer: s.scales, offset: 0, shape: [effCap * nBlocksPerExpert * 16], dtype: .u8), + Tensor(buffer: s.d, offset: 0, shape: [effCap * nBlocksPerExpert], dtype: .f32), + Tensor(buffer: s.dmin, offset: 0, shape: [effCap * nBlocksPerExpert], dtype: .f32), + slots + ) + } + + /// Resident Q8_0 split for a whole weight tensor (cached). Built once + /// on first use; the dense attn/shexp projections then gemv directly + /// from 1-byte Q8 instead of 2-byte f16. + public func residentQ8(_ named: String, device: Device = .shared) throws -> ResidentQ8 { + gatherCacheLock.lock() + if let c = q8Cache[named] { gatherCacheLock.unlock(); return c } + gatherCacheLock.unlock() + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + precondition(info.type == .q8_0, "residentQ8: \(named) is \(info.type)") + let nValues = Int(info.numElements) + let nBlocks = nValues / GGUFDequant.q8_0BlockValues + let dims = info.dimensions.map { Int($0) } // [n_in, n_out] + let qsBuf = device.makeBuffer(length: nBlocks * 8 * 4) + let dBuf = device.makeBuffer(length: nBlocks * 4) + let qsPtr = qsBuf.contents().assumingMemoryBound(to: UInt32.self) + let dPtr = dBuf.contents().assumingMemoryBound(to: Float.self) + let qsI8 = qsPtr.withMemoryRebound(to: Int8.self, capacity: nBlocks * 32) { $0 } + let blk = GGUFDequant.q8_0BlockBytes + try reader.withRawBytes(named: named) { ptr in + let base = ptr.baseAddress! + let chunks = 16 + let per = (nBlocks + chunks - 1) / chunks + DispatchQueue.concurrentPerform(iterations: chunks) { c in + let lo = c * per, hi = min(lo + per, nBlocks) + var b = lo + while b < hi { + let bb = base.advanced(by: b * blk) + let dBits = bb.withMemoryRebound(to: UInt16.self, capacity: 1) { $0.pointee } + dPtr[b] = Float(Float16(bitPattern: dBits)) + memcpy(qsI8.advanced(by: b * 32), bb.advanced(by: 2), 32) + b += 1 + } + } + } + let q8 = ResidentQ8(qs: qsBuf, d: dBuf, mOut: dims[1], kIn: dims[0]) + gatherCacheLock.lock(); q8Cache[named] = q8; gatherCacheLock.unlock() + return q8 + } + + /// Fast-path accessors: the already-built resident pool for a + /// tensor (nil if the warmup pass hasn't created it yet). The + /// sync-free GPU router path reads these without filling. + public func builtIQ2(_ named: String) -> ResidentIQ2Split? { + gatherCacheLock.lock(); defer { gatherCacheLock.unlock() } + return iq2SplitCache[named] + } + + // ── Zero-copy GPU weight views (overlapping no-copy mmap views) ── + // Lets kernels read raw quant bytes straight from the mmap by + // (buffer, offset) — no CPU repack into a pool. Built once, lazily. + private var _modelViews: GGUFModelViews?? // outer optional = "tried", inner = result + private let modelViewsLock = NSLock() + + /// `(MTLBuffer, byteOffset)` for a tensor's raw bytes, residing in an + /// overlapping no-copy mmap view. `nil` if views can't be built. + public func gpuTensorView(named: String, device: Device = .shared) -> (buffer: MTLBuffer, offset: Int)? { + modelViewsLock.lock() + if _modelViews == nil { + let maxTensor = reader.tensorInfos.map { $0.byteLength }.max() ?? 0 + if let base = reader.mmapBase { + let mv = GGUFModelViews( + mmapBase: base, fileSize: reader.mmapByteCount, + dataStart: Int(reader.tensorDataOffset), + maxTensorBytes: maxTensor, device: device) + // Register the no-copy windows with the device residency set. + // The buffers wrap read-only mmap pages; the CPU faults them + // in on access, but the GPU can only read pages Metal has made + // resident. The mmap views must be pinned via an MTLResidency + // Set for exactly this reason — without it the kernel reads + // unfaulted pages → wrong weights (the FFAI_PREFILL_VIEW gateP + // divergence). markWeightsResident is a no-op pre-macOS-15. + if let mv { device.markWeightsResident(mv.views.map { $0.buffer }) } + _modelViews = mv + } else { + _modelViews = .some(nil) // not a contiguous mmap; no view path + } + } + let mv = _modelViews ?? nil + modelViewsLock.unlock() + guard let mv = mv, let idx = reader.tensorIndex[named] else { return nil } + let info = reader.tensorInfos[idx] + let absStart = Int(reader.tensorDataOffset + info.dataOffset) + return mv.view(absStart: absStart, length: info.byteLength) + } + + /// Per-expert block count + byte stride for an MoE expert tensor, straight + /// from metadata (NO pool build). IQ2_XXS and Q2_K both pack 256 values + /// per block; byteLenPerExpert is the kernel's `expert_byte_stride` (the + /// GGUF expert tensor is [n_experts, n_out, n_in] contiguous). Used by the + /// zero-copy view bgemm path so it never repacks/MADV_FREEs experts. + public func expertViewInfo(named: String, nExperts: Int) -> (nBlocksPerExpert: Int, byteLenPerExpert: Int)? { + guard let idx = reader.tensorIndex[named] else { return nil } + let info = reader.tensorInfos[idx] + let nBlocksPerExpert = (Int(info.numElements) / nExperts) / 256 + let byteLenPerExpert = Int(info.byteLength) / nExperts + return (nBlocksPerExpert, byteLenPerExpert) + } + public func builtQ2K(_ named: String) -> ResidentQ2KSplit? { + gatherCacheLock.lock(); defer { gatherCacheLock.unlock() } + return q2kSplitCache[named] + } + + /// Fire-and-forget async readahead of a tensor's mmap pages — see + /// `GGUFReader.prefetchTensor`. Used to overlap the cold expert-weight + /// disk I/O with the previous layer's GPU compute (madvise WILLNEED, no + /// memcpy → no unified-memory bandwidth contention). + public func prefetchTensor(named: String) { reader.prefetchTensor(named: named) } + + /// RAW bulk gather: copy each routed expert's RAW bytes (interleaved + /// blocks, no deinterleave) CONTIGUOUSLY into a reliable makeBuffer pool — + /// ONE bulk memcpy/expert vs residentGatherIQ2XXS's per-block deinterleave + /// (32768 tiny memcpys/expert). The view-u16 bgemm reads this directly + /// (slot-indexed, stride = byteLenPerExpert). Reliable GPU memory (avoids + /// the mmap-residency-zeros bug) + a much cheaper repack. Caches per-name, + /// pool sized to poolCap. Returns (buffer, slotOf, nBlocksPerExpert, stride). + /// `reuseKey` (e.g. "gate"/"up"): cache the gather buffer under a STABLE + /// per-role key instead of the layer-specific tensor name, and REFILL it + /// each call. In a 43-layer prefill the per-name cache would retain 43× + /// buffers (~22 GB, never freed = the memory-pressure/freeze bomb); reusing + /// one buffer per role keeps it at ~2 buffers. Safe because the caller + /// commits+waits the layer's command buffer before the next layer refills. + public func rawGatherBlocks( + named: String, expertIndices: [Int], nExperts: Int, device: Device = .shared, poolCap: Int, + reuseKey: String? = nil + ) throws -> (buffer: MTLBuffer, slotOf: [Int: Int], nBlocksPerExpert: Int, byteStride: Int)? { + guard let idx = reader.tensorIndex[named] else { return nil } + let info = reader.tensorInfos[idx] + let byteLenPerExpert = Int(info.byteLength) / nExperts + let nBlocksPerExpert = (Int(info.numElements) / nExperts) / 256 + let cacheKey = reuseKey ?? named + gatherCacheLock.lock() + var ent = rawGatherCache[cacheKey] + // (Re)allocate when missing or when a prior buffer is too small for this + // layer's poolCap. With reuseKey we RESET slotOf/nextSlot every call so + // the single buffer is refilled with THIS layer's experts. + let needBytes = poolCap * byteLenPerExpert + if ent == nil || ent!.buffer.length < needBytes { + ent = RawGatherEntry(buffer: device.makeBuffer(length: needBytes), slotOf: [:], nextSlot: 0) + } else if reuseKey != nil { + ent!.slotOf = [:]; ent!.nextSlot = 0 + } + var slotOf = ent!.slotOf + var nextSlot = ent!.nextSlot + var toFill: [(slot: Int, expert: Int)] = [] + for e in expertIndices { + if slotOf[e] != nil { continue } + guard nextSlot < poolCap else { gatherCacheLock.unlock(); return nil } + slotOf[e] = nextSlot; toFill.append((nextSlot, e)); nextSlot += 1 + } + ent!.slotOf = slotOf; ent!.nextSlot = nextSlot + rawGatherCache[cacheKey] = ent + let buf = ent!.buffer + gatherCacheLock.unlock() + let dst = buf.contents() + // CONCURRENT memcpy from ONE whole-tensor map. withRawBytes maps the + // tensor once and MADV_FREEs it ONCE at the end (single-threaded) — so + // the per-thread memcpys race nothing (disjoint read-only src regions, + // disjoint dst regions). At large N (~all experts) this copies the whole + // tensor; sequential single-thread was ~110ms/tensor, concurrent is far + // faster. (Per-slice withRawBytesSlice + concurrentPerform was unsafe: + // each call MADV_FREEs its page-rounded range, evicting neighbors.) + try reader.withRawBytes(named: named) { ptr in + let src = ptr.baseAddress! + DispatchQueue.concurrentPerform(iterations: toFill.count) { fi in + let (slot, e) = toFill[fi] + memcpy( + dst.advanced(by: slot * byteLenPerExpert), + src.advanced(by: byteLenPerExpert * e), byteLenPerExpert) + } + } + return (buf, slotOf, nBlocksPerExpert, byteLenPerExpert) + } + + /// Encode the GPU dequant kernel for a pre-staged slice. Must be + /// called from the main thread (Metal encoder is not thread-safe). + public func encodeStagedExpertSlice(_ s: StagedExpertSlice, device: Device = .shared, on cmd: MTLCommandBuffer) + -> Tensor + { + let out = Tensor(buffer: s.outBuf, offset: 0, shape: [s.nValuesPerExpert], dtype: s.outDtype) + switch s.infoType { + case .iq2_xxs: + let (grid, signs) = GGUFDequant.iq2xxsTables(device: device) + let qsTensor = Tensor(buffer: s.qsBuf, offset: 0, shape: [s.nBlocks * 16], dtype: .u32) + let dTensor = Tensor(buffer: s.dBuf, offset: 0, shape: [s.nBlocks], dtype: .f32) + _ = Ops.ggufDequantIQ2_XXS( + qsU32: qsTensor, dF32: dTensor, + grid: grid, signs: signs, + nValues: s.nValuesPerExpert, outDtype: s.outDtype, + on: cmd, into: out) + case .q2_K: + let qsTensor = Tensor(buffer: s.qsBuf, offset: 0, shape: [s.nBlocks * 16], dtype: .u32) + let scalesTensor = Tensor(buffer: s.scBuf!, offset: 0, shape: [s.nBlocks * 16], dtype: .u8) + let dTensor = Tensor(buffer: s.dBuf, offset: 0, shape: [s.nBlocks], dtype: .f32) + let dminTensor = Tensor(buffer: s.dminBuf!, offset: 0, shape: [s.nBlocks], dtype: .f32) + _ = Ops.ggufDequantQ2_K( + qsPacked: qsTensor, scales: scalesTensor, + dF32: dTensor, dminF32: dminTensor, + nValues: s.nValuesPerExpert, outDtype: s.outDtype, + on: cmd, into: out) + default: + fatalError("encodeStagedExpertSlice: unsupported type \(s.infoType)") + } + return out.reshaped(to: s.outShape) + } + + public func dequantExpertSliceOnto( + named: String, expertIdx: Int, nExperts: Int, + slot: String, + outDtype: DType? = nil, device: Device = .shared, + on cmd: MTLCommandBuffer + ) throws -> Tensor { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + let nValuesTotal = Int(info.numElements) + let nValuesPerExpert = nValuesTotal / nExperts + let byteStart = (Int(info.byteLength) / nExperts) * expertIdx + let byteLen = Int(info.byteLength) / nExperts + let dtOut = outDtype ?? .f32 + let outShape = info.dimensions.dropLast().map { Int($0) } + GGUFTensorBundle.profSliceType[String(describing: info.type), default: 0] += 1 + switch info.type { + case .q8_0: + let _tq = CACurrentMediaTime() + let out = Self.pooledDequantOutput( + nValues: nValuesPerExpert, dtype: dtOut, device: device, + tagSuffix: "expert_\(slot)") + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let zeroCopy = Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + count: ptr.count, deallocator: .none) + _ = GGUFDequant.dequantQ8_0( + rawBlocks: zeroCopy, nValues: nValuesPerExpert, outDtype: dtOut, + on: cmd, device: device, into: out, slot: slot) + } + GGUFTensorBundle.profSliceQ80 += CACurrentMediaTime() - _tq + return out.reshaped(to: outShape) + case .q2_K: + let _tq = CACurrentMediaTime() + let out = Self.pooledDequantOutput( + nValues: nValuesPerExpert, dtype: dtOut, device: device, + tagSuffix: "expert_\(slot)") + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let zeroCopy = Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + count: ptr.count, deallocator: .none) + _ = GGUFDequant.dequantQ2_K( + rawBlocks: zeroCopy, nValues: nValuesPerExpert, outDtype: dtOut, + on: cmd, device: device, into: out, slot: slot) + } + GGUFTensorBundle.profSliceQ2K += CACurrentMediaTime() - _tq + return out.reshaped(to: outShape) + case .iq2_xxs: + let _ta = CACurrentMediaTime() + let (grid, signs) = GGUFDequant.iq2xxsTables(device: device) + let _tb = CACurrentMediaTime() + let out = Self.pooledDequantOutput( + nValues: nValuesPerExpert, dtype: dtOut, device: device, + tagSuffix: "expert_\(slot)") + let _tc = CACurrentMediaTime() + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let _td = CACurrentMediaTime() + let zeroCopy = Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + count: ptr.count, deallocator: .none) + _ = GGUFDequant.dequantIQ2_XXS( + rawBlocks: zeroCopy, nValues: nValuesPerExpert, outDtype: dtOut, + gridTensor: grid, signsTensor: signs, + on: cmd, device: device, into: out, slot: slot) + let _te = CACurrentMediaTime() + GGUFTensorBundle.profSliceWrslice += _td - _tc + GGUFTensorBundle.profSliceDequant += _te - _td + } + GGUFTensorBundle.profSlicePooled += _tc - _tb + GGUFTensorBundle.profSliceTables += _tb - _ta + return out.reshaped(to: outShape) + default: + throw GGUFError.unsupportedDequant(info.type, tensor: named) + } + } + nonisolated(unsafe) public static var profSliceTables: Double = 0 + nonisolated(unsafe) public static var profSlicePooled: Double = 0 + nonisolated(unsafe) public static var profSliceWrslice: Double = 0 + nonisolated(unsafe) public static var profSliceDequant: Double = 0 + nonisolated(unsafe) public static var profSliceQ80: Double = 0 + nonisolated(unsafe) public static var profSliceQ2K: Double = 0 + nonisolated(unsafe) public static var profSliceType: [String: Int] = [:] + + public func dequantExpertSlice( + named: String, expertIdx: Int, nExperts: Int, + slot: String = "default", + outDtype: DType? = nil, device: Device = .shared + ) throws -> Tensor { + guard let idx = reader.tensorIndex[named] else { + throw GGUFError.missingMetadataKey("tensor:\(named)") + } + let info = reader.tensorInfos[idx] + // Expert e's slice is the e-th out of `nExperts` equal-sized + // chunks of the tensor (slowest GGUF dim = n_experts). + let nValuesTotal = Int(info.numElements) + let nValuesPerExpert = nValuesTotal / nExperts + let byteStart = (Int(info.byteLength) / nExperts) * expertIdx + let byteLen = Int(info.byteLength) / nExperts + let dtOut = outDtype ?? .f32 + let outShape = info.dimensions.dropLast().map { Int($0) } // shape minus n_experts axis + switch info.type { + case .q8_0: + let out = Self.pooledDequantOutput( + nValues: nValuesPerExpert, dtype: dtOut, device: device, + tagSuffix: "expert_\(slot)") + let cmd = device.makeCommandBuffer() + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let zeroCopy = Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + count: ptr.count, deallocator: .none) + _ = GGUFDequant.dequantQ8_0( + rawBlocks: zeroCopy, nValues: nValuesPerExpert, outDtype: dtOut, + on: cmd, device: device, into: out) + } + cmd.commit() + cmd.waitUntilCompleted() + return out.reshaped(to: outShape) + case .q2_K: + let out = Self.pooledDequantOutput( + nValues: nValuesPerExpert, dtype: dtOut, device: device, + tagSuffix: "expert_\(slot)") + let cmd = device.makeCommandBuffer() + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let zeroCopy = Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + count: ptr.count, deallocator: .none) + _ = GGUFDequant.dequantQ2_K( + rawBlocks: zeroCopy, nValues: nValuesPerExpert, outDtype: dtOut, + on: cmd, device: device, into: out) + } + cmd.commit() + cmd.waitUntilCompleted() + return out.reshaped(to: outShape) + case .iq2_xxs: + let (grid, signs) = GGUFDequant.iq2xxsTables(device: device) + let out = Self.pooledDequantOutput( + nValues: nValuesPerExpert, dtype: dtOut, device: device, + tagSuffix: "expert_\(slot)") + let cmd = device.makeCommandBuffer() + try reader.withRawBytesSlice(named: named, byteStart: byteStart, byteLength: byteLen) { ptr in + let zeroCopy = Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: ptr.baseAddress!), + count: ptr.count, deallocator: .none) + _ = GGUFDequant.dequantIQ2_XXS( + rawBlocks: zeroCopy, nValues: nValuesPerExpert, outDtype: dtOut, + gridTensor: grid, signsTensor: signs, + on: cmd, device: device, into: out) + } + cmd.commit() + cmd.waitUntilCompleted() + return out.reshaped(to: outShape) + default: + throw GGUFError.unsupportedDequant(info.type, tensor: named) + } + } + + /// Pre-allocated dequant output buffer keyed by (dtype, nValues). + /// All layer-load calls with the SAME shape + dtype reuse the + /// SAME MTLBuffer — Metal's driver pool wasn't recycling fresh + /// 4 GB IQ2_XXS expert-tensor allocations efficiently (~1.5 GB + /// stuck per layer). Caller must commit + wait the dequant cmd + /// buffer before requesting the same (dtype, nValues) again + /// (which the per-call `cmd.commit + waitUntilCompleted` here + /// ensures), AND must finish any forward work that consumed the + /// previous layer's buffer before loading the next layer. + private static func pooledDequantOutput( + nValues: Int, dtype: DType, device: Device, + tagSuffix: String = "full" + ) -> Tensor { + let bytes = nValues * dtype.byteSize + // Distinct tag suffix for per-expert slice outputs so they + // don't collide with the full-tensor outputs at the same + // shape — keeps `Layer.ffnGateExps` (full 3D pool) and the + // transient per-expert slice in different slabs. + let tag = "dequant_out_\(dtype)_\(nValues)_\(tagSuffix)" + let buf = device.intermediateScratch(tag: tag, minBytes: bytes) + return Tensor(buffer: buf, offset: 0, shape: [nValues], dtype: dtype) + } + + /// CPU-side dtype conversion for the small float tensors + /// (norms, sinks, biases) where the GGUF on-disk dtype differs + /// from the caller's requested activation dtype. Goes via f32 + /// then narrows; bf16 isn't covered yet (only emerges as a + /// destination once a model needs bf16 activations and a + /// dedicated f32-bytes → bf16-bytes helper lands). + private static func convertHalfPrecisionTensor( + raw: Data, srcDtype: DType, dstDtype: DType, + shape: [Int], device: Device + ) throws -> Tensor { + // Step 1: decode raw → [Float] in f32. + var f32s: [Float] + switch srcDtype { + case .f32: + f32s = raw.withUnsafeBytes { rawBuf in + Array(rawBuf.bindMemory(to: Float.self)) + } + case .f16: + f32s = raw.withUnsafeBytes { rawBuf in + rawBuf.bindMemory(to: Float16.self).map { Float($0) } + } + case .bf16: + f32s = raw.withUnsafeBytes { rawBuf in + rawBuf.bindMemory(to: UInt16.self).map { bits in + Float(bitPattern: UInt32(bits) << 16) + } + } + default: + throw GGUFError.unsupportedDequant(.f32, tensor: "convert src \(srcDtype)") + } + // Step 2: encode f32 → dst bytes. + let outByteCount = f32s.count * dstDtype.byteSize + let buf = device.makeBuffer(length: max(outByteCount, dstDtype.byteSize)) + switch dstDtype { + case .f32: + buf.contents().assumingMemoryBound(to: Float.self) + .update(from: &f32s, count: f32s.count) + case .f16: + var f16s: [Float16] = f32s.map { Float16($0) } + buf.contents().assumingMemoryBound(to: Float16.self) + .update(from: &f16s, count: f16s.count) + case .bf16: + var bf16s: [UInt16] = f32s.map { v in + let bits = v.bitPattern + // Round-to-nearest-even truncation: add bias before + // shifting so the round-half-to-even tie-break is + // approximated (matches PyTorch's bf16 cast). + let lsb = (bits >> 16) & 1 + let rounded = bits + 0x7FFF + lsb + return UInt16(rounded >> 16) + } + buf.contents().assumingMemoryBound(to: UInt16.self) + .update(from: &bf16s, count: bf16s.count) + default: + throw GGUFError.unsupportedDequant(.f32, tensor: "convert dst \(dstDtype)") + } + return Tensor(buffer: buf, offset: 0, shape: shape, dtype: dstDtype) + } + + // ─── Architecture-introspection helpers ────────────────────────── + + /// `general.architecture` — what the loader's family dispatch + /// switches on. Returns `nil` if the metadata key is missing + /// (malformed GGUF). + public var architecture: String? { + reader.metadataString("general.architecture") + } + + /// `general.name` — model display name (e.g. + /// "DeepSeek V4 Flash"). Optional. + public var modelName: String? { + reader.metadataString("general.name") + } + + /// Build a swift-transformers `Tokenizer` from the embedded + /// `tokenizer.ggml.*` metadata. Throws when the embedded + /// tokenizer kind isn't a BPE-family variant the adapter knows + /// how to translate. + public func tokenizer() throws -> any Tokenizers.Tokenizer { + try GGUFTokenizerAdapter.build(reader: reader) + } +} diff --git a/Sources/FFAI/Loader/GGUF/GGUFTokenizer.swift b/Sources/FFAI/Loader/GGUF/GGUFTokenizer.swift new file mode 100644 index 00000000..0868db74 --- /dev/null +++ b/Sources/FFAI/Loader/GGUF/GGUFTokenizer.swift @@ -0,0 +1,207 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// GGUF tokenizer adapter — reconstruct a swift-transformers +// `PreTrainedTokenizer` from the `tokenizer.ggml.*` metadata block. +// +// GGUF embeds the tokenizer alongside the model weights via the +// canonical GGUF v3 metadata schema. The +// shape mirrors the HF `tokenizer.json` / `tokenizer_config.json` +// pair, just under a different key namespace. The adapter +// translates between the two so the rest of FFAI (which already +// consumes `PreTrainedTokenizer`) can use a GGUF checkpoint +// transparently. +// +// Supported tokenizer kinds (`tokenizer.ggml.model`): +// - `gpt2`, `llama`, `deepseek-llm`, `deepseek-coder` — all +// BPE; built as `PreTrainedTokenizer` with tokenizer_class +// `"PreTrainedTokenizerFast"` and the matching pretokenizer +// regex. +// +// Other kinds (`bert`, `unigram`) will throw `unsupportedKind` +// until they're needed. + +import Foundation +import Hub +import Tokenizers + +enum GGUFTokenizerAdapter { + enum Error: Swift.Error, CustomStringConvertible { + case missingField(String) + case unsupportedKind(String) + case buildFailed(underlying: Swift.Error) + + var description: String { + switch self { + case .missingField(let f): + return "GGUFTokenizerAdapter: required metadata field missing: \(f)" + case .unsupportedKind(let k): + return + "GGUFTokenizerAdapter: tokenizer.ggml.model='\(k)' not supported yet (only BPE-family kinds — gpt2 / llama / deepseek-llm / deepseek-coder)" + case .buildFailed(let err): + return "GGUFTokenizerAdapter: swift-transformers init failed: \(err)" + } + } + } + + /// Build a `PreTrainedTokenizer` from a GGUF reader. The reader + /// must have a populated `tokenizer.ggml.*` metadata block (every + /// official GGUF checkpoint does). + static func build(reader: GGUFReader) throws -> any Tokenizer { + let kind = reader.metadataString("tokenizer.ggml.model") ?? "" + guard isBPEKind(kind) else { + throw Error.unsupportedKind(kind) + } + + guard let tokens = reader.metadataStringArray("tokenizer.ggml.tokens") else { + throw Error.missingField("tokenizer.ggml.tokens") + } + guard let merges = reader.metadataStringArray("tokenizer.ggml.merges") else { + throw Error.missingField("tokenizer.ggml.merges") + } + + // Build the vocab dict (token → id). GGUF stores tokens as a + // positionally-indexed array; ID = array index. + var vocab: [NSString: Any] = [:] + vocab.reserveCapacity(tokens.count) + for (i, t) in tokens.enumerated() { + vocab[t as NSString] = i + } + + // BOS / EOS / UNK / PAD lookups. The IDs are stored as u32 in + // GGUF; the token strings come from `tokens[id]`. + let bosTokenStr = lookupToken(reader: reader, key: "tokenizer.ggml.bos_token_id", tokens: tokens) + let eosTokenStr = lookupToken(reader: reader, key: "tokenizer.ggml.eos_token_id", tokens: tokens) + let unkTokenStr = lookupToken(reader: reader, key: "tokenizer.ggml.unknown_token_id", tokens: tokens) + let padTokenStr = lookupToken(reader: reader, key: "tokenizer.ggml.padding_token_id", tokens: tokens) + + // Pre-tokenizer hint (added in late 2024 — `tokenizer.ggml.pre`): + // GGUF carries upstream-side regex-group names (`"joyai-llm"`, + // `"deepseek-llm"`, `"qwen2"`, …) which don't map 1:1 to + // swift-transformers' enum — so we normalise to the closest + // swift-transformers-recognised type. `gpt2`-family models + // collapse to ByteLevel, `llama`-family to Metaspace; unknown + // model kinds fall back to ByteLevel (the GPT-2 BPE default). + let preHint = normalisedPreType( + forKind: kind, hint: reader.metadataString("tokenizer.ggml.pre")) + let chatTemplate = reader.metadataString("tokenizer.chat_template") + + // ── tokenizerData: mirrors HF tokenizer.json structure ── + let modelDict: [NSString: Any] = [ + "type": "BPE", + "vocab": vocab, + // GGUF stores merges as space-separated strings ("a b"). + // swift-transformers' `mergesFromConfig` accepts that + // legacy shape directly — no conversion needed. + "merges": merges, + // `byte_fallback` is the default for SentencePiece-style + // models (llama family); BPE-pure tokenizers (gpt2, + // deepseek-coder) set this false. Conservative default + // off; explicit GGUF metadata wins when present. + "byte_fallback": reader.metadataBool("tokenizer.ggml.add_bos_token") ?? false, + ] + var tokenizerDataDict: [NSString: Any] = [ + "model": modelDict, + "pre_tokenizer": ["type": preHint] as [NSString: Any], + ] + // `added_tokens` is the override layer for special tokens + // that already appear in the vocab. We don't need to inject + // anything here — BOS/EOS already live at their ID positions + // in `tokens` — but the field's shape needs to exist so + // PreTrainedTokenizer doesn't choke on a missing key. + tokenizerDataDict["added_tokens"] = [Any]() + + // ── tokenizerConfig ── + var tokenizerConfigDict: [NSString: Any] = [ + "tokenizer_class": "PreTrainedTokenizerFast" + ] + if let bos = bosTokenStr { tokenizerConfigDict["bos_token"] = bos } + if let eos = eosTokenStr { tokenizerConfigDict["eos_token"] = eos } + if let unk = unkTokenStr { tokenizerConfigDict["unk_token"] = unk } + if let pad = padTokenStr { tokenizerConfigDict["pad_token"] = pad } + if let tpl = chatTemplate { tokenizerConfigDict["chat_template"] = tpl } + if let addBos = reader.metadataBool("tokenizer.ggml.add_bos_token") { + tokenizerConfigDict["add_bos_token"] = addBos + } + if let addEos = reader.metadataBool("tokenizer.ggml.add_eos_token") { + tokenizerConfigDict["add_eos_token"] = addEos + } + + let tokenizerConfig = Config(tokenizerConfigDict) + let tokenizerData = Config(tokenizerDataDict) + + do { + return try PreTrainedTokenizer( + tokenizerConfig: tokenizerConfig, tokenizerData: tokenizerData, strict: false) + } catch { + throw Error.buildFailed(underlying: error) + } + } + + // ─── Helpers ────────────────────────────────────────────────────── + + /// Map a `tokenizer.ggml.*_token_id` u32 to its string from the + /// vocab. Returns `nil` when the id field is absent or out of + /// range. + private static func lookupToken(reader: GGUFReader, key: String, tokens: [String]) -> String? { + guard let id = reader.metadataUInt32(key), Int(id) < tokens.count else { return nil } + return tokens[Int(id)] + } + + /// BPE-family tokenizer kinds. The GGUF `tokenizer.ggml.model` enum + /// covers a wider set (SentencePiece-Unigram, BERT-WordPiece, …); + /// this is the subset we know swift-transformers' `BPETokenizer` + /// handles correctly. New kinds get added here once their + /// pretokenizer regex is wired in. + private static func isBPEKind(_ kind: String) -> Bool { + switch kind { + case "gpt2", "llama", "deepseek-llm", "deepseek-coder", + "qwen2", "chatglm-bpe", "mpt", "starcoder", "falcon", "refact", + "command-r", "olmo", "phi-3", "smaug-bpe": + return true + default: + return false + } + } + + /// PreTokenizer type whitelist that swift-transformers' factory + /// accepts without fatalError'ing. Anything else gets remapped + /// to a safe default keyed by `model` (gpt2 → ByteLevel, llama + /// → Metaspace). + private static let knownPreTypes: Set = [ + "Sequence", "ByteLevel", "Punctuation", "Digits", "Split", + "Whitespace", "WhitespaceSplit", "Metaspace", "BertPreTokenizer", + ] + + /// Normalise the `tokenizer.ggml.pre` hint to one of the + /// swift-transformers-recognised PreTokenizer kinds. The hint + /// string in GGUF carries the upstream regex-family name + /// (`"joyai-llm"`, `"deepseek-llm"`, `"qwen2"`, …); for the BPE + /// kinds we support, all of those collapse into one of two + /// behaviours at the tokenization layer. + private static func normalisedPreType(forKind kind: String, hint: String?) -> String { + if let hint, knownPreTypes.contains(hint) { + return hint + } + switch kind { + case "gpt2", "qwen2", "deepseek-coder", "starcoder", "falcon", "refact", + "command-r", "olmo", "phi-3", "smaug-bpe", "mpt", "chatglm-bpe": + return "ByteLevel" + case "llama", "deepseek-llm": + return "Metaspace" + default: + return "ByteLevel" + } + } +} diff --git a/Sources/FFAI/Loader/GGUF/GGUFTypes.swift b/Sources/FFAI/Loader/GGUF/GGUFTypes.swift new file mode 100644 index 00000000..29bff392 --- /dev/null +++ b/Sources/FFAI/Loader/GGUF/GGUFTypes.swift @@ -0,0 +1,249 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// GGUF v3 format types — header constants, value-type enum, quant-type +// enum, metadata KV value, error type. Whole format is little-endian. + +import Foundation + +// ─── Format constants ──────────────────────────────────────────────── + +public enum GGUFConstants { + /// Magic bytes at byte offset 0 of every GGUF file. The four bytes + /// `G G U F` in ASCII — `0x47 0x47 0x55 0x46`. + public static let magic: [UInt8] = [0x47, 0x47, 0x55, 0x46] + /// We support only v3 (the version stable since 2023). v2 (early + /// 2023) shipped with `u32` array lengths instead of `u64`; v3 fixed + /// that. Files older than v3 are extremely rare in the wild. + public static let supportedVersion: UInt32 = 3 + /// Default tensor-data section alignment (overridable via the + /// `general.alignment` u32 metadata key, but defaults are universal + /// in practice). + public static let defaultAlignment: UInt64 = 32 +} + +// ─── KV metadata value types ───────────────────────────────────────── + +/// `GGUF_METADATA_VALUE_TYPE_*` — the u32 tag prefix on every metadata +/// value in the KV block. +public enum GGUFValueType: UInt32 { + case uint8 = 0 + case int8 = 1 + case uint16 = 2 + case int16 = 3 + case uint32 = 4 + case int32 = 5 + case float32 = 6 + case bool = 7 + case string = 8 + case array = 9 + case uint64 = 10 + case int64 = 11 + case float64 = 12 +} + +/// One metadata value. Arrays carry their element-type discriminator +/// alongside the elements so a caller can index into `.array(.string([…]))` +/// or `.array(.int32([…]))` without re-querying the file. +public enum GGUFValue: Sendable { + case uint8(UInt8) + case int8(Int8) + case uint16(UInt16) + case int16(Int16) + case uint32(UInt32) + case int32(Int32) + case uint64(UInt64) + case int64(Int64) + case float32(Float) + case float64(Double) + case bool(Bool) + case string(String) + case array(GGUFArrayValue) +} + +/// Typed array — GGUF's `array` value carries a per-element type tag, +/// so the parser materialises one of these instead of a heterogeneous +/// `[GGUFValue]`. Saves a lot of casting work in the tokenizer adapter +/// + loader. +public enum GGUFArrayValue: Sendable { + case uint8([UInt8]) + case int8([Int8]) + case uint16([UInt16]) + case int16([Int16]) + case uint32([UInt32]) + case int32([Int32]) + case uint64([UInt64]) + case int64([Int64]) + case float32([Float]) + case float64([Double]) + case bool([Bool]) + case string([String]) +} + +// ─── Tensor data types ─────────────────────────────────────────────── + +/// `GGML_TYPE_*` — the on-disk tensor quant format. The enum order +/// mirrors `ggml.h` so the u32 file values cast cleanly. New variants +/// appended at the end of `ggml.h` should be mirrored here in the same +/// order, with new raw values. +public enum GGUFTensorType: UInt32, Sendable { + case f32 = 0 + case f16 = 1 + case q4_0 = 2 + case q4_1 = 3 + // 4 and 5 are removed legacy quants (Q4_2 / Q4_3). + case q5_0 = 6 + case q5_1 = 7 + case q8_0 = 8 + case q8_1 = 9 + case q2_K = 10 + case q3_K = 11 + case q4_K = 12 + case q5_K = 13 + case q6_K = 14 + case q8_K = 15 + case iq2_xxs = 16 + case iq2_xs = 17 + case iq3_xxs = 18 + case iq1_s = 19 + case iq4_nl = 20 + case iq3_s = 21 + case iq2_s = 22 + case iq4_xs = 23 + case i8 = 24 + case i16 = 25 + case i32 = 26 + case i64 = 27 + case f64 = 28 + case iq1_m = 29 + case bf16 = 30 + case tq1_0 = 34 + case tq2_0 = 35 + case mxfp4 = 39 + + /// Block-byte size for a tensor of this type. Used to compute the + /// total byte footprint of a tensor (`n_elements / block_size * + /// bytes_per_block`). + public var bytesPerBlock: Int { + switch self { + case .f32: return 4 + case .f16, .bf16: return 2 + case .q4_0: return 18 + case .q4_1: return 20 + case .q5_0: return 22 + case .q5_1: return 24 + case .q8_0: return 34 + case .q8_1: return 36 + case .q2_K: return 84 + case .q3_K: return 110 + case .q4_K: return 144 + case .q5_K: return 176 + case .q6_K: return 210 + case .q8_K: return 292 + case .iq1_s: return 50 + case .iq1_m: return 56 + case .iq2_xxs: return 66 + case .iq2_xs: return 74 + case .iq2_s: return 82 + case .iq3_xxs: return 98 + case .iq3_s: return 110 + case .iq4_nl: return 18 + case .iq4_xs: return 136 + case .i8: return 1 + case .i16: return 2 + case .i32: return 4 + case .i64, .f64: return 8 + case .tq1_0: return 34 + case .tq2_0: return 68 + case .mxfp4: return 20 + } + } + + /// Number of values per block. Legacy `Q*_0/1` use 32; all k-quants + /// and i-quants use 256; primitive scalars are 1. + public var blockSize: Int { + switch self { + case .f32, .f16, .bf16, .i8, .i16, .i32, .i64, .f64: return 1 + case .q4_0, .q4_1, .q5_0, .q5_1, .q8_0, .q8_1, .iq4_nl, .mxfp4: return 32 + default: return 256 + } + } +} + +// ─── Tensor descriptor ─────────────────────────────────────────────── + +/// Static metadata for one tensor — populated from the tensor-info +/// table section. The actual bytes live at +/// `tensor_data_offset + dataOffset`, length +/// `(num_elements / blockSize) * bytesPerBlock`. +public struct GGUFTensorInfo: Sendable { + public let name: String + public let dimensions: [UInt64] + public let type: GGUFTensorType + /// Offset relative to the start of the tensor-data blob (NOT the + /// file). Add `fileTensorDataOffset` to get the absolute file + /// offset. + public let dataOffset: UInt64 + + public var numElements: UInt64 { dimensions.reduce(1, *) } + + /// On-disk byte length for this tensor's data. + public var byteLength: Int { + let blocks = Int(numElements) / type.blockSize + return blocks * type.bytesPerBlock + } +} + +// ─── Errors ────────────────────────────────────────────────────────── + +public enum GGUFError: Error, CustomStringConvertible { + case badMagic + case unsupportedVersion(UInt32) + case truncated(at: String) + case unknownValueType(UInt32, key: String?) + case unknownTensorType(UInt32, tensor: String?) + case duplicateKey(String) + case duplicateTensorName(String) + case stringNotUTF8(at: String) + case unsupportedDequant(GGUFTensorType, tensor: String) + case missingMetadataKey(String) + + public var description: String { + switch self { + case .badMagic: + return "GGUF: first 4 bytes are not 'GGUF' (file is not a GGUF v3 checkpoint)" + case .unsupportedVersion(let v): + return "GGUF: file version \(v) is not supported (expected 3)" + case .truncated(let at): + return "GGUF: file truncated while reading \(at)" + case .unknownValueType(let tag, let key): + let where_ = key.map { " (key=\($0))" } ?? "" + return "GGUF: unknown metadata value-type tag \(tag)\(where_)" + case .unknownTensorType(let tag, let tensor): + let where_ = tensor.map { " (tensor=\($0))" } ?? "" + return "GGUF: unknown tensor type tag \(tag)\(where_)" + case .duplicateKey(let k): + return "GGUF: duplicate metadata key '\(k)'" + case .duplicateTensorName(let n): + return "GGUF: duplicate tensor name '\(n)'" + case .stringNotUTF8(let at): + return "GGUF: invalid UTF-8 in string at \(at)" + case .unsupportedDequant(let t, let tensor): + return + "GGUF: dequant for \(t) is not yet implemented (tensor '\(tensor)')" + case .missingMetadataKey(let k): + return "GGUF: required metadata key '\(k)' is missing" + } + } +} diff --git a/Sources/FFAI/Loader/LoadOptions.swift b/Sources/FFAI/Loader/LoadOptions.swift index 9436e158..e2793657 100644 --- a/Sources/FFAI/Loader/LoadOptions.swift +++ b/Sources/FFAI/Loader/LoadOptions.swift @@ -57,25 +57,31 @@ public enum DispatchMode: Sendable { /// Only relevant when `LoadOptions.kvCache == .auraQuantized(...)` — /// raw / affine caches ignore this setting. public enum AURADecodePath: Sendable, Equatable { - /// **Default.** Compressed-domain attention via the - /// `aura_flash_p1` + `aura_flash_pass2` kernel pair. Q is rotated, + /// **Default.** Compressed-domain attention via the 2-pass FA-2 + /// kernel pair (`aura_flash_p1` + `aura_flash_pass2`) when emitted + /// for the (keyBits, valueBits, headDim, dtype) combo, with the + /// single-pass `aura_flash_sdpa` as fallback for cells the 2-pass + /// kernel hasn't been emitted for. Q is rotated + pre-scaled, /// scored directly against the packed K codes (no full-precision - /// dequant), then combined with the packed V codes — the kernel - /// dequantises per-tile on chip, never materialising a maxSeq-sized - /// f16 mirror buffer. Realises AURA's full memory savings (~4× at - /// `aura4v2`). + /// dequant), and the V codes are dequanted per-tile on chip. The + /// `[nKVHeads, maxSeq, headDim]` mirror buffer never materialises, + /// realising AURA's memory savings (~1.88× at aura4v4, ~3.7× at + /// aura4v2 on Qwen3 d=128). + /// + /// AURA-dtype unification (metaltile + FFAI joint change) put the + /// per-token norms and per-scheme codebook into the activation + /// dtype, so encode + both decode kernel paths consume the cache + /// buffers directly — no per-call f32 cast on the decode hot path + /// and no parallel f32 mirror storage. case compressed - /// Stage 1a behaviour. `prepareForAttention(on:)` dequantises the - /// full compressed K/V cache into per-layer shared working buffers - /// (`sharedWorkingK` / `sharedWorkingV`, sized - /// `[nKVHeads, maxSeq, headDim]`), and the standard - /// `Ops.sdpaDecode` reads those. Preserves AURA's quality but - /// **gives back the memory savings** — the mirror is the same size - /// as a raw fp16 cache. Kept as an opt-in path for A/B benching - /// (`compressed` vs `dequantMirror` speed at production shapes) - /// and for callers with the memory headroom who want - /// matrix-engine SDPA. + /// Dequant-mirror path. `prepareForAttention(on:)` materialises + /// the full compressed K/V cache into per-layer shared working + /// buffers (`sharedWorkingK` / `sharedWorkingV`, sized + /// `[nKVHeads, maxSeq, headDim]`) and `Ops.sdpaDecode` reads those. + /// Same quality as `.compressed`, **gives back the memory + /// savings** — the mirror is the same size as a raw fp16 cache. + /// Useful as an A/B baseline against the compressed path. case dequantMirror } @@ -113,13 +119,13 @@ public struct LoadOptions: Sendable { /// entire advertised window, or a smaller value to bound memory. public var maxContextLength: Int? - /// Selects the AURA decode path. Defaults to `.compressed` (Stage - /// 1b: attend on packed K/V codes directly via the `aura_flash_*` - /// kernel pair — full ~4× memory savings). Set to `.dequantMirror` - /// for the Stage 1a path that maintains a full-precision - /// `[nKVHeads, maxSeq, headDim]` mirror buffer and runs the - /// standard `Ops.sdpaDecode` against it — useful for A/B speed - /// benching. Has no effect when `kvCache != .auraQuantized(...)`. + /// Selects the AURA decode path. Defaults to `.compressed` — the + /// 2-pass FA-2 kernel pair gives token-parallel attention over the + /// packed K/V codes directly, with no f16/f32 mirror materialised. + /// Set to `.dequantMirror` for an A/B baseline that dequants the + /// cache into a per-layer working buffer and runs the standard + /// `Ops.sdpaDecode` against it. Has no effect when + /// `kvCache != .auraQuantized(...)`. public var auraDecodePath: AURADecodePath public init( diff --git a/Sources/FFAI/Loader/Model.swift b/Sources/FFAI/Loader/Model.swift index adbe9ad1..7c227562 100644 --- a/Sources/FFAI/Loader/Model.swift +++ b/Sources/FFAI/Loader/Model.swift @@ -683,6 +683,24 @@ public enum ModelRegistry { options: options, device: device) } + // DeepSeek V4 — hybrid full / CSA / HCA attention over a + // 43-layer MoE backbone with MLA latent KV, Lightning Indexer + // top-k sparse routing, and `sqrtsoftplus` MoE gating. The + // safetensors forward path is not yet implemented; the GGUF + // loader is a separate parallel path (see + // `DeepSeekV4Variant.loadModelFromGGUF`). + // Routes through its own family file. + if let arch = config.architecture, DeepSeekV4.architectures.contains(arch) { + return try loadDeepSeekV4( + config: config, weights: weights, + options: options, device: device) + } + if let mt = config.modelType, DeepSeekV4.modelTypes.contains(mt) { + return try loadDeepSeekV4( + config: config, weights: weights, + options: options, device: device) + } + // GPT-OSS — a mixture-of-experts transformer with an alternating // sliding/full attention schedule, learned per-head attention // sinks, and bias-corrected projections. Routes through its own @@ -900,6 +918,32 @@ public enum ModelRegistry { defaultGenerationParameters: variant.defaultGenerationParameters) } + /// DeepSeek V4 family loader. Mirrors the other `load` + /// helpers, but the safetensors forward path is not yet implemented + /// — the variant's `loadModel` always raises + /// `DeepSeekV4Error.notYetImplemented` today, so this helper + /// resolves the variant, attempts the load, and surfaces that error + /// in ONE place (rather than the duplicated dispatch sites). + /// + /// NOTE: `DeepSeekV4Model` does not yet conform to `LanguageModel` + /// (forward not yet implemented), so it cannot be wrapped in a + /// `Loaded` yet. The + /// `loadModel` call above throws before we get here; the trailing + /// throw keeps control flow well-formed and documents the gap. Once + /// `DeepSeekV4Model: LanguageModel` lands, replace the trailing + /// throw with the standard `return Loaded(engine:...)` wrap. + public static func loadDeepSeekV4( + config: ModelConfig, weights: SafeTensorsBundle, + options: LoadOptions, device: Device + ) throws -> Loaded { + let variant = try DeepSeekV4.variant(for: config) + _ = try variant.loadModel( + config: config, weights: weights, + options: options, device: device + ) + throw DeepSeekV4Error.notYetImplemented("DeepSeekV4 Loaded wrapping") + } + public static func loadGPTOSS( config: ModelConfig, weights: SafeTensorsBundle, options: LoadOptions, device: Device diff --git a/Sources/FFAI/Models/DeepSeekV4.swift b/Sources/FFAI/Models/DeepSeekV4.swift new file mode 100644 index 00000000..f576e8e7 --- /dev/null +++ b/Sources/FFAI/Models/DeepSeekV4.swift @@ -0,0 +1,167 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// DeepSeek V4 family root — DeepSeek V4 line (DeepSeek-V4-Flash, +// DeepSeek-V4-Pro). +// +// This file is the **main model interface** for the family: +// • the family enum `DeepSeekV4` (modelTypes, architectures, variant +// dispatch), +// • the `DeepSeekV4Variant` protocol every concrete variant conforms +// to, +// • the unified `DeepSeekV4Error` type every loader / decode site +// raises. +// +// Concrete variants + the hybrid CSA/HCA/MLA decoder + per-layer impl +// live under `Models/Text/DeepSeekV4Text.swift`: +// - `DeepSeekV4Flash` — 284B total / 13B active. 43 transformer +// layers + 1 MTP head, interleaved full / CSA / HCA attention, +// 288-expert MoE with sigmoid+bias + Lightning Indexer routing. +// - `DeepSeekV4Pro` — same arch, ~1.6T / 49B active. +// - `DeepSeekV4Model` — full LanguageModel decoder. +// +// **Status:** Family scaffold + config decoder + loader hook are +// in place so a safetensors `DeepSeek-V4-Flash` checkpoint is +// identified end-to-end via the standard `Model.load` dispatch; the +// forward path is stubbed and raises +// `DeepSeekV4Error.notYetImplemented` on load. The MLA / CSA / HCA / +// Lightning-Indexer kernel work lands in follow-ups (metaltile-side +// scope spans 4-5 distinct PRs). +// +// The GGUF path is SEPARATE: `GGUFTensorBundle` (in `Loader/GGUF/`) is +// a parallel DSv4 loader reached via `loadModelFromGGUF`, not the +// safetensors `SafeTensorsBundle` flow. It does not yet mirror +// `SafeTensorsBundle`'s public surface, so the two are not +// interchangeable in the dispatcher — unifying them behind a shared +// `TensorBundle` protocol is future work. The GGUF reader ships +// alongside the family namespace so the load-from-GGUF infrastructure +// goes in together. + +import Foundation + +// ─── Family entry point ────────────────────────────────────────────── + +public enum DeepSeekV4 { + /// HuggingFace `model_type` strings this family handles. + public static let modelTypes: Set = ["deepseek_v4", "deepseek4"] + + /// HuggingFace `architectures[0]` strings this family handles. GGUF + /// checkpoints carry the bare `deepseek4` string in + /// `general.architecture`; the safetensors convention is + /// `DeepseekV4ForCausalLM`. + public static let architectures: Set = [ + "DeepseekV4ForCausalLM", + "DeepseekV4Model", + "deepseek4", + ] + + /// Resolve the concrete variant from config. Flash and Pro share + /// the same architecture surface; the variant is picked by total + /// parameter count (Pro: 1.6T; Flash: 284B). Defaults to Flash + /// since that's the user-runnable size on Apple Silicon today. + public static func variant( + for config: ModelConfig + ) throws -> any DeepSeekV4Variant.Type { + let tc = DeepSeekV4Config.textConfig(config) + // `num_hidden_layers` is the cheapest variant discriminator — + // Pro layers ≈ 60+, Flash = 43. Fall back to Flash on any + // ambiguity. + if let layers = tc.int("num_hidden_layers"), layers > 50 { + return DeepSeekV4Pro.self + } + return DeepSeekV4Flash.self + } +} + +// ─── Variant protocol ──────────────────────────────────────────────── + +public protocol DeepSeekV4Variant { + static var availableCapabilities: Set { get } + static var defaultGenerationParameters: GenerationParameters { get } + static func loadModel( + config: ModelConfig, + weights: SafeTensorsBundle, + options: LoadOptions, + device: Device + ) throws -> DeepSeekV4Model + + /// GGUF entry point — parallel to `loadModel(weights:)` for the + /// safetensors path. The default impl throws + /// `notYetImplemented`; concrete variants override once the + /// GGUF→tensor dequant + tokenizer reconstruction land. + static func loadModelFromGGUF( + config: ModelConfig, + gguf: GGUFTensorBundle, + options: LoadOptions, + device: Device + ) throws -> DeepSeekV4Model +} + +extension DeepSeekV4Variant { + public static var availableCapabilities: Set { + [.textIn, .textOut] + } + public static var defaultGenerationParameters: GenerationParameters { + // DSv4-Flash: 1M context (256K from RoPE base + 4× YARN + // extrapolation). The 4096-token prefill chunk matches the + // Gemma 4 / Qwen 3 hybrid family defaults — large enough to + // amortise the MLA absorb-W_UK setup over many positions, + // small enough to fit on a 96 GB Apple Silicon machine. + // No model-specific maxTokens — fall to the GenerationParameters + // default so callers control generation length. DeepSeek-V3/V4 + // recommend temperature 0.6 / top-p 0.95 for general use. + GenerationParameters( + prefillStepSize: 4096, + temperature: 0.6, topP: 0.95, topK: 64, + repetitionPenalty: 1.0) + } + + public static func loadModelFromGGUF( + config: ModelConfig, + gguf: GGUFTensorBundle, + options: LoadOptions, + device: Device + ) throws -> DeepSeekV4Model { + _ = config; _ = gguf; _ = options; _ = device + throw DeepSeekV4Error.notYetImplemented("GGUF forward path") + } +} + +// ─── Errors ────────────────────────────────────────────────────────── + +public enum DeepSeekV4Error: Error, CustomStringConvertible { + case missingConfig(String) + case missingTensor(String) + case unsupportedLayerType(String) + case unsupportedRouterShape(String) + case unsupportedQuantType(GGUFTensorType, tensor: String) + case notYetImplemented(String) + + public var description: String { + switch self { + case .missingConfig(let f): + return "DeepSeekV4: required config field missing: \(f)" + case .missingTensor(let name): + return "DeepSeekV4: checkpoint is missing tensor '\(name)'" + case .unsupportedLayerType(let t): + return "DeepSeekV4: unknown layer kind '\(t)' (expected one of: full / csa / hca)" + case .unsupportedRouterShape(let why): + return "DeepSeekV4: MoE router shape unsupported: \(why)" + case .unsupportedQuantType(let t, let tensor): + return "DeepSeekV4: GGUF quant '\(t)' for tensor '\(tensor)' not yet supported" + case .notYetImplemented(let what): + return "DeepSeekV4: \(what) — not yet implemented" + } + } +} diff --git a/Sources/FFAI/Models/MoELayer.swift b/Sources/FFAI/Models/MoELayer.swift index d539ea9c..9d0c8007 100644 --- a/Sources/FFAI/Models/MoELayer.swift +++ b/Sources/FFAI/Models/MoELayer.swift @@ -532,6 +532,7 @@ public final class MoELayer: Module, DecoderLayer { cache _: any LayerCacheProtocol, cmd: MTLCommandBuffer, device: Device ) -> Tensor { + return Profile.signpost("moe.decode") { () -> Tensor in precondition( h.elementCount == hidden, "MoELayer.decode: input has \(h.elementCount) elements, expected hidden \(hidden)") @@ -631,6 +632,7 @@ public final class MoELayer: Module, DecoderLayer { // MoE layer per token (Qwen3.6-A3B = 40 layers). work.commit() return result + } // Profile.signpost("moe.decode") } /// T-batched MoE forward. `hFlat` is `[T, hidden]` flat; returns diff --git a/Sources/FFAI/Models/Text/DeepSeekV4Text.swift b/Sources/FFAI/Models/Text/DeepSeekV4Text.swift new file mode 100644 index 00000000..07dafcf1 --- /dev/null +++ b/Sources/FFAI/Models/Text/DeepSeekV4Text.swift @@ -0,0 +1,2657 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// DeepSeek V4 text backbone — DSv4-Flash / DSv4-Pro decoder config + +// variants. +// +// **Status:** Scaffold. This file declares the static shape — the +// `DeepSeekV4TextConfig` decoder, the two variants (`DeepSeekV4Flash`, +// `DeepSeekV4Pro`), and the `DeepSeekV4Model` placeholder — so the +// loader can identify a DSv4 checkpoint (safetensors or GGUF) and +// dispatch into the family. The forward path land in follow-up PRs +// per the multi-week metaltile kernel sequence (MLA decode, CSA +// sparse-gather SDPA, HCA compressed-stream SDPA, Lightning Indexer, +// FP4 / block-FP8 dequant). +// +// ─── Architecture summary (from the upstream config) ───────────────── +// +// • 43 transformer layers + 1 MTP head, hidden=4096, vocab=129,280. +// • Interleaved attention pattern (`compress_ratios` aligned 1-1 with +// the layer stack): layers 0-1 = full attention; layers 2-41 = +// alternating CSA(4×) / HCA(128×) pairs; layer 42 = full; layer 43 +// = MTP next-N predictor. +// • **MLA (Multi-head Latent Attention)** carried over from DSv3. +// `head_dim=512` (MLA latent dim), `kv_heads=1` (single MLA cache), +// `qk_rope_head_dim=64` decoupled-RoPE concat tail. Q is low-rank- +// compressed to `q_lora_rank=1024`. O-projection is now ALSO +// low-rank (`o_lora_rank=1024`, `o_groups=8`) — new in V4. +// • **CSA (Compressed Sparse Attention)** — 4× compressed KV stream. +// A 64-head × 128-dim **Lightning Indexer** sub-network scores all +// compressed entries; per query, top-`index_topk=512` selected + +// 128-token local sliding window. Scoring: `sqrtsoftplus`. +// • **HCA (Heavily Compressed Attention)** — 128× compressed KV +// stream. Dense attention (no top-k) over the small compressed +// buffer. Separate `compress_rope_theta=160_000` (vs `rope_theta= +// 10_000` for full + CSA layers). +// • MoE on every non-MTP layer (288 experts, top-k=6, expert +// intermediate=2048, 1 always-on shared expert at dim=2048). +// `noaux_tc` aux-loss-free + per-expert bias routing. +// • **`sqrtsoftplus` routing scoring** — replaces DSv3's sigmoid+bias +// gate (companion `ffai_moe_router_sigmoid_bias` kernel from +// the Step-3 series is the closest cousin; sqrtsoftplus is its +// own small variant kernel). +// • **mHC (Manifold-Constrained Hyper-Connections)** — residual +// projection matrix constrained to the Birkhoff polytope (doubly +// stochastic) via 20 Sinkhorn-Knopp iterations. Folded into +// weights at LOAD time, not per token — no runtime kernel needed. +// • **Clamped SwiGLU** — `swiglu_limit=10.0` applied to every MoE +// expert MLP. The Step-3 `mt_clamped_swiglu` kernel is the +// drop-in here. +// • **MTP head** — `num_nextn_predict_layers=1`. Carries the same +// per-layer block shape as the main stack but its output flows +// into a separate logits head used by speculative decoding. +// +// ─── Mixed precision (from the upstream config) ────────────────────── +// +// • MoE expert weights stored as **fp4** (FP4 e2m1, block 32, with +// per-block fp8 e4m3 scales). New kernel family — not in +// metaltile-std today. +// • Attention / router weights stored as **block-FP8** (FP8 e4m3, +// block 128×128). Distinct from per-channel int4 affine; another +// net-new dequant kernel. +// • Activations: bf16 throughout. Output norms: f32. + +import Foundation +import Metal +import MetalTileSwift +import QuartzCore + +// ─── DeepSeekV4TextConfig ──────────────────────────────────────────── + +public struct DeepSeekV4TextConfig: Sendable { + let nLayers: Int // 43 (excludes MTP) + let hidden: Int // 4096 + let vocab: Int // 129_280 + let maxSeq: Int // 1_048_576 (1M) + let rmsNormEps: Float + + // ── Attention shape ── + let nHeads: Int // 64 + let nKVHeads: Int // 1 — MLA carries one logical KV head + let headDim: Int // 512 — MLA latent dim + let qkRopeHeadDim: Int // 64 — decoupled-RoPE concat tail + let qLoraRank: Int // 1024 — Q low-rank compression + let oLoraRank: Int // 1024 — O-projection low-rank (new in V4) + let oGroups: Int // 8 — O-projection group count + + // ── Per-layer compression schedule ── + /// `compress_ratios` mirror of the layer stack (length = + /// nLayers + MTP slot). Value semantics: + /// - 0 → full attention + /// - 4 → CSA (4× compression) + /// - 128 → HCA (128× compression) + let layerCompressRatios: [Int] + let slidingWindow: Int // 128 — CSA local window + + // ── Lightning Indexer ── + let indexerHeads: Int // 64 + let indexerHeadDim: Int // 128 + let indexerTopK: Int // 512 + + // ── mHC ── + let hcMultiplier: Int // 4 + let hcEpsilon: Float // 1e-6 + let hcSinkhornIterations: Int // 20 + + // ── RoPE ── + let ropeTheta: Float // 10_000 (full + CSA) + let compressRopeTheta: Float // 160_000 (HCA compressed stream) + let yarnFactor: Float // 16 + let yarnOriginalContext: Int // 65_536 + + // ── MoE ── + let nExperts: Int // 288 + let nExpertsPerToken: Int // 6 + let nSharedExperts: Int // 1 + let moeIntermediate: Int // 2048 + let sharedExpertIntermediate: Int // 2048 + let routerBias: Bool // true (noaux_tc) + let routerScalingFactor: Float // 1.5 + let routerScoringFunc: String // "sqrtsoftplus" + + // ── Activation clip ── + let swigluLimit: Float // 10.0 + + // ── MTP ── + let nMTPLayers: Int // 1 + + static func decode(_ tc: ModelConfig) throws -> DeepSeekV4TextConfig { + guard + let nLayers = tc.int("num_hidden_layers"), + let hidden = tc.int("hidden_size"), + let vocab = tc.int("vocab_size"), + let nHeads = tc.int("num_attention_heads") + else { + throw DeepSeekV4Error.missingConfig("text_config core attention shape") + } + let nKVHeads = tc.int("num_key_value_heads") ?? 1 + let headDim = tc.int("head_dim") ?? 512 + let qkRopeHeadDim = tc.int("qk_rope_head_dim") ?? 64 + let qLoraRank = tc.int("q_lora_rank") ?? 1024 + let oLoraRank = tc.int("o_lora_rank") ?? 1024 + let oGroups = tc.int("o_groups") ?? 8 + let maxSeq = + tc.int("max_position_embeddings") + ?? tc.int("model_max_length") ?? 1_048_576 + + // `compress_ratios` schedule. If absent, default to a + // uniformly-full attention stack (which is wrong for DSv4 but + // safe — the loader will reject before forward is reached). + let ratios: [Int] = tc.intArray("compress_ratios") ?? Array(repeating: 0, count: nLayers + 1) + + let slidingWindow = tc.int("sliding_window") ?? 128 + + // Lightning indexer. + let indexerHeads = tc.int("index_n_heads") ?? 64 + let indexerHeadDim = tc.int("index_head_dim") ?? 128 + let indexerTopK = tc.int("index_topk") ?? 512 + + // mHC. + let hcMult = tc.int("hc_mult") ?? 4 + let hcEps = Float(tc.float("hc_eps") ?? 1e-6) + let hcIters = tc.int("hc_sinkhorn_iters") ?? 20 + + // RoPE. + let ropeTheta = Float(tc.float("rope_theta") ?? 10_000) + let compressRopeTheta = Float(tc.float("compress_rope_theta") ?? 160_000) + var yarnFactor: Float = 1.0 + var yarnOriginal = maxSeq + if let rs = tc.nested("rope_scaling") { + if let f = rs["factor"] as? Double { yarnFactor = Float(f) } + if let o = rs["original_max_position_embeddings"] as? Int { yarnOriginal = o } + } + + // MoE. + let nExperts = tc.int("n_routed_experts") ?? tc.int("num_experts") ?? 288 + let nExpertsPerToken = + tc.int("num_experts_per_tok") + ?? tc.int("num_experts_per_token") ?? 6 + let nShared = tc.int("n_shared_experts") ?? 1 + let moeIntermediate = + tc.int("moe_intermediate_size") ?? tc.int("expert_dim") ?? 2048 + let sharedIntermediate = + tc.int("share_expert_dim") + ?? tc.int("shared_expert_intermediate_size") + ?? moeIntermediate + let routerScoringFunc = tc.string("scoring_func") ?? "sqrtsoftplus" + let routerScale = Float(tc.float("routed_scaling_factor") ?? 1.5) + + let swigluLimit = Float(tc.float("swiglu_limit") ?? 10.0) + let nMTPLayers = tc.int("num_nextn_predict_layers") ?? 1 + + return DeepSeekV4TextConfig( + nLayers: nLayers, + hidden: hidden, + vocab: vocab, + maxSeq: maxSeq, + rmsNormEps: Float(tc.float("rms_norm_eps") ?? 1e-6), + nHeads: nHeads, + nKVHeads: nKVHeads, + headDim: headDim, + qkRopeHeadDim: qkRopeHeadDim, + qLoraRank: qLoraRank, + oLoraRank: oLoraRank, + oGroups: oGroups, + layerCompressRatios: ratios, + slidingWindow: slidingWindow, + indexerHeads: indexerHeads, + indexerHeadDim: indexerHeadDim, + indexerTopK: indexerTopK, + hcMultiplier: hcMult, + hcEpsilon: hcEps, + hcSinkhornIterations: hcIters, + ropeTheta: ropeTheta, + compressRopeTheta: compressRopeTheta, + yarnFactor: yarnFactor, + yarnOriginalContext: yarnOriginal, + nExperts: nExperts, + nExpertsPerToken: nExpertsPerToken, + nSharedExperts: nShared, + moeIntermediate: moeIntermediate, + sharedExpertIntermediate: sharedIntermediate, + routerBias: tc.bool("router_bias") ?? true, + routerScalingFactor: routerScale, + routerScoringFunc: routerScoringFunc, + swigluLimit: swigluLimit, + nMTPLayers: nMTPLayers) + } +} + +// ─── Variants ──────────────────────────────────────────────────────── + +/// 284B total / 13B active. The user-runnable size on Apple Silicon +/// today (4-bit weights + dequant load fits in 128 GB unified memory +/// at ~32K context). +public enum DeepSeekV4Flash: DeepSeekV4Variant { + public static var availableCapabilities: Set { [.textIn, .textOut] } + public static var defaultGenerationParameters: GenerationParameters { + // No model-specific maxTokens — falls to the GenerationParameters + // default so callers own generation length. DeepSeek-V3/V4 recommend + // temperature 0.6 / top-p 0.95. + GenerationParameters( + prefillStepSize: 4096, + temperature: 0.6, topP: 0.95, topK: 64, + repetitionPenalty: 1.0) + } + + public static func loadModel( + config: ModelConfig, weights: SafeTensorsBundle, + options: LoadOptions, device: Device + ) throws -> DeepSeekV4Model { + let tc = DeepSeekV4Config.textConfig(config) + _ = try DeepSeekV4TextConfig.decode(tc) + throw DeepSeekV4Error.notYetImplemented("DeepSeekV4Flash safetensors forward path") + } + + public static func loadModelFromGGUF( + config: ModelConfig, gguf: GGUFTensorBundle, + options: LoadOptions, device: Device + ) throws -> DeepSeekV4Model { + let tc = DeepSeekV4Config.textConfig(config) + let textConfig = try DeepSeekV4TextConfig.decode(tc) + // Architecture-string sanity-check now that the reader is + // open. Either form is accepted upstream. + if let arch = gguf.architecture, + !DeepSeekV4.architectures.contains(arch), + arch != "deepseek4" + { + throw DeepSeekV4Error.missingConfig( + "general.architecture='\(arch)' not in DeepSeekV4 known set") + } + return try DeepSeekV4Model.loadFromGGUF( + textConfig: textConfig, gguf: gguf, device: device, options: options) + } + + /// Convenience GGUF loader for benchmarks/CLI that don't have a + /// `ModelConfig` handy. Builds the minimal DSv4-Flash config from + /// known dims and delegates to `loadModelFromGGUF`. + public static func loadFlashFromGGUF( + gguf: GGUFTensorBundle, device: Device, options: LoadOptions = LoadOptions() + ) throws -> DeepSeekV4Model { + let raw: [String: Any] = [ + "hidden_size": 4096, "num_hidden_layers": 43, + "vocab_size": 129_280, "num_attention_heads": 64, + ] + let config = ModelConfig( + architecture: "DeepSeekV4ForCausalLM", modelType: "deepseek4", raw: raw) + return try loadModelFromGGUF( + config: config, gguf: gguf, options: options, device: device) + } +} + +/// ~1.6T / 49B active. Same architecture as Flash, deeper + wider +/// MoE. Not currently runnable on Apple Silicon (weights alone need +/// ~480 GB at 4-bit). Kept here for completeness — the variant +/// dispatch + config decode work today; load throws. +public enum DeepSeekV4Pro: DeepSeekV4Variant { + public static func loadModel( + config: ModelConfig, weights: SafeTensorsBundle, + options: LoadOptions, device: Device + ) throws -> DeepSeekV4Model { + _ = config; _ = weights; _ = options; _ = device + throw DeepSeekV4Error.notYetImplemented("DeepSeekV4Pro safetensors forward path") + } +} + +// ─── DeepSeekV4Model — weight slots ────────────────────────────────── +// +// Tensor inventory mirrors the DSv4-Flash GGUF (see +// `Tests/ModelIntegrationTests/GGUFDsv4TensorMapTest.swift` for the +// full dump). Field names follow the GGUF tensor-name convention so +// the loader is a direct `bundle.tensor(named:"blk.\(N).\(suffix)")` +// dispatch. +// +// Architecture summary: +// +// - Each layer holds an mHC 4-channel residual state H[hidden, 4, t]. +// - Attention sub-block: rms_norm → q_a → q_a_norm → q_b → per-head +// Q-norm (eps-only) → partial RoPE on tail 64 dims of each head; +// kv (single 512-d MQA head) → kv_a_norm → partial RoPE on tail 64 +// dims → optional FP8 quantize on first 448 dims → store in cache. +// Softmax-attention with `attn_sinks` (per-head learnable extra +// logit). Inverse partial RoPE on output. Grouped O-LoRA: reshape +// to [4096, 8 groups] × [4096, 1024] per group → [8192] → wo_b → +// [4096]. +// - FFN sub-block: rms_norm → MoE (256 experts top-6, sqrt-softplus +// routing OR precomputed hash routing via `ffn_gate_tid2eid` on +// the first `n_hash_layers`) + shared-expert SwiGLU. +// - mHC: at each sub-block boundary, `hc_*_fn @ flatten(H)` produces +// a 24-dim mix that splits as 4 `pre` (sigmoid+eps) + 4 `post` +// (2·sigmoid) + 16 (=4×4) `comb` matrix (softmax + Sinkhorn-Knopp +// row/col-normalized). `pre` collapses H → sub-block input; +// `post` + `comb` expand the sub-block output back into H. +// - CSA (compress_ratio=4): adds a Lightning Indexer that scores all +// compressed K-entries against a 64-head × 128-dim Q (sharing the +// same `qr` from the main attn-path) + an attn compressor that +// builds a 4×-pooled (overlap-2) compressed KV stream. Top-512 +// compressed slots feed the sparse-gather attention. +// - HCA (compress_ratio=128): only the attn compressor (no indexer); +// dense attention over the small compressed stream. +// - Layer pattern per the GGUF: 0,1 = full; 2,4,…,42 = CSA; +// 3,5,…,41 = HCA. The `compress_ratios` array on the GGUF metadata +// is authoritative. + +/// One transformer block's worth of weights. Allocated per-layer +/// regardless of compression regime — the regime-specific tensors +/// (compressor, indexer) are nil for layers that don't use them. +public final class DeepSeekV4Layer: @unchecked Sendable { + let layerIndex: Int + let compressRatio: Int // 0 = full, 4 = CSA, 128 = HCA + + // ── Common attention path ── + let attnNorm: Tensor // f32 [hidden] + let attnQA: Tensor // q8_0 [hidden, q_lora_rank] + let attnQANorm: Tensor // f32 [q_lora_rank] + let attnQB: Tensor // q8_0 [q_lora_rank, n_heads * head_dim] + let attnKV: Tensor // q8_0 [hidden, head_dim] (MQA: 1 kv head) + let attnKVANorm: Tensor // f32 [head_dim] + let attnSinks: Tensor // f32 [n_heads] + let attnOutputA: Tensor // q8_0 [group_dim, n_groups * o_lora_rank] + let attnOutputB: Tensor // q8_0 [n_groups * o_lora_rank, hidden] + + // ── FFN path ── + let ffnNorm: Tensor // f32 [hidden] + let ffnGateInp: Tensor // f16 [hidden, n_experts] + let ffnGateTid2Eid: Tensor? // i32 [n_experts_per_token, vocab] — hash-route, nil past n_hash_layers + let ffnGateExps: Tensor // iq2_xxs [hidden, expert_intermediate, n_experts] + let ffnUpExps: Tensor // iq2_xxs [hidden, expert_intermediate, n_experts] + let ffnDownExps: Tensor // q2_K [expert_intermediate, hidden, n_experts] + let ffnGateShexp: Tensor // q8_0 [hidden, shared_expert_intermediate] + let ffnUpShexp: Tensor // q8_0 [hidden, shared_expert_intermediate] + let ffnDownShexp: Tensor // q8_0 [shared_expert_intermediate, hidden] + let expProbsBias: Tensor? // f32 [n_experts] — noaux_tc bias, only on non-hash layers + + // ── mHC weights (attn + ffn sub-blocks) ── + let hcAttnBase: Tensor // f32 [24] + let hcAttnFn: Tensor // f16 [hc_dim, 24] where hc_dim = n_hc * hidden = 4*4096 = 16384 + let hcAttnScale: Tensor // f32 [3] + let hcFfnBase: Tensor // f32 [24] + let hcFfnFn: Tensor // f16 [hc_dim, 24] + let hcFfnScale: Tensor // f32 [3] + + // ── CSA / HCA compressor (compress_ratio > 0) ── + let attnCompressorAPE: Tensor? // f16 [coff * head_dim, ratio] (ratio=4 for CSA, =128 for HCA) + let attnCompressorGate: Tensor? // f16 [hidden, coff * head_dim] + let attnCompressorKV: Tensor? // f16 [hidden, coff * head_dim] + let attnCompressorNorm: Tensor? // f32 [head_dim] + + // ── CSA-only Lightning Indexer (compress_ratio == 4) ── + let indexerAttnQB: Tensor? // f16 [q_lora_rank, indexer_n_heads * indexer_head_size] + let indexerProj: Tensor? // f16 [hidden, indexer_n_heads] + let indexerCompressorAPE: Tensor? // f16 [coff * indexer_head_size, ratio] + let indexerCompressorGate: Tensor? // f16 [hidden, coff * indexer_head_size] + let indexerCompressorKV: Tensor? // f16 [hidden, coff * indexer_head_size] + let indexerCompressorNorm: Tensor? // f32 [indexer_head_size] + + init( + layerIndex: Int, compressRatio: Int, + attnNorm: Tensor, attnQA: Tensor, attnQANorm: Tensor, attnQB: Tensor, + attnKV: Tensor, attnKVANorm: Tensor, attnSinks: Tensor, + attnOutputA: Tensor, attnOutputB: Tensor, + ffnNorm: Tensor, ffnGateInp: Tensor, ffnGateTid2Eid: Tensor?, + ffnGateExps: Tensor, ffnUpExps: Tensor, ffnDownExps: Tensor, + ffnGateShexp: Tensor, ffnUpShexp: Tensor, ffnDownShexp: Tensor, + expProbsBias: Tensor?, + hcAttnBase: Tensor, hcAttnFn: Tensor, hcAttnScale: Tensor, + hcFfnBase: Tensor, hcFfnFn: Tensor, hcFfnScale: Tensor, + attnCompressorAPE: Tensor? = nil, attnCompressorGate: Tensor? = nil, + attnCompressorKV: Tensor? = nil, attnCompressorNorm: Tensor? = nil, + indexerAttnQB: Tensor? = nil, indexerProj: Tensor? = nil, + indexerCompressorAPE: Tensor? = nil, indexerCompressorGate: Tensor? = nil, + indexerCompressorKV: Tensor? = nil, indexerCompressorNorm: Tensor? = nil + ) { + self.layerIndex = layerIndex + self.compressRatio = compressRatio + self.attnNorm = attnNorm; self.attnQA = attnQA; self.attnQANorm = attnQANorm + self.attnQB = attnQB; self.attnKV = attnKV; self.attnKVANorm = attnKVANorm + self.attnSinks = attnSinks + self.attnOutputA = attnOutputA; self.attnOutputB = attnOutputB + self.ffnNorm = ffnNorm; self.ffnGateInp = ffnGateInp; self.ffnGateTid2Eid = ffnGateTid2Eid + self.ffnGateExps = ffnGateExps; self.ffnUpExps = ffnUpExps; self.ffnDownExps = ffnDownExps + self.ffnGateShexp = ffnGateShexp; self.ffnUpShexp = ffnUpShexp; self.ffnDownShexp = ffnDownShexp + self.expProbsBias = expProbsBias + self.hcAttnBase = hcAttnBase; self.hcAttnFn = hcAttnFn; self.hcAttnScale = hcAttnScale + self.hcFfnBase = hcFfnBase; self.hcFfnFn = hcFfnFn; self.hcFfnScale = hcFfnScale + self.attnCompressorAPE = attnCompressorAPE; self.attnCompressorGate = attnCompressorGate + self.attnCompressorKV = attnCompressorKV; self.attnCompressorNorm = attnCompressorNorm + self.indexerAttnQB = indexerAttnQB; self.indexerProj = indexerProj + self.indexerCompressorAPE = indexerCompressorAPE + self.indexerCompressorGate = indexerCompressorGate + self.indexerCompressorKV = indexerCompressorKV + self.indexerCompressorNorm = indexerCompressorNorm + } +} + +/// DSv4 decoder. Holds the shared embedding / output-norm / LM-head / +/// output-mHC weights eagerly + a **lazy per-layer cache**. Layers are +/// loaded on first `layer(_:)` access and can be evicted via +/// `releaseLayer(_:)` so a streaming decoder pipeline keeps only a +/// handful of layers in RAM at a time — the only realistic shape for +/// the 86 GB DSv4-Flash GGUF on Apple Silicon. +public final class DeepSeekV4Model: @unchecked Sendable { + public let textConfig: DeepSeekV4TextConfig + public let layerCompressRatios: [Int] // 0 / 4 / 128 per layer + + // ── Non-block weights, eagerly loaded ── + public let tokenEmbd: Tensor // f16 [hidden, vocab] + public let outputNorm: Tensor // f32 [hidden] + public let outputHead: Tensor // q8_0 [hidden, vocab] + public let outputHcBase: Tensor // f32 [n_hc=4] + public let outputHcFn: Tensor // f16 [hc_dim, n_hc] + public let outputHcScale: Tensor // f32 [1] + + // ── Lazy per-layer cache ── + let bundle: GGUFTensorBundle + let device: Device + let activationDtype: DType + private var layerCache: [Int: DeepSeekV4Layer] = [:] + private let cacheLock = NSLock() + /// Cached `[head_dim]` ones tensor for the per-head unit-RMS Q + /// norm (no learnable weight, so we pass ones to `rmsNormRows`). + var qHeadNormOnesCache: Tensor? + var hcFlatOnesCache: Tensor? + /// Host-side cache of the i32 token→expert hash-routing tables + /// (ffn_gate_tid2eid), keyed by layer index. Read once per layer + /// instead of a 3 MB GPU→CPU copy every token. + var tid2eidCache: [Int: [Int32]] = [:] + /// When `true`, layers loaded by `forwardAllLayers` stay resident. + /// Trades ~8-20 GB RAM for near-zero per-token load cost on token2+. + public var keepLayersResident: Bool = false + /// DEBUG stashes (set by the CLI bench's prompt-IDs debug path; read via + /// optional guards in the forward, no-op when nil). + public var dbgAnorm: Tensor? // [nLayers*4] last-token per-layer attn_norm[0..3] + public var dbgL0: Tensor? // [8] L0 last-token: hcState[0..3], x(pre-norm)[0..3] + + init( + textConfig: DeepSeekV4TextConfig, layerCompressRatios: [Int], + tokenEmbd: Tensor, outputNorm: Tensor, outputHead: Tensor, + outputHcBase: Tensor, outputHcFn: Tensor, outputHcScale: Tensor, + bundle: GGUFTensorBundle, device: Device, activationDtype: DType + ) { + self.textConfig = textConfig + self.layerCompressRatios = layerCompressRatios + self.tokenEmbd = tokenEmbd + self.outputNorm = outputNorm + self.outputHead = outputHead + self.outputHcBase = outputHcBase + self.outputHcFn = outputHcFn + self.outputHcScale = outputHcScale + self.bundle = bundle + self.device = device + self.activationDtype = activationDtype + } + + /// Lazy per-layer loader. First access dequants all tensors for + /// the layer; subsequent accesses return the cached bundle. + /// Thread-safe. + public func layer(_ index: Int) throws -> DeepSeekV4Layer { + cacheLock.lock() + defer { cacheLock.unlock() } + if let cached = layerCache[index] { return cached } + let layer = try DeepSeekV4Model.loadLayer( + index: index, + compressRatio: layerCompressRatios[index], + bundle: bundle, device: device, activationDtype: activationDtype, + textConfig: textConfig) + layerCache[index] = layer + return layer + } + + /// Drop a layer from the cache to free GPU memory. Caller drives + /// the eviction policy (typically: keep the active layer + the + /// next one prefetched, drop everything else). + public func releaseLayer(_ index: Int) { + cacheLock.lock() + defer { cacheLock.unlock() } + layerCache.removeValue(forKey: index) + } + + /// Top-level GGUF → DeepSeekV4Model entry. Eagerly loads the + /// non-block weights + compress_ratios array; layers stay lazy. + static func loadFromGGUF( + textConfig: DeepSeekV4TextConfig, gguf: GGUFTensorBundle, + device: Device, options: LoadOptions + ) throws -> DeepSeekV4Model { + // Activation dtype = f16 to match the GGUF's f16 weights + // (hc_*_fn, indexer.*, compressor.*, token_embd, ffn_gate_inp). + // The q8_0 / q2_K / iq2_xxs dequant kernels narrow into f16 + // at the store boundary so the bulk matmul weights end up + // at the same dtype as the activations. + // DEFAULT f32: required for correct decode/prefill — under f16 the + // 43-layer mHC residual accumulates enough error to flip the argmax + // off the correct token (Tokyo lands #2 by ~0.08 logits). f32 is + // essentially FREE here: decode is weight-bandwidth-bound (84 GB of + // quantized weights), so f32 activations measured ~the same tps as + // f16 (13.8 vs 13.2). Set FFAI_DSV4_ACT_F16=1 to force f16 for perf + // experiments. (Follow-up: selective f32 — f32 residual only — if a + // future kernel makes f16 activations cheaper than f32.) + let activationDtype: DType = + ProcessInfo.processInfo.environment["FFAI_DSV4_ACT_F16"] == "1" ? .f16 : .f32 + // Extract the compress_ratios array from GGUF metadata. The + // key matches the canonical DSv4 GGUF naming convention used + // by the converter. Falls back to inferring from layer-tensor + // presence (CSA layers have indexer.*, HCA layers don't, full + // layers have neither). + let ratios = try resolveCompressRatios(gguf: gguf, nLayers: textConfig.nLayers) + + // ── Eager non-block load ── + let tokenEmbd = try gguf.tensor(named: "token_embd.weight", outDtype: activationDtype, device: device) + let outputNorm = try gguf.tensor(named: "output_norm.weight", outDtype: activationDtype, device: device) + let outputHead = try gguf.tensor(named: "output.weight", outDtype: activationDtype, device: device) + let outputHcBase = try gguf.tensor(named: "output_hc_base.weight", outDtype: .f32, device: device) + let outputHcFn = try gguf.tensor(named: "output_hc_fn.weight", outDtype: activationDtype, device: device) + let outputHcScale = try gguf.tensor(named: "output_hc_scale.weight", outDtype: .f32, device: device) + + return DeepSeekV4Model( + textConfig: textConfig, layerCompressRatios: ratios, + tokenEmbd: tokenEmbd, outputNorm: outputNorm, outputHead: outputHead, + outputHcBase: outputHcBase, outputHcFn: outputHcFn, outputHcScale: outputHcScale, + bundle: gguf, device: device, activationDtype: activationDtype) + } + + /// Returns the per-layer `compress_ratios` array. Prefers the + /// GGUF metadata key; falls back to the structural inference if + /// the key is absent. + private static func resolveCompressRatios( + gguf: GGUFTensorBundle, nLayers: Int + ) throws -> [Int] { + // Try direct metadata keys first (different converters use + // slightly different names). + let keysToTry = [ + "deepseek4.attention.compress_ratios", + "deepseek4.compress_ratios", + "compress_ratios", + ] + for key in keysToTry { + if let arr = gguf.reader.metadataIntArray(key) { + return arr + } + } + // Fallback: infer from per-layer tensor presence. + // CSA → has `blk.N.indexer.attn_q_b.weight` + // HCA → has `blk.N.attn_compressor_kv.weight` but no indexer + // full → has neither + var ratios: [Int] = [] + let names = Set(gguf.reader.tensorInfos.map { $0.name }) + for n in 0 ..< nLayers { + let hasIndexer = names.contains("blk.\(n).indexer.attn_q_b.weight") + let hasCompressor = names.contains("blk.\(n).attn_compressor_kv.weight") + let ratio = hasIndexer ? 4 : (hasCompressor ? 128 : 0) + ratios.append(ratio) + } + return ratios + } + + /// Loads all tensors for one layer. Called by `layer(_:)` on + /// cache miss. The activation-dtype dequant target is fixed at + /// `activationDtype` for the bulk weights, with `.f32` for the + /// per-channel norm + sink + bias scalars (they're tiny and the + /// downstream kernels expect f32). + private static func loadLayer( + index n: Int, compressRatio: Int, + bundle: GGUFTensorBundle, device: Device, activationDtype dt: DType, + textConfig: DeepSeekV4TextConfig + ) throws -> DeepSeekV4Layer { + let p = "blk.\(n)" + // Common attention path. + // RMSNorm weights load at the ACTIVATION dtype so Ops.rmsNorm + // doesn't fail its `x.dtype == weight.dtype` precondition. + // The sink / bias / mHC-base / mHC-scale tensors stay f32 — + // their consumer kernels take f32 inputs regardless of T. + let attnNorm = try bundle.tensor(named: "\(p).attn_norm.weight", outDtype: dt, device: device) + let attnQA = try bundle.tensor(named: "\(p).attn_q_a.weight", outDtype: dt, device: device) + let attnQANorm = try bundle.tensor(named: "\(p).attn_q_a_norm.weight", outDtype: dt, device: device) + let attnQB = try bundle.tensor(named: "\(p).attn_q_b.weight", outDtype: dt, device: device) + let attnKV = try bundle.tensor(named: "\(p).attn_kv.weight", outDtype: dt, device: device) + let attnKVANorm = try bundle.tensor(named: "\(p).attn_kv_a_norm.weight", outDtype: dt, device: device) + let attnSinks = try bundle.tensor(named: "\(p).attn_sinks.weight", outDtype: .f32, device: device) + let attnOutputA = try bundle.tensor(named: "\(p).attn_output_a.weight", outDtype: dt, device: device) + let attnOutputB = try bundle.tensor(named: "\(p).attn_output_b.weight", outDtype: dt, device: device) + + // FFN path. + let ffnNorm = try bundle.tensor(named: "\(p).ffn_norm.weight", outDtype: dt, device: device) + let ffnGateInp = try bundle.tensor(named: "\(p).ffn_gate_inp.weight", outDtype: dt, device: device) + let ffnGateTid2Eid = try? bundle.tensor(named: "\(p).ffn_gate_tid2eid.weight", outDtype: .i32, device: device) + // MoE expert tensors LOADED LAZILY per top-K routed expert in + // forwardFfnSubblock via bundle.dequantExpertSlice. Skip eager + // dequant of all 256 experts/layer (~12 GB wasted). Placeholder + // [1] tensors keep Layer non-nil; forward never reads them. + let ffnGateExps = Tensor.filled(0.0, shape: [1], dtype: dt, device: device) + let ffnUpExps = Tensor.filled(0.0, shape: [1], dtype: dt, device: device) + let ffnDownExps = Tensor.filled(0.0, shape: [1], dtype: dt, device: device) + // persistent: these are kept resident on the Layer; without a + // per-name scratch slot the same-shape gate/up dequants alias to + // one pooled buffer (gate == up == last-loaded). + let ffnGateShexp = try bundle.tensor( + named: "\(p).ffn_gate_shexp.weight", outDtype: dt, device: device, persistent: true) + let ffnUpShexp = try bundle.tensor( + named: "\(p).ffn_up_shexp.weight", outDtype: dt, device: device, persistent: true) + let ffnDownShexp = try bundle.tensor( + named: "\(p).ffn_down_shexp.weight", outDtype: dt, device: device, persistent: true) + let expProbsBias = try? bundle.tensor(named: "\(p).exp_probs_b.bias", outDtype: .f32, device: device) + + // mHC weights. + let hcAttnBase = try bundle.tensor(named: "\(p).hc_attn_base.weight", outDtype: .f32, device: device) + let hcAttnFn = try bundle.tensor(named: "\(p).hc_attn_fn.weight", outDtype: dt, device: device) + let hcAttnScale = try bundle.tensor(named: "\(p).hc_attn_scale.weight", outDtype: .f32, device: device) + let hcFfnBase = try bundle.tensor(named: "\(p).hc_ffn_base.weight", outDtype: .f32, device: device) + let hcFfnFn = try bundle.tensor(named: "\(p).hc_ffn_fn.weight", outDtype: dt, device: device) + let hcFfnScale = try bundle.tensor(named: "\(p).hc_ffn_scale.weight", outDtype: .f32, device: device) + + // CSA / HCA compressor. + var attnCompressorAPE: Tensor? + var attnCompressorGate: Tensor? + var attnCompressorKV: Tensor? + var attnCompressorNorm: Tensor? + if compressRatio > 0 { + attnCompressorAPE = try bundle.tensor( + named: "\(p).attn_compressor_ape.weight", outDtype: dt, device: device) + attnCompressorGate = try bundle.tensor( + named: "\(p).attn_compressor_gate.weight", outDtype: dt, device: device) + attnCompressorKV = try bundle.tensor(named: "\(p).attn_compressor_kv.weight", outDtype: dt, device: device) + attnCompressorNorm = try bundle.tensor( + named: "\(p).attn_compressor_norm.weight", outDtype: dt, device: device) + } + + // CSA-only Lightning Indexer. + var indexerAttnQB: Tensor? + var indexerProj: Tensor? + var indexerCompressorAPE: Tensor? + var indexerCompressorGate: Tensor? + var indexerCompressorKV: Tensor? + var indexerCompressorNorm: Tensor? + if compressRatio == 4 { + indexerAttnQB = try bundle.tensor(named: "\(p).indexer.attn_q_b.weight", outDtype: dt, device: device) + indexerProj = try bundle.tensor(named: "\(p).indexer.proj.weight", outDtype: dt, device: device) + indexerCompressorAPE = try bundle.tensor( + named: "\(p).indexer_compressor_ape.weight", outDtype: dt, device: device) + indexerCompressorGate = try bundle.tensor( + named: "\(p).indexer_compressor_gate.weight", outDtype: dt, device: device) + indexerCompressorKV = try bundle.tensor( + named: "\(p).indexer_compressor_kv.weight", outDtype: dt, device: device) + indexerCompressorNorm = try bundle.tensor( + named: "\(p).indexer_compressor_norm.weight", outDtype: dt, device: device) + } + + _ = textConfig // shape-checking against the config is a follow-up + + return DeepSeekV4Layer( + layerIndex: n, compressRatio: compressRatio, + attnNorm: attnNorm, attnQA: attnQA, attnQANorm: attnQANorm, attnQB: attnQB, + attnKV: attnKV, attnKVANorm: attnKVANorm, attnSinks: attnSinks, + attnOutputA: attnOutputA, attnOutputB: attnOutputB, + ffnNorm: ffnNorm, ffnGateInp: ffnGateInp, ffnGateTid2Eid: ffnGateTid2Eid, + ffnGateExps: ffnGateExps, ffnUpExps: ffnUpExps, ffnDownExps: ffnDownExps, + ffnGateShexp: ffnGateShexp, ffnUpShexp: ffnUpShexp, ffnDownShexp: ffnDownShexp, + expProbsBias: expProbsBias, + hcAttnBase: hcAttnBase, hcAttnFn: hcAttnFn, hcAttnScale: hcAttnScale, + hcFfnBase: hcFfnBase, hcFfnFn: hcFfnFn, hcFfnScale: hcFfnScale, + attnCompressorAPE: attnCompressorAPE, attnCompressorGate: attnCompressorGate, + attnCompressorKV: attnCompressorKV, attnCompressorNorm: attnCompressorNorm, + indexerAttnQB: indexerAttnQB, indexerProj: indexerProj, + indexerCompressorAPE: indexerCompressorAPE, indexerCompressorGate: indexerCompressorGate, + indexerCompressorKV: indexerCompressorKV, indexerCompressorNorm: indexerCompressorNorm) + } +} + +// ─── Config shim ───────────────────────────────────────────────────── + +/// Returns the `text_config` sub-tree on a multimodal V4 conversion +/// (none ship today, but the slot exists upstream); otherwise the +/// top-level config (text-only checkpoint). +enum DeepSeekV4Config { + static func textConfig(_ c: ModelConfig) -> ModelConfig { + c.subConfig("text_config") ?? c + } +} + +// ═══════════════════════════════════════════════════════════════════ +// Decode forward path (was DeepSeekV4Forward.swift — consolidated per the +// one-file-per-model-family convention). +// ═══════════════════════════════════════════════════════════════════ + + +// MARK: - Per-call decode state + +extension DeepSeekV4Model { + /// Sliding-window MQA KV cache for one layer. Holds up to + /// `n_swa=128` 512-d entries; appends grow `swCount` until the + /// cache wraps. Indexing within the window stays in slot order + /// (the SDPA kernel walks `[0..n_visible)` directly). + public final class LayerKVState: @unchecked Sendable { + public var swCache: Tensor // [n_swa, head_dim] + public var swCount: Int + public let nSWA: Int + public let headDim: Int + + // ── CSA/HCA compressor state (compress_ratio != 0 layers) ── + public let compressRatio: Int // 0 / 4 / 8 / 128 + public var compCache: Tensor? // [maxComp, head_dim] compressed long-range KV + public var compCount: Int = 0 + // Rolling per-token projection window (host): coff*ratio rows × width. + // width = coff*head_dim; coff = (ratio==4 ? 2 : 1). + public var compKvWin: [Float] = [] + public var compScoreWin: [Float] = [] + + public init(headDim: Int, nSWA: Int, dtype: DType, compressRatio: Int = 0, maxComp: Int = 0) { + self.swCache = Tensor.empty(shape: [nSWA, headDim], dtype: dtype) + self.swCount = 0 + self.nSWA = nSWA + self.headDim = headDim + self.compressRatio = compressRatio + if compressRatio != 0 { + let coff = compressRatio == 4 ? 2 : 1 + let width = coff * headDim + let rows = coff * compressRatio + self.compKvWin = [Float](repeating: 0, count: rows * width) + // scores init to -inf so unfilled lane-p rows contribute 0. + self.compScoreWin = [Float](repeating: -1e30, count: rows * width) + self.compCache = Tensor.empty(shape: [max(maxComp, 1), headDim], dtype: dtype) + } + } + } + + /// One forward-call decode state. + public final class DecodeState: @unchecked Sendable { + public var layerStates: [LayerKVState] + /// 4-channel mHC residual state, `[n_hc=4, hidden]`. + public var hcState: Tensor + public var position: Int + /// Current input token id — needed by the hash-routed early + /// layers (DSv4 ffn_gate_tid2eid lookup keys on the token id). + public var currentToken: Int = 0 + + public init(layerStates: [LayerKVState], hcState: Tensor, position: Int = 0) { + self.layerStates = layerStates + self.hcState = hcState + self.position = position + } + } + + public func makeDecodeState() -> DecodeState { + let cfg = textConfig + // maxComp: compressed entries we can accumulate. For decode/prefill + // of up to ~a few thousand tokens this is ctx/ratio; size generously. + let maxCtx = 8192 + let states = (0 ..< cfg.nLayers).map { (il: Int) -> LayerKVState in + let ratio = il < layerCompressRatios.count ? layerCompressRatios[il] : 0 + let maxComp = ratio != 0 ? (maxCtx / ratio + 4) : 0 + return LayerKVState( + headDim: cfg.headDim, nSWA: cfg.slidingWindow, dtype: activationDtype, + compressRatio: ratio, maxComp: maxComp) + } + let hc = Tensor.empty(shape: [4, cfg.hidden], dtype: activationDtype) + return DecodeState(layerStates: states, hcState: hc, position: 0) + } +} + +// MARK: - Errors + +enum DeepSeekV4ForwardError: Error, CustomStringConvertible { + case notImplementedForRegime(Int) + var description: String { + switch self { + case .notImplementedForRegime(let r): + return "DSv4 forward path not yet implemented for compress_ratio=\(r)" + } + } +} + +// MARK: - Shape helpers + +extension Tensor { + /// GGUF stores matmul weights as `[n_in_fast, n_out_slow]` in + /// dimensions order, but `Ops.gemv` expects `[n_out, n_in]`. + /// Swap the two dim labels (no data movement — same byte layout, + /// different interpretation). + public func asGgufMatmulWeight() -> Tensor { + precondition(shape.count == 2, "asGgufMatmulWeight: rank must be 2") + return reshaped(to: [shape[1], shape[0]]) + } +} + +// MARK: - Ones-tensor cache (for per-head unit-RMS Q-norm) + +extension DeepSeekV4Model { + /// `[head_dim]` ones tensor, cached lazily on first access so + /// the per-head Q unit-RMS norm has a no-op weight to pass to + /// `Ops.rmsNormRows`. Backed by the generic `Tensor.filled(...)` + /// constructor — any model that needs a constant-valued weight + /// for a no-learnable-weight norm can reuse the same primitive. + fileprivate func qHeadNormOnes(_ dt: DType) -> Tensor { + if let cached = qHeadNormOnesCache { return cached } + // MUST be a persistent (non-scratch) buffer: this tensor is cached + // and reused across every layer/token. Tensor.filled → Tensor.empty + // routes to the scratch slab whenever scratchModeActive is true + // (which it is inside forwardFullAttnSubblock's withScratch), so the + // cached "ones" would alias recycled scratch memory (= whatever + // transient reused that slot, e.g. kvNorm) on the next layer — + // silently corrupting the per-head Q-norm weight. Force real memory. + let wasActive = device.scratchModeActive + device.scratchModeActive = false + let t = Tensor.filled(1.0, shape: [textConfig.headDim], dtype: dt, device: device) + device.scratchModeActive = wasActive + qHeadNormOnesCache = t + return t + } + + /// Host copy of a layer's i32 hash-routing table (token-major, + /// `topK` experts per token), cached on first use. + func tid2eidHost(layerIndex: Int, tensor: Tensor) -> [Int32] { + if let cached = tid2eidCache[layerIndex] { return cached } + let host = tensor.toArray(as: Int32.self) + tid2eidCache[layerIndex] = host + return host + } +} + +// MARK: - Full-attention sub-block forward +// +// ## Infrastructure gaps blocking the runnable body +// +// 1. **Mixed-dtype rmsNorm**. `Ops.rmsNorm` enforces +// `x.dtype == weight.dtype` but DSv4 ships norm weights as f32 +// while activations are f16. Either (a) cast f32 norm weights to +// f16 at load time inside `GGUFTensorBundle.tensor(named:outDtype:)` +// (currently f32/f16/bf16 sources ignore `outDtype` and pass +// through with their on-disk dtype), or (b) widen Ops.rmsNorm to +// accept f32 weight + f16 input. +// +// 2. **No-weight rmsNormRows**. The per-head Q-norm has no learnable +// weight (just `eps`). `Ops.rmsNormRows` requires a `[rowSize]` +// weight tensor. Either allocate a ones tensor once, or add a +// `rmsNormRowsNoWeight` variant. +// +// 3. **Grouped O-LoRA `mul_mat_id`**. `attn_output_a` is a single +// [4096 × 8192] tensor that must be applied as 8 distinct +// [4096 × 1024] slices, each driven by a different [4096] slice +// of the [n_heads × head_dim] attention output. No Ops surface +// today does this without 8 sequential gemvs against +// output-axis-strided weight views — and `slicedRows` only +// slices the leading dim. +// +// 4. **GGUF matmul-weight layout swap**. GGUF dimensions list the +// fast dim first: `[n_in, n_out]`. Ops.gemv expects `[n_out, n_in]`. +// The `Tensor.asGgufMatmulWeight()` helper above swaps the dim +// labels (no data movement). Verified correct for `Ops.gemv` by +// inspection but not yet unit-tested. +// +// 5. **Sliding-window cache append**. `Ops.copy(_:into:)` writes the +// [head_dim] kv_norm into `swCache.slicedRows(start: slot, count: 1)` +// which is shape `[1, head_dim]` — element-count matches but +// dtype precondition may need the slice to be the same dtype as +// src (currently fine, both are activation dtype). Untested. +// +// The decode-state types below are correct as-is; the +// `forwardFullAttnSubblock` function body lives in a working +// branch until the 5 gaps are closed. + +extension DeepSeekV4Model { + + /// Decode one full-attention layer's attention sub-block. + /// Reads `state.hcState` (the 4-channel residual), runs the + /// full-attn block, writes the new 4-channel state back into + /// `state.hcState`, and returns the un-residualised + /// `block_out [hidden]` for downstream introspection. + /// + /// Wired against the real Ops API (`gemv` with GGUF shape-swap, + /// `rmsNorm` for the learnable norms, `dsv4MhcSinkhornSplit / + /// Collapse / Expand` for the mHC dance, `dsv4PartialRope` for + /// the K/Q tail rotation, `dsv4SdpaDecodeD512Sink` for the + /// MQA attention with attn_sinks). + /// + /// Known-incorrect details (deferred to follow-ups) — these matter + /// for numerical + /// correctness but not for "does the dispatch chain compile and + /// run without NaN?": + /// - Per-head Q-norm (eps-only, no learnable weight) is **skipped** + /// — needs a `[head_dim]` ones tensor or a no-weight rms variant. + /// - Grouped O-LoRA collapses to a single 32768 → 8192 → 4096 + /// matmul that **does NOT** apply per-group LoRA-A slices. + /// Output dims match; values are wrong until the proper + /// per-group dispatch lands. + /// YaRN RoPE params for a layer. Full layers (ratio 0): no YaRN. + /// Compressed layers: freq_scale = 1/yarn_factor, ext_factor = 1, and + /// the correction-dim ramp bounds (YaRN rope corr-dims). The + /// YaRN magnitude scale (mscale) cancels to 1 in DSv4, so it's omitted. + func yarnParams(ratio: Int) -> (freqScale: Float, extFactor: Float, corrLow: Float, corrHigh: Float) { + if ratio == 0 { return (1.0, 0.0, 0.0, 0.0) } + let nRot = Float(textConfig.qkRopeHeadDim) + let nCtx = Float(textConfig.yarnOriginalContext) + let base = textConfig.compressRopeTheta + let betaFast: Float = 32, betaSlow: Float = 1 + func corrDim(_ beta: Float) -> Float { + nRot * Foundation.log(nCtx / (beta * 2 * Float.pi)) / (2 * Foundation.log(base)) + } + let low = max(0, Foundation.floor(corrDim(betaFast))) + let high = min(nRot - 1, Foundation.ceil(corrDim(betaSlow))) + return (1.0 / textConfig.yarnFactor, 1.0, low, high) + } + + /// Cached ones tensor [n_hc*hidden] for the no-weight RMSNorm applied + /// to the flattened mHC state BEFORE the hc_*_fn mix projection + /// (`mix = fn @ rms_norm(flat)`). + func hcFlatOnes(_ dt: DType) -> Tensor { + if let c = hcFlatOnesCache { return c } + let n = 4 * textConfig.hidden + let wasActive = device.scratchModeActive + device.scratchModeActive = false + let t = Tensor.filled(1.0, shape: [n], dtype: dt, device: device) + device.scratchModeActive = wasActive + hcFlatOnesCache = t + return t + } + + /// mHC mix = fn @ rms_norm_no_weight(flatH). The RMSNorm over the full + /// flattened mHC state is the step FFAI was missing (it fed raw flatH). + func mhcMix(flatH: Tensor, fnWeight: Tensor, on cmd: MTLCommandBuffer) -> Tensor { + let normed = Ops.rmsNorm( + flatH, weight: hcFlatOnes(flatH.dtype), + eps: textConfig.rmsNormEps, on: cmd) + return Ops.gemv(weight: fnWeight, input: normed, on: cmd) + } + + /// Upload host floats into a tensor of the given dtype. + private func uploadFloats(_ f: [Float], into t: Tensor, dtype: DType) { + switch dtype { + case .f32: t.copyIn(from: f) + case .f16: t.copyIn(from: f.map { Float16($0) }) + case .bf16: + t.copyIn( + from: f.map { (v: Float) -> UInt16 in + let b = v.bitPattern; return UInt16((b &+ 0x7FFF &+ ((b >> 16) & 1)) >> 16) + }) + default: fatalError("uploadFloats: unsupported dtype \(dtype)") + } + } + + /// DSv4 CSA/HCA KV compressor — one streaming step. + /// Projects attn_norm → kv/score rows, rolls + /// the per-token window, and on a `compress_ratio` boundary emits one + /// compressed KV row (per-dim softmax pool → RMSNorm → compressed-RoPE) + /// into `ls.compCache`. Runs on its own committed command buffer and + /// recomputes attn_norm from the (committed) hcState so it doesn't + /// disturb the caller's shared attn/ffn command buffer. + func compressorStepCPU( + layer: DeepSeekV4Layer, state: DecodeState, ls: LayerKVState, + layerTheta: Float, nNope: Int + ) { + let cfg = textConfig; let dt = activationDtype + let hidden = cfg.hidden; let headDim = cfg.headDim + let ratio = ls.compressRatio + let coff = ratio == 4 ? 2 : 1 + let width = coff * headDim + let pos = state.position + let posMod = pos % ratio + let row = ratio == 4 ? (ratio + posMod) : posMod + + // Recompute attn_norm from committed hcState, then project kv/score. + let c = device.makeCommandBuffer() + let flatH = state.hcState.reshaped(to: [4 * hidden]) + let mixes = mhcMix(flatH: flatH, fnWeight: layer.hcAttnFn.asGgufMatmulWeight(), on: c) + let (preA, _, _) = Ops.dsv4MhcSinkhornSplit( + mixes: mixes, scale: layer.hcAttnScale, base: layer.hcAttnBase, + nTokens: 1, eps: cfg.hcEpsilon, sinkhornIters: cfg.hcSinkhornIterations, on: c) + let x = Ops.dsv4MhcCollapse( + state: state.hcState, pre: preA, + hiddenDim: hidden, nHc: 4, nTokens: 1, outDtype: dt, on: c + ).reshaped(to: [hidden]) + let xNorm = Ops.rmsNorm(x, weight: layer.attnNorm, eps: cfg.rmsNormEps, on: c) + let kvCur = Ops.gemv(weight: layer.attnCompressorKV!.asGgufMatmulWeight(), input: xNorm, on: c) + let scCur = Ops.gemv(weight: layer.attnCompressorGate!.asGgufMatmulWeight(), input: xNorm, on: c) + c.commit(); c.waitUntilCompleted() + let kvH = kvCur.toFloatArray(); let scH = scCur.toFloatArray() + let apeH = layer.attnCompressorAPE!.toFloatArray() // [ratio, width], j fast + + // Store projected rows into the rolling window (+ APE on score). + for j in 0 ..< width { + ls.compKvWin[row * width + j] = kvH[j] + ls.compScoreWin[row * width + j] = scH[j] + apeH[posMod * width + j] + } + if (pos + 1) % ratio != 0 { return } + + // Per-dimension softmax pool. + let negHalf: Float = -1e30 * 0.5 + var pooled = [Float](repeating: 0, count: headDim) + for j in 0 ..< headDim { + var mx = -Float.infinity + if ratio == 4 { + for r in 0 ..< ratio { + mx = max(mx, ls.compScoreWin[r * width + j]) + mx = max(mx, ls.compScoreWin[(ratio + r) * width + headDim + j]) + } + } else { + for r in 0 ..< ratio { mx = max(mx, ls.compScoreWin[r * width + j]) } + } + if mx <= negHalf { continue } + var denom: Float = 0, sum: Float = 0 + if ratio == 4 { + for r in 0 ..< ratio { + let wp = expf(ls.compScoreWin[r * width + j] - mx) + let wc = expf(ls.compScoreWin[(ratio + r) * width + headDim + j] - mx) + denom += wp + wc + sum += wp * ls.compKvWin[r * width + j] + sum += wc * ls.compKvWin[(ratio + r) * width + headDim + j] + } + } else { + for r in 0 ..< ratio { + let w = expf(ls.compScoreWin[r * width + j] - mx) + denom += w; sum += w * ls.compKvWin[r * width + j] + } + } + pooled[j] = denom > 0 ? sum / denom : 0 + } + // RMSNorm(compressor_norm). + let normH = layer.attnCompressorNorm!.toFloatArray() + var ss = 0.0; for v in pooled { ss += Double(v) * Double(v) } + let rms = Float(1.0 / ((ss / Double(headDim)) + Double(cfg.rmsNormEps)).squareRoot()) + var outComp = [Float](repeating: 0, count: headDim) + for i in 0 ..< headDim { outComp[i] = pooled[i] * rms * normH[i] } + + guard let cc = ls.compCache, ls.compCount < cc.shape[0] else { return } + let dst = cc.slicedRows(start: ls.compCount, count: 1).reshaped(to: [headDim]) + uploadFloats(outComp, into: dst, dtype: dt) + let compPos = pos + 1 - ratio + if compPos > 0 { + let yp = yarnParams(ratio: ratio) + let rc = device.makeCommandBuffer() + Ops.dsv4PartialRope( + qk: dst, out: dst, nHeads: 1, headDim: headDim, nNope: nNope, + position: compPos, thetaBase: layerTheta, inverse: false, + freqScale: yp.freqScale, extFactor: yp.extFactor, corrLow: yp.corrLow, corrHigh: yp.corrHigh, + on: rc) + rc.commit(); rc.waitUntilCompleted() + } + ls.compCount += 1 + + // Slide CSA two-lane window: lane-c → lane-p, then lane-p → lane-c. + if ratio == 4 { + for r in 0 ..< ratio { + for j in 0 ..< width { + ls.compKvWin[r * width + j] = ls.compKvWin[(ratio + r) * width + j] + ls.compScoreWin[r * width + j] = ls.compScoreWin[(ratio + r) * width + j] + } + } + for r in 0 ..< ratio { + for j in 0 ..< width { + ls.compKvWin[(ratio + r) * width + j] = ls.compKvWin[r * width + j] + ls.compScoreWin[(ratio + r) * width + j] = ls.compScoreWin[r * width + j] + } + } + } + } + + public func forwardFullAttnSubblock( + layer: DeepSeekV4Layer, state: DecodeState, on cmd: MTLCommandBuffer + ) -> Tensor { + let cfg = textConfig + let dt = activationDtype + let hidden = cfg.hidden + let headDim = cfg.headDim + let qLoraRank = cfg.qLoraRank + let nHeads = cfg.nHeads + let qkRopeDim = cfg.qkRopeHeadDim + let nNope = headDim - qkRopeDim + // Per-layer RoPE base: compressed layers (compress_ratio != 0, i.e. + // CSA ratio-4 and HCA ratio-128) use compress_rope_theta=160000; + // full layers (0, 1, 42 → ratio 0) use rope_theta=10000. + // ratio != 0 ⇒ 160000. + let li = layer.layerIndex + let layerRatio = li < layerCompressRatios.count ? layerCompressRatios[li] : 0 + let layerTheta = layerRatio != 0 ? cfg.compressRopeTheta : cfg.ropeTheta + let yp = yarnParams(ratio: layerRatio) + + // ── mHC pre/post/comb split ── + // mixes = hc_attn_fn @ flatten(H) → [24] + let flatH = state.hcState.reshaped(to: [4 * hidden]) + let hcAttnFnW = layer.hcAttnFn.asGgufMatmulWeight() + let mixes = mhcMix(flatH: flatH, fnWeight: hcAttnFnW, on: cmd) + let (preAttn, postAttn, combAttn) = Ops.dsv4MhcSinkhornSplit( + mixes: mixes, scale: layer.hcAttnScale, base: layer.hcAttnBase, + nTokens: 1, eps: cfg.hcEpsilon, sinkhornIters: cfg.hcSinkhornIterations, + on: cmd) + + // ── mHC collapse: H[4, hidden] → x[hidden] (drop n_tokens=1 dim) ── + let xWithTokens = Ops.dsv4MhcCollapse( + state: state.hcState, pre: preAttn, + hiddenDim: hidden, nHc: 4, nTokens: 1, outDtype: dt, on: cmd) + let x = xWithTokens.reshaped(to: [hidden]) + + // ── attn_norm ── + let xNorm = Ops.rmsNorm(x, weight: layer.attnNorm, eps: cfg.rmsNormEps, on: cmd) + if let da = dbgAnorm, li * 4 + 4 <= da.elementCount { + // capture x (pre-norm collapse) per layer + Ops.copy( + x.slicedRows(start: 0, count: 4), + into: da.slicedRows(start: li * 4, count: 4), on: cmd) + } + + // ── Q low-rank chain: x → q_a → q_a_norm → q_b ── + // q_a/q_b/kv/output_* are Q8_0 on disk — gemv straight from + // resident Q8 (1 byte/weight) instead of the f16-expanded copy, + // halving read bandwidth (attn is bandwidth-bound). + let useQ8Attn = ProcessInfo.processInfo.environment["FFAI_DSV4_Q8ATTN"] != "0" + let qA: Tensor + if useQ8Attn, let qaQ8 = try? bundle.residentQ8("blk.\(layer.layerIndex).attn_q_a.weight", device: device) { + qA = Tensor.empty(shape: [qaQ8.mOut], dtype: dt) + Ops.gemvQ8(q8: qaQ8, x: xNorm, on: cmd, into: qA) + } else { + qA = Ops.gemv(weight: layer.attnQA.asGgufMatmulWeight(), input: xNorm, on: cmd) + } + let qANorm = Ops.rmsNorm(qA, weight: layer.attnQANorm, eps: cfg.rmsNormEps, on: cmd) + if let d0 = dbgL0, li == 0, d0.elementCount >= 16 { + Ops.copy(qANorm.slicedRows(start: 0, count: 4), into: d0.slicedRows(start: 12, count: 4), on: cmd) + } + let q: Tensor + if useQ8Attn, let qbQ8 = try? bundle.residentQ8("blk.\(layer.layerIndex).attn_q_b.weight", device: device) { + q = Tensor.empty(shape: [qbQ8.mOut], dtype: dt) + Ops.gemvQ8(q8: qbQ8, x: qANorm, on: cmd, into: q) + } else { + q = Ops.gemv(weight: layer.attnQB.asGgufMatmulWeight(), input: qANorm, on: cmd) + } + // Per-head unit-RMS Q-norm: normalize each [head_dim] row + // independently with no learnable weight. Pass a ones-tensor + // of shape [head_dim] cached on the model. + Ops.rmsNormRows( + q, weight: qHeadNormOnes(dt), eps: cfg.rmsNormEps, + nRows: nHeads, rowSize: headDim, on: cmd, into: q) + + // ── Partial RoPE on Q tail ── + let qRoped = Tensor.empty(shape: q.shape, dtype: dt) + Ops.copy(q, into: qRoped, on: cmd) + Ops.dsv4PartialRope( + qk: qRoped, out: qRoped, + nHeads: nHeads, headDim: headDim, nNope: nNope, + position: state.position, thetaBase: layerTheta, inverse: false, freqScale: yp.freqScale, + extFactor: yp.extFactor, corrLow: yp.corrLow, corrHigh: yp.corrHigh, on: cmd) + + // ── KV down-projection + norm + partial RoPE ── + let kv: Tensor + if useQ8Attn, let kvQ8 = try? bundle.residentQ8("blk.\(layer.layerIndex).attn_kv.weight", device: device) { + kv = Tensor.empty(shape: [kvQ8.mOut], dtype: dt) + Ops.gemvQ8(q8: kvQ8, x: xNorm, on: cmd, into: kv) + } else { + kv = Ops.gemv(weight: layer.attnKV.asGgufMatmulWeight(), input: xNorm, on: cmd) + } + let kvNorm = Ops.rmsNorm(kv, weight: layer.attnKVANorm, eps: cfg.rmsNormEps, on: cmd) + Ops.dsv4PartialRope( + qk: kvNorm, out: kvNorm, + nHeads: 1, headDim: headDim, nNope: nNope, + position: state.position, thetaBase: layerTheta, inverse: false, freqScale: yp.freqScale, + extFactor: yp.extFactor, corrLow: yp.corrLow, corrHigh: yp.corrHigh, on: cmd) + + // ── Append to sliding-window cache ── + let layerState = state.layerStates[layer.layerIndex] + let slot = layerState.swCount % layerState.nSWA + Ops.copy(kvNorm, into: layerState.swCache.slicedRows(start: slot, count: 1), on: cmd) + layerState.swCount += 1 + let nVisible = min(layerState.swCount, layerState.nSWA) + + // ── CSA/HCA compressor: update the compressed long-range KV stream + // (runs on its own committed cmd; recomputes attn_norm from the + // committed hcState — see compressorStepCPU). ── + if layerState.compressRatio != 0 { + compressorStepCPU( + layer: layer, state: state, ls: layerState, + layerTheta: layerTheta, nNope: nNope) + } + + // ── MQA SDPA with attn_sinks over [raw sliding window ∪ compressed + // rows]. CSA/HCA: dense over all compressed entries (no indexer top-k + // needed while n_comp ≤ index_topk=512). ── + let scale = 1.0 / Float(headDim).squareRoot() + let nComp = layerState.compressRatio != 0 ? layerState.compCount : 0 + let kvBuf: Tensor + let nKvTotal: Int + if nComp > 0, let cc = layerState.compCache { + let total = nVisible + nComp + let combined = Tensor.empty(shape: [total, headDim], dtype: dt) + Ops.copy( + layerState.swCache.slicedRows(start: 0, count: nVisible), + into: combined.slicedRows(start: 0, count: nVisible), on: cmd) + Ops.copy( + cc.slicedRows(start: 0, count: nComp), + into: combined.slicedRows(start: nVisible, count: nComp), on: cmd) + kvBuf = combined; nKvTotal = total + } else { + kvBuf = layerState.swCache.slicedRows(start: 0, count: nVisible) + nKvTotal = nVisible + } + let attnOut = Ops.dsv4SdpaDecodeD512Sink( + q: qRoped, k: kvBuf, v: kvBuf, sinkLogit: layer.attnSinks, + nQHeads: nHeads, nKvHeads: 1, headDim: headDim, + nKv: nKvTotal, kvStride: nKvTotal, + scale: scale, outDtype: dt, on: cmd) + + if let d0 = dbgL0, li == 0, d0.elementCount >= 16 { + Ops.copy(qRoped.slicedRows(start: 0, count: 4), into: d0.slicedRows(start: 0, count: 4), on: cmd) + Ops.copy(kvNorm.slicedRows(start: 0, count: 4), into: d0.slicedRows(start: 4, count: 4), on: cmd) + Ops.copy( + attnOut.reshaped(to: [nHeads * headDim]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 8, count: 4), on: cmd) + } + // ── Inverse partial RoPE on attention output ── + Ops.dsv4PartialRope( + qk: attnOut, out: attnOut, + nHeads: nHeads, headDim: headDim, nNope: nNope, + position: state.position, thetaBase: layerTheta, inverse: true, freqScale: yp.freqScale, + extFactor: yp.extFactor, corrLow: yp.corrLow, corrHigh: yp.corrHigh, on: cmd) + + // ── Grouped O-LoRA: 8 groups × [4096, 1024] then [8192, 4096] ── + // Reshape attnOut [n_heads, head_dim] → [n_groups, group_dim] + // = [8, 4096]. Each group consumes a different LoRA-A slice; + // since attn_output_a is stored [n_in=4096, n_out=8192] in + // GGUF (= [8192, 4096] after the as-weight swap), and the + // 8192-output-dim is the **concatenation of 8 × 1024 + // per-group LoRA-A outputs**, the per-group dispatch is: + // oLow[g, :1024] = wA[g, :1024, :] @ attnOut_group[g, :] + // 8 sequential gemvs, each [1024, 4096], with weight slice + // taken as a row-range of the swapped weight tensor (axis-0 + // slicing — contiguous and supported by `slicedRows`). + let oGroups = 8 + let groupDim = (nHeads * headDim) / oGroups // 4096 + let oLoraRank = cfg.oLoraRank // 1024 + let attnOutGrouped = attnOut.reshaped(to: [oGroups, groupDim]) + let oLow = Tensor.empty(shape: [oGroups * oLoraRank], dtype: dt) + let outputAW = layer.attnOutputA.asGgufMatmulWeight() // [8192, 4096] + let oaQ8 = + useQ8Attn ? try? bundle.residentQ8("blk.\(layer.layerIndex).attn_output_a.weight", device: device) : nil + if let oaQ8 = oaQ8 { + // One grouped Q8 gemv: row block g reads attnOutGrouped[g]. + // attnOut is already the contiguous [oGroups * groupDim] + // input the grouped kernel expects. + Ops.groupedGemvQ8( + q8: oaQ8, x: attnOut, rowsPerGroup: oLoraRank, on: cmd, into: oLow) + } else { + for g in 0 ..< oGroups { + let inputSlice = attnOutGrouped.slicedRows(start: g, count: 1).reshaped(to: [groupDim]) + let outSlice = oLow.slicedRows(start: g * oLoraRank, count: oLoraRank) + let weightSlice = outputAW.slicedRows(start: g * oLoraRank, count: oLoraRank) + _ = Ops.gemv(weight: weightSlice, input: inputSlice, on: cmd, into: outSlice) + } + } + let blockOut: Tensor + if useQ8Attn, let obQ8 = try? bundle.residentQ8("blk.\(layer.layerIndex).attn_output_b.weight", device: device) + { + blockOut = Tensor.empty(shape: [obQ8.mOut], dtype: dt) + Ops.gemvQ8(q8: obQ8, x: oLow, on: cmd, into: blockOut) + } else { + blockOut = Ops.gemv( + weight: layer.attnOutputB.asGgufMatmulWeight(), input: oLow, on: cmd) + } + + // ── mHC expand: write new 4-channel state ── + let newH = Ops.dsv4MhcExpand( + blockOut: blockOut, post: postAttn, comb: combAttn, + residualState: state.hcState, + hiddenDim: hidden, nHc: 4, nTokens: 1, on: cmd) + state.hcState = newH + if let d0 = dbgL0, li == 0, d0.elementCount >= 24 { + Ops.copy( + blockOut.reshaped(to: [hidden]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 16, count: 4), on: cmd) + Ops.copy( + newH.reshaped(to: [4 * hidden]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 20, count: 4), on: cmd) + } + return blockOut + } + + /// FFN sub-block — runs the mHC dance + RMS norm + MoE-top-6 + + /// shared expert + mHC expand. Single token, decode mode. + /// + /// Selection path: full sqrtsoftplus router scoring → top-6 via + /// CPU readback (no GPU `argpartition` Op yet, so this is the + /// quick-correct path). Expert dispatch is 6 × 3 gemvs against + /// per-expert slices of the [n_experts, intermediate, hidden] + /// tensors. Combine = weighted sum of expert outputs by + /// `score_unbiased * routed_scaling_factor`, plus the + /// always-on shared expert. + public static func resetFfnProf() { + Self.profRouter = 0 + Self.profExpertRecord = 0 + Self.profSharedRecord = 0 + Self.profGpuWait = 0 + Self.resetGpuProf() + } + public func forwardFfnSubblock( + layer: DeepSeekV4Layer, state: DecodeState, on cmd: MTLCommandBuffer, + copyHcInto outHc: Tensor? = nil + ) throws -> Tensor { + let _tFfnStart = CACurrentMediaTime() + let cfg = textConfig + let dt = activationDtype + let hidden = cfg.hidden + let intermediate = cfg.moeIntermediate + // ffnGateExps is a placeholder [1] in the lazy-load world. + // ffnGateInp is shape [hidden, n_experts] — last dim is the + // real expert count. Prefer it over cfg, which falls back to + // a generic default (288) when the gguf metadata doesn't + // expose `n_routed_experts` and so disagrees with this gguf + // (256-expert variant of DSv4-Flash). + let nExperts = layer.ffnGateInp.shape.last ?? cfg.nExperts + let topK = cfg.nExpertsPerToken + let scaling = cfg.routerScalingFactor + + // ── mHC pre/post/comb split ── + let flatH = state.hcState.reshaped(to: [4 * hidden]) + let hcFnW = layer.hcFfnFn.asGgufMatmulWeight() + let mixes = mhcMix(flatH: flatH, fnWeight: hcFnW, on: cmd) + let (preFfn, postFfn, combFfn) = Ops.dsv4MhcSinkhornSplit( + mixes: mixes, scale: layer.hcFfnScale, base: layer.hcFfnBase, + nTokens: 1, eps: cfg.hcEpsilon, sinkhornIters: cfg.hcSinkhornIterations, + on: cmd) + + // ── mHC collapse + ffn_norm ── + let xWithTokens = Ops.dsv4MhcCollapse( + state: state.hcState, pre: preFfn, + hiddenDim: hidden, nHc: 4, nTokens: 1, outDtype: dt, on: cmd) + let x = xWithTokens.reshaped(to: [hidden]) + let xNorm = Ops.rmsNorm(x, weight: layer.ffnNorm, eps: cfg.rmsNormEps, on: cmd) + if let d0 = dbgL0, layer.layerIndex == 0, d0.elementCount >= 56 { + // xNorm[2048..2051] right after rmsNorm (compare to expert-gemv-time value) + Ops.copy(xNorm.slicedRows(start: 2048, count: 4), into: d0.slicedRows(start: 52, count: 4), on: cmd) + } + // ── Router scoring: logits = ffn_gate_inp @ xNorm ── + let routerLogits = Ops.gemv( + weight: layer.ffnGateInp.asGgufMatmulWeight(), input: xNorm, on: cmd) + // The sqrtsoftplus router Op takes f32 logits + bias and writes + // f32 score_unbiased + score_biased. Routerlogits is `dt` + // (activation dtype). Cast to f32 first. + let routerLogitsF32 = Tensor.empty(shape: routerLogits.shape, dtype: .f32) + Ops.castToF32(routerLogits, into: routerLogitsF32, on: cmd) + let bias: Tensor + if let b = layer.expProbsBias { + bias = b + } else { + bias = Tensor.filled(0.0, shape: [nExperts], dtype: .f32, device: device) + } + let (scoreUnbiased, scoreBiased) = Ops.dsv4MoeRouterSqrtsoftplus( + logits: routerLogitsF32, bias: bias, on: cmd) + + // ── Sync-free GPU routing fast path ── + // Once the resident expert pools for this layer are built (the + // first token fills them via the CPU path below), route entirely + // on the GPU: top-K + weights via mt_dsv4_router_topk, raw→slot + // remap, then the gather dispatches — NO per-layer + // waitUntilCompleted. Removes 43 CPU↔GPU round-trips/token. + // Correct when the routed experts are all pool-resident (always, + // post-warmup, for a fixed prompt); a pool miss reads slot 0 + // (same timing — see PLAN.md §9 caveat). + let gateNameF = "blk.\(layer.layerIndex).ffn_gate_exps.weight" + let upNameF = "blk.\(layer.layerIndex).ffn_up_exps.weight" + let downNameF = "blk.\(layer.layerIndex).ffn_down_exps.weight" + if ProcessInfo.processInfo.environment["FFAI_DSV4_GPUROUTER"] != "0", topK == 6, + layer.ffnGateTid2Eid == nil, // hash-routed layers select via token→expert table, not GPU top-k + let rg = bundle.builtIQ2(gateNameF), + let ru = bundle.builtIQ2(upNameF), + let rd = bundle.builtQ2K(downNameF) + { + return try gpuRoutedFfnTail( + layer: layer, state: state, xNorm: xNorm, + scoreBiased: scoreBiased, scoreUnbiased: scoreUnbiased, + rg: rg, ru: ru, rd: rd, nExperts: nExperts, topK: topK, + hidden: hidden, intermediate: intermediate, dt: dt, + postFfn: postFfn, combFfn: combFfn, attnCmd: cmd, + copyHcInto: outHc) + } + + // CPU-side top-K selection. Sync flush, readback, argpartition. + // (Also the warmup pass that builds the resident pools.) + DeepSeekV4Model.commitWithProfile(cmd, tag: "attn+router") + cmd.waitUntilCompleted() + // EXPERIMENT (FFAI_DSV4_Q8K_ACT=1): replicate the reference's Q8_K + // activation quant on the expert input. The IQ2/Q2_K experts are + // imatrix-calibrated assuming Q8_K activations (the reference + // quantize x → Q8_K before the 2-bit dot); FFAI's f16 activation + // deviates. Round-trip xNorm through Q8_K (per-256-block, + // iscale=-128/max) in place so the expert gemvs see the same + // rounded activation. Router already consumed the f16 xNorm above. + if ProcessInfo.processInfo.environment["FFAI_DSV4_Q8K_ACT"] == "1" { + var h = xNorm.toFloatArray() + let n = h.count + var i = 0 + while i < n { + let end = Swift.min(i + 256, n) + var maxv: Float = 0, amax: Float = 0 + for j in i ..< end { let a = Swift.abs(h[j]); if a > amax { amax = a; maxv = h[j] } } + if amax > 0 { + let iscale = -128.0 / maxv + let d = 1.0 / iscale + for j in i ..< end { + var q = (iscale * h[j]).rounded() + if q > 127 { q = 127 } + h[j] = Float(q) * Float(d) + } + } + i = end + } + xNorm.copyIn(from: h.map { Float16($0) }) + } + let biasedHost = scoreBiased.toArray(as: Float.self) + let unbiasedHost = scoreUnbiased.toArray(as: Float.self) + let topIndices: [Int] + if let tid2eid = layer.ffnGateTid2Eid { + // ── Hash routing (DSv4 early layers, il < n_hash_layer=3) ── + // The selected experts come from a precomputed token→expert + // table (ffn_gate_tid2eid, i32 [n_expert_used, vocab], stored + // token-major so token t's experts are at [t*topK ..< t*topK+topK]). + // The router logits are NOT used for selection here — only for + // the combine weights (probs at the hash-selected experts). + let table = self.tid2eidHost(layerIndex: layer.layerIndex, tensor: tid2eid) + let tok = state.currentToken + topIndices = (0 ..< topK).map { Int(table[tok * topK + $0]) } + } else { + var indexed = Array(biasedHost.enumerated()) + indexed.sort { $0.element > $1.element } + topIndices = Array(indexed.prefix(topK)).map { $0.offset } + } + // routed_scaling_factor applied AFTER the sum-to-1 renorm (DSv4 + // reference order); applying it before (unbiased*scaling) cancels + // in the division and drops the 1.5× entirely. Hash and top-k + // layers share this weight formula: probs[selected]/sum * scale. + let topWeights = topIndices.map { unbiasedHost[$0] } + let weightSum = topWeights.reduce(0, +) + let normWeights: [Float] = + weightSum > 0 + ? topWeights.map { ($0 / weightSum) * scaling } + : Array(repeating: scaling / Float(topK), count: topK) + if dbgL0 != nil, layer.layerIndex == 0 { + let xn = xNorm.toFloatArray() + FileHandle.standardError.write( + Data( + String( + format: + "[dsv4bench] FFL0ROUTER experts=\(topIndices) tw=\(topWeights.map{String(format:"%.5f",$0)}) ffn_norm=%.5f,%.5f,%.5f,%.5f\n", + xn[0], xn[1], xn[2], xn[3] + ).utf8)) + } + + // ── Expert dispatch ── + // gate_exps / up_exps: [hidden, intermediate, n_experts] + // → reshape [n_experts, intermediate, hidden] (no data move, + // fast/slow swap), slice expert e, get [intermediate, hidden] + // = [n_out, n_in] which Ops.gemv accepts directly. + // down_exps: [intermediate, hidden, n_experts] + // → reshape [n_experts, hidden, intermediate], slice e, + // [hidden, intermediate] = [n_out, n_in]. + // GPU-side accumulator: moeOut += w_k * expert_out_k for each + // of topK experts, then + shared-expert output. Uses Ops.add + // (vector_add) to keep the chain on-GPU — no per-expert + // CPU sync. + // **Lazy per-expert dequant** — dequant only the top-K=6 + // routed experts here, not all 256 at layer load. 42× less + // dequant work per token (≤2 GB / layer vs ~12 GB eager, + // ≤16 MB per expert × 6 experts × 3 roles). + // + // Each (iter k, role) gets its own pool slot so a later + // iter's dequant can't overwrite an earlier iter's already- + // encoded gemv input before cmd2.commit. Slots reused across + // layers (cmd2 commits + waits at end of FFN, slabs are free + // when the next layer's FFN starts). + let _tRouter = CACurrentMediaTime() + DeepSeekV4Model.profRouter += _tRouter - _tFfnStart + let gateName = "blk.\(layer.layerIndex).ffn_gate_exps.weight" + let upName = "blk.\(layer.layerIndex).ffn_up_exps.weight" + let downName = "blk.\(layer.layerIndex).ffn_down_exps.weight" + _ = intermediate + let moeAccum = Tensor.filled(0.0, shape: [hidden], dtype: dt, device: device) + let env = ProcessInfo.processInfo.environment + let useFusedDown = topK == 6 && env["FFAI_DSV4_FUSED_DOWN"] == "1" + let useFusedEpilogue = + topK == 6 + && !useFusedDown + && env["FFAI_DSV4_FUSED_EPILOGUE"] == "1" + // PER-EXPERT 3-stage pipeline: gate / up / down on separate cmd + // buffers. gate+up are independent of each other so CPU staging + // for up can run concurrent with GPU running gate's dequant + + // gemv. swiglu fuses on cmdAB after both gate+up finish. + var pipeGate: [MTLCommandBuffer] = [] + var pipeUp: [MTLCommandBuffer] = [] + var pipeB: [MTLCommandBuffer] = [] + var gateOuts: [Tensor?] = Array(repeating: nil, count: topK) + var upOuts: [Tensor?] = Array(repeating: nil, count: topK) + for _ in 0 ..< topK { + pipeGate.append(device.makeCommandBuffer()) + pipeUp.append(device.makeCommandBuffer()) + pipeB.append(device.makeCommandBuffer()) + } + func recordExpertGate(_ k: Int) throws { + let e = topIndices[k] + let cmd = pipeGate[k] + let gateWp = try bundle.dequantExpertSliceOnto( + named: gateName, expertIdx: e, nExperts: nExperts, + slot: "k\(k)_gate", outDtype: dt, device: device, on: cmd) + // TEST: copy the pooled dequant into a fresh contiguous tensor + // before the gemv to rule out a stride/offset metadata issue. + let gateW = gateWp + gateOuts[k] = Ops.gemv(weight: gateW.asGgufMatmulWeight(), input: xNorm, on: cmd) + if let d0 = dbgL0, layer.layerIndex == 0, k == 0, d0.elementCount >= 52 { + Ops.copy( + gateOuts[k]!.reshaped(to: [gateOuts[k]!.elementCount]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 48, count: 4), on: cmd) + } + } + func recordExpertUp(_ k: Int) throws { + let e = topIndices[k] + let cmd = pipeUp[k] + let upW = try bundle.dequantExpertSliceOnto( + named: upName, expertIdx: e, nExperts: nExperts, + slot: "k\(k)_up", outDtype: dt, device: device, on: cmd) + upOuts[k] = Ops.gemv(weight: upW.asGgufMatmulWeight(), input: xNorm, on: cmd) + if let d0 = dbgL0, layer.layerIndex == 0, k == 0, d0.elementCount >= 56 { + Ops.copy( + upOuts[k]!.reshaped(to: [upOuts[k]!.elementCount]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 52, count: 4), on: cmd) + } + } + func recordExpertB(_ k: Int, on cmd: MTLCommandBuffer) throws { + let e = topIndices[k] + let w = normWeights[k] + let inner = Ops.swigluLimit(gate: gateOuts[k]!, up: upOuts[k]!, limit: textConfig.swigluLimit, on: cmd) + let downW = try bundle.dequantExpertSliceOnto( + named: downName, expertIdx: e, nExperts: nExperts, + slot: "k\(k)_down", outDtype: dt, device: device, on: cmd) + let expertOut = Ops.gemv( + weight: downW.asGgufMatmulWeight(), input: inner, on: cmd) + if let d0 = dbgL0, layer.layerIndex == 0, k == 0, d0.elementCount >= 52 { + Ops.copy( + expertOut.reshaped(to: [expertOut.elementCount]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 48, count: 4), on: cmd) + } + let wT = Tensor.filled(w, shape: [hidden], dtype: dt, device: device) + let scaled = Ops.mul(expertOut, wT, on: cmd) + _ = Ops.add(moeAccum, scaled, on: cmd, into: moeAccum) + } + func recordExpertDownOnly(_ k: Int, on cmd: MTLCommandBuffer) throws -> Tensor { + let e = topIndices[k] + let inner = Ops.swigluLimit(gate: gateOuts[k]!, up: upOuts[k]!, limit: textConfig.swigluLimit, on: cmd) + let downW = try bundle.dequantExpertSliceOnto( + named: downName, expertIdx: e, nExperts: nExperts, + slot: "k\(k)_down", outDtype: dt, device: device, on: cmd) + return Ops.gemv(weight: downW.asGgufMatmulWeight(), input: inner, on: cmd) + } + func recordFusedEpilogue(on cmd: MTLCommandBuffer) throws { + var expertOuts: [Tensor?] = Array(repeating: nil, count: topK) + for k in 0 ..< (topK - 1) { + expertOuts[k] = try recordExpertDownOnly(k, on: pipeB[k]) + DeepSeekV4Model.commitWithProfile(pipeB[k], tag: "expert_down") + } + expertOuts[topK - 1] = try recordExpertDownOnly(topK - 1, on: cmd) + + var scalars: [Tensor] = [] + var values: [Tensor] = [] + scalars.reserveCapacity(8) + values.reserveCapacity(8) + for k in 0 ..< topK { + scalars.append(Tensor.filled(normWeights[k], shape: [1], dtype: dt, device: device)) + values.append(expertOuts[k]!) + } + let zeroScalar = Tensor.filled(0.0, shape: [1], dtype: dt, device: device) + let zeroValue = Tensor.filled(0.0, shape: [hidden], dtype: dt, device: device) + while scalars.count < 8 { + scalars.append(zeroScalar) + values.append(zeroValue) + } + Ops.scalarFMAChain8(scalars: scalars, values: values, out: moeAccum, on: cmd) + } + func recordFusedRoutedDown(on cmd: MTLCommandBuffer) throws { + var inners: [Tensor] = [] + var downs: [Tensor] = [] + inners.reserveCapacity(topK) + downs.reserveCapacity(topK) + for k in 0 ..< topK { + let e = topIndices[k] + let inner = Ops.swigluLimit(gate: gateOuts[k]!, up: upOuts[k]!, limit: textConfig.swigluLimit, on: cmd) + let downW = try bundle.dequantExpertSliceOnto( + named: downName, expertIdx: e, nExperts: nExperts, + slot: "k\(k)_down", outDtype: dt, device: device, on: cmd) + inners.append(inner) + downs.append(downW.asGgufMatmulWeight()) + } + let weights = Tensor.empty(shape: [topK], dtype: .f32, device: device) + weights.copyIn(from: normWeights) + Ops.moeDownWeightedSum6( + downs: downs, inners: inners, weights: weights, + accum: moeAccum, on: cmd) + } + // Shared expert on its own pipe cmd buffer, encoded + committed + // FIRST so the GPU can start it while CPU stages routed + // experts. + let shexpCmd = device.makeCommandBuffer() + let sGate = Ops.gemv( + weight: layer.ffnGateShexp.asGgufMatmulWeight(), input: xNorm, on: shexpCmd) + let sUp = Ops.gemv( + weight: layer.ffnUpShexp.asGgufMatmulWeight(), input: xNorm, on: shexpCmd) + let sInner = Ops.swigluLimit(gate: sGate, up: sUp, limit: textConfig.swigluLimit, on: shexpCmd) + let shexpOut = Ops.gemv( + weight: layer.ffnDownShexp.asGgufMatmulWeight(), input: sInner, on: shexpCmd) + if let d0 = dbgL0, layer.layerIndex == 0, d0.elementCount >= 52 { + Ops.copy( + sGate.reshaped(to: [sGate.elementCount]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 40, count: 4), on: shexpCmd) + Ops.copy( + sUp.reshaped(to: [sUp.elementCount]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 44, count: 4), on: shexpCmd) + Ops.copy( + sInner.reshaped(to: [sInner.elementCount]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 48, count: 4), on: shexpCmd) + } + DeepSeekV4Model.commitWithProfile(shexpCmd, tag: "shexp") + // FUSED gather path: one inline-dequant gather GEMV per role + // (gate, up) computing all topK experts' outputs in a single + // dispatch each — replaces 2*topK per-expert {dequant,gemv} cmd + // buffers (12/layer) with 2. gate/up share x=xNorm. + let useGather = topK == 6 && env["FFAI_DSV4_GATHER"] != "0" + let useResident = env["FFAI_DSV4_RESIDENT"] != "0" + // Build a u32 expert_ids tensor for a role's gather dispatch. + func eids(_ slots: [Int]) -> Tensor { + let t = Tensor.empty(shape: [slots.count], dtype: .u32, device: device) + t.copyIn(from: slots.map { UInt32($0) }) + return t + } + // gate/up: resident packed pool (slots) when it fits, else + // per-token staging (contiguous → identity ids). + func gatherIQ2(_ name: String, _ tag: String) throws + -> (qs: Tensor, d: Tensor, mOut: Int, kIn: Int, ids: Tensor) + { + if useResident, + let r = try bundle.residentGatherIQ2XXS( + named: name, expertIndices: topIndices, nExperts: nExperts, device: device) + { + return (r.qsAll, r.dAll, r.split.mOut, r.split.kIn, eids(r.slots)) + } + let g = try bundle.stageGatherIQ2XXS( + named: name, expertIndices: topIndices, nExperts: nExperts, slot: tag, device: device) + return (g.qsAll, g.dAll, g.mOut, g.kIn, eids(Array(0 ..< topK))) + } + if useGather { + let (grid, signs) = GGUFDequant.iq2xxsTables(device: device) + // ── gate ── + let gG = try gatherIQ2(gateName, "gather_gate_L\(layer.layerIndex)") + let gateAll = Tensor.empty(shape: [topK * gG.mOut], dtype: dt) + let cmdGate = device.makeCommandBuffer() + Ops.moeGatherGemvIQ2XXS( + x: xNorm, qsAll: gG.qs, dAll: gG.d, expertIds: gG.ids, + grid: grid, signs: signs, + nSlots: topK, mOut: gG.mOut, kIn: gG.kIn, on: cmdGate, into: gateAll) + DeepSeekV4Model.commitWithProfile(cmdGate, tag: "expert_gate") + // ── up ── + let gU = try gatherIQ2(upName, "gather_up_L\(layer.layerIndex)") + let upAll = Tensor.empty(shape: [topK * gU.mOut], dtype: dt) + let cmdUp = device.makeCommandBuffer() + Ops.moeGatherGemvIQ2XXS( + x: xNorm, qsAll: gU.qs, dAll: gU.d, expertIds: gU.ids, + grid: grid, signs: signs, + nSlots: topK, mOut: gU.mOut, kIn: gU.kIn, on: cmdUp, into: upAll) + DeepSeekV4Model.commitWithProfile(cmdUp, tag: "expert_up") + for k in 0 ..< topK { + gateOuts[k] = gateAll.slicedRows(start: k * gG.mOut, count: gG.mOut).reshaped(to: [gG.mOut]) + upOuts[k] = upAll.slicedRows(start: k * gU.mOut, count: gU.mOut).reshaped(to: [gU.mOut]) + } + } else { + // 3-stage pipeline: gate / up / swiglu / down for each expert + // distributed across pipeGate / pipeUp / pipeAB / pipeB. + for k in 0 ..< topK { + try recordExpertGate(k) + DeepSeekV4Model.commitWithProfile(pipeGate[k], tag: "expert_gate") + try recordExpertUp(k) + DeepSeekV4Model.commitWithProfile(pipeUp[k], tag: "expert_up") + } + } + let cmd2 = device.makeCommandBuffer() + if useGather { + // SwiGLU all topK experts into one contiguous inner buffer, + // then a single Q2_K gather down-projection + router-weighted + // sum writes moeAccum directly — replaces topK×{swiglu, dequant, + // gemv} + the topK-way weighted accumulate with 2 dispatches. + let innerAll = Tensor.empty(shape: [topK * intermediate], dtype: dt) + var innerSlices: [Tensor] = [] + innerSlices.reserveCapacity(topK) + for k in 0 ..< topK { + innerSlices.append( + innerAll.slicedRows(start: k * intermediate, count: intermediate) + .reshaped(to: [intermediate])) + } + let cmdSwiglu = device.makeCommandBuffer() + Ops.swigluLimitMany( + gates: (0 ..< topK).map { gateOuts[$0]! }, + ups: (0 ..< topK).map { upOuts[$0]! }, + outs: innerSlices, limit: textConfig.swigluLimit, on: cmdSwiglu) + DeepSeekV4Model.commitWithProfile(cmdSwiglu, tag: "expert_swiglu") + let wT = Tensor.empty(shape: [topK], dtype: .f32, device: device) + wT.copyIn(from: normWeights) + if useResident, + let rD = try bundle.residentGatherQ2K( + named: downName, expertIndices: topIndices, nExperts: nExperts, device: device) + { + Ops.moeGatherDownQ2K( + innersAll: innerAll, qsAll: rD.qsAll, scalesAll: rD.scalesAll, + dAll: rD.dAll, dminAll: rD.dminAll, expertIds: eids(rD.slots), weights: wT, + nSlots: topK, mOut: rD.split.mOut, kIn: rD.split.kIn, on: cmd2, into: moeAccum) + } else { + let gD = try bundle.stageGatherQ2K( + named: downName, expertIndices: topIndices, nExperts: nExperts, + slot: "gather_down_L\(layer.layerIndex)", device: device) + Ops.moeGatherDownQ2K( + innersAll: innerAll, qsAll: gD.qsAll, scalesAll: gD.scalesAll, + dAll: gD.dAll, dminAll: gD.dminAll, expertIds: eids(Array(0 ..< topK)), weights: wT, + nSlots: topK, mOut: gD.mOut, kIn: gD.kIn, on: cmd2, into: moeAccum) + } + } else if useFusedDown { + try recordFusedRoutedDown(on: cmd2) + } else if useFusedEpilogue { + try recordFusedEpilogue(on: cmd2) + } else { + for k in 0 ..< (topK - 1) { + try recordExpertB(k, on: pipeB[k]) + DeepSeekV4Model.commitWithProfile(pipeB[k], tag: "expert_down") + } + try recordExpertB(topK - 1, on: cmd2) + } + let _tExpertRecord = CACurrentMediaTime() + DeepSeekV4Model.profExpertRecord += _tExpertRecord - _tRouter + let blockOut = Ops.add(moeAccum, shexpOut, on: cmd2) + // mHC expand + let newH = Ops.dsv4MhcExpand( + blockOut: blockOut, post: postFfn, comb: combFfn, + residualState: state.hcState, + hiddenDim: hidden, nHc: 4, nTokens: 1, on: cmd2) + if let d0 = dbgL0, layer.layerIndex == 0, d0.elementCount >= 40 { + Ops.copy(moeAccum.slicedRows(start: 0, count: 4), into: d0.slicedRows(start: 24, count: 4), on: cmd2) + Ops.copy( + shexpOut.reshaped(to: [hidden]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 28, count: 4), on: cmd2) + Ops.copy( + blockOut.reshaped(to: [hidden]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 32, count: 4), on: cmd2) + Ops.copy( + newH.reshaped(to: [4 * hidden]).slicedRows(start: 0, count: 4), + into: d0.slicedRows(start: 36, count: 4), on: cmd2) + } + // Fold the hcState copy-to-persistent into cmd2 — saves a + // commit+wait round-trip per layer (~1 ms each × 43 layers). + if let outHc = outHc { + Ops.copy(newH, into: outHc, on: cmd2) + } + let _tBeforeCommit = CACurrentMediaTime() + DeepSeekV4Model.profSharedRecord += _tBeforeCommit - _tExpertRecord + DeepSeekV4Model.commitWithProfile(cmd2, tag: "ffn_final") + // Drop the per-layer waitUntilCompleted — next layer's attn + // cmd buffer queues on the same queue and is serialized + // automatically. The terminal lmHead wait synchronizes + // everything before host readback. state.hcState is reassigned + // to outHc (persistent buffer), not the scratch-resident newH, + // so withScratch's resetScratch at the layer body exit doesn't + // invalidate cross-layer carry-over state. + let _tAfterWait = CACurrentMediaTime() + DeepSeekV4Model.profGpuWait += _tAfterWait - _tBeforeCommit + state.hcState = outHc ?? newH + return blockOut + } + + /// Sync-free GPU-routed FFN tail. Routing (top-K + weights), the + /// raw→slot remaps, the IQ2 gate/up gathers, SwiGLU, the Q2_K down + /// gather+weighted-sum, the shared expert, and the mHC expand all + /// run on the queue with NO `waitUntilCompleted` — the only sync per + /// token is the terminal lmHead readback. Requires the resident + /// pools (`rg`/`ru`/`rd`) to already hold the routed experts (the + /// first token's CPU path fills them). + private func gpuRoutedFfnTail( + layer: DeepSeekV4Layer, state: DecodeState, xNorm: Tensor, + scoreBiased: Tensor, scoreUnbiased: Tensor, + rg: ResidentIQ2Split, ru: ResidentIQ2Split, rd: ResidentQ2KSplit, + nExperts: Int, topK: Int, hidden: Int, intermediate: Int, dt: DType, + postFfn: Tensor, combFfn: Tensor, attnCmd: MTLCommandBuffer, + copyHcInto outHc: Tensor? + ) throws -> Tensor { + let (grid, signs) = GGUFDequant.iq2xxsTables(device: device) + let cap = RESIDENT_POOL_CAP + func smap(_ b: MTLBuffer) -> Tensor { Tensor(buffer: b, offset: 0, shape: [nExperts], dtype: .u32) } + + // Router top-K + weights on the attn cmd buffer; commit, NO wait. + let rawIdx = Tensor.empty(shape: [topK], dtype: .u32) + let gpuW = Tensor.empty(shape: [topK], dtype: .f32) + Ops.dsv4RouterTopK( + scoreBiased: scoreBiased, scoreUnbiased: scoreUnbiased, + indicesOut: rawIdx, weightsOut: gpuW, nExperts: nExperts, k: topK, on: attnCmd) + // The router kernel renormalizes the chosen weights to sum-to-1 but + // does NOT apply routed_scaling_factor (it has no scaling input). Apply + // it here — final_weight_k = scaling * unbiased_k / Σ unbiased — to match + // the CPU decode/prefill paths. It MUST multiply AFTER the sum-to-1 + // renorm; folding it in before would cancel in the division (the bug + // that left the GPU router path 1/scaling× too small). + let scaling = textConfig.routerScalingFactor + let scaleVec = Tensor.filled(scaling, shape: [topK], dtype: .f32, device: device) + let gpuWScaled = Ops.mul(gpuW, scaleVec, on: attnCmd) + DeepSeekV4Model.commitWithProfile(attnCmd, tag: "attn+router") + + // Shared expert — independent of routing, commit first. Q8 gemv + // straight from resident Q8 (shexp gate/up/down are Q8_0). + let shexpCmd = device.makeCommandBuffer() + let q8on = ProcessInfo.processInfo.environment["FFAI_DSV4_Q8ATTN"] != "0" + let li = layer.layerIndex + let sGate: Tensor; let sUp: Tensor + if q8on, let g = try? bundle.residentQ8("blk.\(li).ffn_gate_shexp.weight", device: device), + let u = try? bundle.residentQ8("blk.\(li).ffn_up_shexp.weight", device: device) + { + sGate = Tensor.empty(shape: [g.mOut], dtype: dt); Ops.gemvQ8(q8: g, x: xNorm, on: shexpCmd, into: sGate) + sUp = Tensor.empty(shape: [u.mOut], dtype: dt); Ops.gemvQ8(q8: u, x: xNorm, on: shexpCmd, into: sUp) + } else { + sGate = Ops.gemv(weight: layer.ffnGateShexp.asGgufMatmulWeight(), input: xNorm, on: shexpCmd) + sUp = Ops.gemv(weight: layer.ffnUpShexp.asGgufMatmulWeight(), input: xNorm, on: shexpCmd) + } + let sInner = Ops.swigluLimit(gate: sGate, up: sUp, limit: textConfig.swigluLimit, on: shexpCmd) + let shexpOut: Tensor + if q8on, let d = try? bundle.residentQ8("blk.\(li).ffn_down_shexp.weight", device: device) { + shexpOut = Tensor.empty(shape: [d.mOut], dtype: dt); + Ops.gemvQ8(q8: d, x: sInner, on: shexpCmd, into: shexpOut) + } else { + shexpOut = Ops.gemv(weight: layer.ffnDownShexp.asGgufMatmulWeight(), input: sInner, on: shexpCmd) + } + DeepSeekV4Model.commitWithProfile(shexpCmd, tag: "shexp") + + // All routed-expert work shares ONE cmd buffer (the chain + // gate/up → swiglu → down is sequential anyway, so merging loses + // no GPU parallelism but saves ~4 commits/layer of CPU overhead). + let ffnCmd = device.makeCommandBuffer() + // gate + let gateIds = Tensor.empty(shape: [topK], dtype: .u32) + let gateAll = Tensor.empty(shape: [topK * rg.mOut], dtype: dt) + Ops.remapU32(table: smap(rg.slotmap), idx: rawIdx, out: gateIds, n: topK, on: ffnCmd) + Ops.moeGatherGemvIQ2XXS( + x: xNorm, + qsAll: Tensor(buffer: rg.qs, offset: 0, shape: [cap * rg.nBlocksPerExpert * 16], dtype: .u32), + dAll: Tensor(buffer: rg.d, offset: 0, shape: [cap * rg.nBlocksPerExpert], dtype: .f32), + expertIds: gateIds, grid: grid, signs: signs, + nSlots: topK, mOut: rg.mOut, kIn: rg.kIn, on: ffnCmd, into: gateAll) + // up + let upIds = Tensor.empty(shape: [topK], dtype: .u32) + let upAll = Tensor.empty(shape: [topK * ru.mOut], dtype: dt) + Ops.remapU32(table: smap(ru.slotmap), idx: rawIdx, out: upIds, n: topK, on: ffnCmd) + Ops.moeGatherGemvIQ2XXS( + x: xNorm, + qsAll: Tensor(buffer: ru.qs, offset: 0, shape: [cap * ru.nBlocksPerExpert * 16], dtype: .u32), + dAll: Tensor(buffer: ru.d, offset: 0, shape: [cap * ru.nBlocksPerExpert], dtype: .f32), + expertIds: upIds, grid: grid, signs: signs, + nSlots: topK, mOut: ru.mOut, kIn: ru.kIn, on: ffnCmd, into: upAll) + + // SwiGLU all experts → one contiguous inner buffer. + let innerAll = Tensor.empty(shape: [topK * intermediate], dtype: dt) + var gates: [Tensor] = []; var ups: [Tensor] = []; var inners: [Tensor] = [] + for k in 0 ..< topK { + gates.append(gateAll.slicedRows(start: k * rg.mOut, count: rg.mOut).reshaped(to: [rg.mOut])) + ups.append(upAll.slicedRows(start: k * ru.mOut, count: ru.mOut).reshaped(to: [ru.mOut])) + inners.append( + innerAll.slicedRows(start: k * intermediate, count: intermediate).reshaped(to: [intermediate])) + } + Ops.swigluLimitMany(gates: gates, ups: ups, outs: inners, limit: textConfig.swigluLimit, on: ffnCmd) + + // Down gather + router-weighted sum → moeAccum. + let moeAccum = Tensor.filled(0.0, shape: [hidden], dtype: dt, device: device) + let downIds = Tensor.empty(shape: [topK], dtype: .u32) + let cmd2 = ffnCmd + Ops.remapU32(table: smap(rd.slotmap), idx: rawIdx, out: downIds, n: topK, on: cmd2) + Ops.moeGatherDownQ2K( + innersAll: innerAll, + qsAll: Tensor(buffer: rd.qs, offset: 0, shape: [cap * rd.nBlocksPerExpert * 16], dtype: .u32), + scalesAll: Tensor(buffer: rd.scales, offset: 0, shape: [cap * rd.nBlocksPerExpert * 16], dtype: .u8), + dAll: Tensor(buffer: rd.d, offset: 0, shape: [cap * rd.nBlocksPerExpert], dtype: .f32), + dminAll: Tensor(buffer: rd.dmin, offset: 0, shape: [cap * rd.nBlocksPerExpert], dtype: .f32), + expertIds: downIds, weights: gpuWScaled, + nSlots: topK, mOut: rd.mOut, kIn: rd.kIn, on: cmd2, into: moeAccum) + + let blockOut = Ops.add(moeAccum, shexpOut, on: cmd2) + let newH = Ops.dsv4MhcExpand( + blockOut: blockOut, post: postFfn, comb: combFfn, + residualState: state.hcState, hiddenDim: hidden, nHc: 4, nTokens: 1, on: cmd2) + if let outHc = outHc { Ops.copy(newH, into: outHc, on: cmd2) } + DeepSeekV4Model.commitWithProfile(cmd2, tag: "ffn_final") + state.hcState = outHc ?? newH + return blockOut + } + nonisolated(unsafe) public static var profRouter: Double = 0 + nonisolated(unsafe) public static var profExpertRecord: Double = 0 + nonisolated(unsafe) public static var profSharedRecord: Double = 0 + nonisolated(unsafe) public static var profGpuWait: Double = 0 + nonisolated(unsafe) public static var profDequant: Double = 0 + nonisolated(unsafe) public static var profExpertGemv: Double = 0 + nonisolated(unsafe) public static var profExpertEpilogue: Double = 0 + + // Per-cmd-buffer GPU-time profile. Keyed by tag set on MTLCommandBuffer.label + // before commit. Updated in the completion handler from gpuStartTime/ + // gpuEndTime so we get TRUE GPU runtime per stage (not CPU wait time). + nonisolated(unsafe) public static var profGpuByTag: [String: Double] = [:] + nonisolated(unsafe) public static var profCountByTag: [String: Int] = [:] + nonisolated(unsafe) public static var profKernelEncodeCount: Int = 0 + nonisolated(unsafe) public static let profGpuLock = NSLock() + public static let profileEnabled = + ProcessInfo.processInfo.environment["FFAI_DSV4_PROFILE"] == "1" + + public static func resetGpuProf() { + profGpuLock.lock() + defer { profGpuLock.unlock() } + profGpuByTag.removeAll(keepingCapacity: true) + profCountByTag.removeAll(keepingCapacity: true) + profKernelEncodeCount = 0 + } + + /// Helper: label cmd buffer + install completion handler that + /// records true GPU runtime, then commit. Aggregates into + /// `profGpuByTag[tag]`. + public static func commitWithProfile(_ cmd: MTLCommandBuffer, tag: String) { + cmd.label = tag + guard profileEnabled else { + cmd.commit() + return + } + cmd.addCompletedHandler { cb in + let gpu = cb.gpuEndTime - cb.gpuStartTime + profGpuLock.lock() + profGpuByTag[tag, default: 0] += gpu + profCountByTag[tag, default: 0] += 1 + profGpuLock.unlock() + } + cmd.commit() + } + + /// Full single-token decode forward through all `nLayers` layers + /// + output mHC head + output norm + LM head. Returns the logits + /// vector `[vocab]`. + /// + /// **Note:** CSA / HCA forward paths aren't implemented yet, so + /// `forwardFullAttnSubblock` is used on ALL layers regardless of + /// `compress_ratio`. The output is dispatch-correct but the + /// numerics for CSA/HCA layers are wrong (they should run the + /// indexer + compressed-cache attention). Quality of the + /// generated token will be garbage until those paths land. + public func forwardAllLayers( + inputTokenId: Int, state: DecodeState + ) throws -> Tensor { + let cfg = textConfig + let dt = activationDtype + let hidden = cfg.hidden + state.currentToken = inputTokenId // hash-routed early layers key on this + + // Seed hcState with the input token's embedding broadcast + // across all 4 mHC channels. + let embedRow = tokenEmbd.asGgufMatmulWeight() + .slicedRows(start: inputTokenId, count: 1).reshaped(to: [hidden]) + let cmdSeed = device.makeCommandBuffer() + for c in 0 ..< 4 { + let dst = state.hcState.slicedRows(start: c, count: 1).reshaped(to: [hidden]) + Ops.copy(embedRow, into: dst, on: cmdSeed) + } + DeepSeekV4Model.commitWithProfile(cmdSeed, tag: "seed") + // No wait — the first layer's attn reads hcState on the same + // queue, which serializes after the seed copy. + + // Iterate layers wrapped in withScratch so the per-layer + // transient tensors all flow through the device scratch slab + // and get reset between layers. Without this, ~100 + // Tensor.empty calls per layer × 43 layers hammered Metal's + // driver pool and RSS grew ~3 GB/min until OOM. + // + // CARRY-OVER STATE NOTE: state.hcState is the only tensor + // that must persist ACROSS layer boundaries. The mHC expand + // step at the end of each sub-block writes a new hcState + // tensor — inside withScratch that lands in the slab, then + // we COPY it into a persistent buffer before the scope exit + // so the next layer reads from real memory, not the + // about-to-be-reset slab. + let hcStatePersistent = Tensor.empty( + shape: [4, hidden], dtype: dt, device: device) + var tLoad = 0.0 + var tAttn = 0.0 + var tFfn = 0.0 + var tCopy = 0.0 + var tRelease = 0.0 + for layerIdx in 0 ..< cfg.nLayers { + let t0 = CACurrentMediaTime() + let layer = try self.layer(layerIdx) + let t1 = CACurrentMediaTime() + try autoreleasepool { + try device.withScratch { + // Single cmd buffer for attn + ffn prefix — the FFN + // top-K readback inside forwardFfnSubblock commits+waits + // the shared buffer once, instead of attn doing a + // separate commit+wait of its own. + let cmdAttn = device.makeCommandBuffer() + _ = forwardFullAttnSubblock(layer: layer, state: state, on: cmdAttn) + let t2 = CACurrentMediaTime() + _ = try forwardFfnSubblock( + layer: layer, state: state, on: cmdAttn, + copyHcInto: hcStatePersistent) + let t3 = CACurrentMediaTime() + let t4 = CACurrentMediaTime() + tAttn += t2 - t1 + tFfn += t3 - t2 + tCopy += t4 - t3 + } + } + let t5 = CACurrentMediaTime() + if !keepLayersResident { + self.releaseLayer(layerIdx) + } + let t6 = CACurrentMediaTime() + tLoad += t1 - t0 + tRelease += t6 - t5 + } + if DeepSeekV4Model.profileEnabled { + print( + String( + format: "[prof] load=%.2fs attn=%.2fs ffn=%.2fs copy=%.2fs release=%.2fs", + tLoad, tAttn, tFfn, tCopy, tRelease)) + print( + String( + format: "[prof-ffn] router-host-wait=%.2fs expert-record=%.2fs shared-record=%.2fs gpu-wait=%.2fs", + DeepSeekV4Model.profRouter, + DeepSeekV4Model.profExpertRecord, + DeepSeekV4Model.profSharedRecord, + DeepSeekV4Model.profGpuWait)) + print( + String( + format: "[prof-expert] dequant=%.2fs expert-gemv=%.2fs epilogue=%.2fs", + DeepSeekV4Model.profDequant, + DeepSeekV4Model.profExpertGemv, + DeepSeekV4Model.profExpertEpilogue)) + print( + String( + format: "[prof-slice] tables=%.2fs pooled=%.2fs wrslice=%.2fs dequant=%.2fs q80=%.2fs q2k=%.2fs", + GGUFTensorBundle.profSliceTables, + GGUFTensorBundle.profSlicePooled, + GGUFTensorBundle.profSliceWrslice, + GGUFTensorBundle.profSliceDequant, + GGUFTensorBundle.profSliceQ80, + GGUFTensorBundle.profSliceQ2K)) + print("[prof-slice-type] \(GGUFTensorBundle.profSliceType)") + // Per-cmd-buffer GPU runtime (true GPU time, NOT CPU wait). + // Populated via commitWithProfile completion handlers. + DeepSeekV4Model.profGpuLock.lock() + let gpuTags = DeepSeekV4Model.profGpuByTag.sorted { $0.value > $1.value } + let counts = DeepSeekV4Model.profCountByTag + DeepSeekV4Model.profGpuLock.unlock() + let totalGpu = gpuTags.reduce(0.0) { $0 + $1.value } + print( + String( + format: "[prof-gpu] total=%.4fs across %d cmd buffers", + totalGpu, counts.values.reduce(0, +))) + for (tag, gpu) in gpuTags { + let n = counts[tag] ?? 0 + let avgMs = n > 0 ? (gpu / Double(n)) * 1000.0 : 0 + print( + String( + format: "[prof-gpu] %@: %.4fs / %d cmds (avg %.3f ms)", + tag, gpu, n, avgMs)) + } + print( + String( + format: "[prof-stage] iq2_stage=%.3fs iq2_encode=%.3fs", + GGUFDequant.profStageIq2, GGUFDequant.profEncodeIq2)) + } + // Output mHC head: pre = sigmoid(output_hc_fn^T @ flatten(H) + // * scale + base) + eps → [4] + let flatH = state.hcState.reshaped(to: [4 * hidden]) + let cmdHead = device.makeCommandBuffer() + let outputHcFnW = outputHcFn.asGgufMatmulWeight() + let pre4 = mhcMix(flatH: flatH, fnWeight: outputHcFnW, on: cmdHead) + DeepSeekV4Model.commitWithProfile(cmdHead, tag: "output_head_pre4") + cmdHead.waitUntilCompleted() + let pre4Host = pre4.toArray(as: Float.self) + let scaleHost = outputHcScale.toArray(as: Float.self) + let baseHost = outputHcBase.toArray(as: Float.self) + let eps = cfg.hcEpsilon + var preFinal = [Float](repeating: 0, count: 4) + for c in 0 ..< 4 { + let z = pre4Host[c] * scaleHost[0] + baseHost[c] + preFinal[c] = 1.0 / (1.0 + Foundation.exp(-z)) + eps + } + let preTensor = Tensor.empty(shape: [4], dtype: .f32) + preTensor.copyIn(from: preFinal) + + // Collapse H → x using preFinal, then LM head gemv. + let cmdCollapse = device.makeCommandBuffer() + let xWithTokens = Ops.dsv4MhcCollapse( + state: state.hcState, pre: preTensor, + hiddenDim: hidden, nHc: 4, nTokens: 1, outDtype: dt, on: cmdCollapse) + let x = xWithTokens.reshaped(to: [hidden]) + let xNorm = Ops.rmsNorm(x, weight: outputNorm, eps: cfg.rmsNormEps, on: cmdCollapse) + // LM head (output.weight is Q8_0, ~1 GB as f16) — gemv from + // resident Q8 to halve the per-token output-projection bandwidth. + let logits: Tensor + if ProcessInfo.processInfo.environment["FFAI_DSV4_Q8ATTN"] != "0", + let lmQ8 = try? bundle.residentQ8("output.weight", device: device) + { + logits = Tensor.empty(shape: [lmQ8.mOut], dtype: dt) + Ops.gemvQ8(q8: lmQ8, x: xNorm, on: cmdCollapse, into: logits) + } else { + logits = Ops.gemv(weight: outputHead.asGgufMatmulWeight(), input: xNorm, on: cmdCollapse) + } + DeepSeekV4Model.commitWithProfile(cmdCollapse, tag: "lmhead+collapse") + cmdCollapse.waitUntilCompleted() + return logits + } +} + +// ═══════════════════════════════════════════════════════════════════ +// Batched prefill path (was DeepSeekV4Prefill.swift — consolidated). +// ═══════════════════════════════════════════════════════════════════ + + +struct PrefillError: Error, CustomStringConvertible { + let message: String + var description: String { message } +} + +extension DeepSeekV4Model { + /// One-time guard: have we pre-warmed the expert tensors into page cache? + nonisolated(unsafe) static var dsv4Prewarmed = false + + /// Batched prefill over `tokens`; returns the last token's logits. + /// Single chunk (N ≤ a few hundred); KV cache is the chunk's own K/V. + public func forwardPrefillChunk(tokens: [Int], state decodeState: DecodeState? = nil) throws -> Tensor { + let cfg = textConfig + let dt = activationDtype + let hidden = cfg.hidden + let headDim = cfg.headDim + let nHeads = cfg.nHeads + let intermediate = cfg.moeIntermediate + let topK = cfg.nExpertsPerToken + let scaling = cfg.routerScalingFactor + let window = cfg.slidingWindow + let N = tokens.count + let nExperts = 256 + let scale = 1.0 / Float(headDim).squareRoot() + + // Per-layer scratch transients scale ~linearly with N (xPerm [N*topK, + // hidden], gate/up/inner [M,intermediate], attn [N,*]). Measured ~1.1 + // MB/token; size the slab to fit one layer with headroom (2 MB/token, + // 256 MB floor) so large chunks don't overflow the 256 MB default. + device.ensureScratchSlab(max(256 << 20, N * (2 << 20))) + // ── Seed hcState [N, 4, hidden] from per-token embeddings ── + let embW = tokenEmbd.asGgufMatmulWeight() // [vocab, hidden] + // 2D [N*4, hidden]: row (t*4+c) = token t, mHC channel c. slicedRows + // slices dim0, so a 3D shape here would index the token dim (overflow). + var hcState = Tensor.empty(shape: [N * 4, hidden], dtype: dt, device: device) + let seedCmd = device.makeCommandBuffer() + for t in 0 ..< N { + let row = embW.slicedRows(start: tokens[t], count: 1).reshaped(to: [hidden]) + for c in 0 ..< 4 { + let dst = hcState.slicedRows(start: t * 4 + c, count: 1).reshaped(to: [hidden]) + Ops.copy(row, into: dst, on: seedCmd) + } + } + seedCmd.commit(); seedCmd.waitUntilCompleted() + + // hcState is the only state that crosses layer boundaries. It MUST + // be a persistent (non-scratch) buffer: prefillLayer's newH is + // allocated inside withScratch, so it would be invalidated when the + // slab resets at scope exit. Copy newH into the persistent hcState + // before the scope ends (mirrors forwardAllLayers' hcStatePersistent). + // + // COLD-I/O READAHEAD: the per-layer expert gather runs at ~11 GB/s on + // the first chunk (latency-bound on cold SSD page faults — the #2 cost). + // A background madvise(WILLNEED) of the NEXT layer's expert tensors, + // fired while the GPU computes THIS layer, overlaps that disk I/O with + // compute. It only HINTS readahead — copies nothing — so it does NOT + // contend for the unified-memory bandwidth the way a background memcpy + // does (that regressed 2×). Fire-and-forget (advisory): no join, no + // buffers; if readahead isn't done by the gather, it just faults + // normally (no worse than baseline). + let raQueue = DispatchQueue(label: "ffai.prefill.readahead", qos: .utility) + // PREWARM: touch-read ALL expert tensors into the page cache ONCE, + // before the first prefill. The 80GB model fits in 128GB cache, but + // mmap is lazy — macOS hasn't faulted it, so the per-layer gather hits + // cold/evicted pages (disk-bound 7-11 GB/s) → ~232 t/s. Pre-faulting + // the whole model (reclaimable CACHE, not wired → no freeze, the guard + // sees inactive pages as available) lifts warm prefill to ~320+ (94%+ + // of parity). One-time ~7s (the cold weight read paid upfront, like + // model load) — subsequent chunks/prefills stay warm. + if !Self.dsv4Prewarmed { + Self.dsv4Prewarmed = true + let names = (0 ..< cfg.nLayers).flatMap { li in + ["blk.\(li).ffn_gate_exps.weight", "blk.\(li).ffn_up_exps.weight", "blk.\(li).ffn_down_exps.weight"] + } + DispatchQueue.concurrentPerform(iterations: names.count) { i in bundle.prefetchTensor(named: names[i]) } + } + for layerIdx in 0 ..< cfg.nLayers { + if layerIdx + 1 < cfg.nLayers { + let nx = layerIdx + 1 + raQueue.async { [weak self] in + guard let self else { return } + self.bundle.prefetchTensor(named: "blk.\(nx).ffn_gate_exps.weight") + self.bundle.prefetchTensor(named: "blk.\(nx).ffn_up_exps.weight") + self.bundle.prefetchTensor(named: "blk.\(nx).ffn_down_exps.weight") + } + } + // FREEZE GUARD: abort cleanly if system free memory drops below a + // floor (default 12%) before a layer's allocations. The M5 Max + // freezes near ~8-10% free; bailing with a throw lets the OS + // reclaim instead of the app-quit-monitor freeze. Override the + // floor via FFAI_MEM_FLOOR_PCT, disable with =0. + if let freePct = MemorySnapshot.systemFreePercent() { + let floor = Double(ProcessInfo.processInfo.environment["FFAI_MEM_FLOOR_PCT"].flatMap { Int($0) } ?? 12) + if floor > 0 && freePct < floor { + throw PrefillError( + message: + "prefill aborted at layer \(layerIdx): system free memory \(String(format: "%.0f", freePct))% < floor \(Int(floor))% (freeze guard). Reduce chunk size / residency." + ) + } + } + let layer = try self.layer(layerIdx) + // autoreleasepool is CRITICAL: prefillLayer allocates a FRESH + // per-layer expert pool (~1.5 GB of MTLBuffers via makeBuffer). + // Metal buffers are autoreleased ObjC objects — without draining + // the pool each layer they'd accumulate (43 × 1.5 GB ≈ 64 GB) and + // freeze the machine. Drain per layer → peak stays ~one layer. + try autoreleasepool { + try device.withScratch { + let newH = try prefillLayer( + layer: layer, hcState: hcState, N: N, hidden: hidden, headDim: headDim, + nHeads: nHeads, intermediate: intermediate, topK: topK, nExperts: nExperts, + scaling: scaling, window: window, scale: scale, dt: dt, tokens: tokens, + decodeState: decodeState) + let ccmd = device.makeCommandBuffer() + Ops.copy(newH.reshaped(to: [N * 4, hidden]), into: hcState, on: ccmd) + ccmd.commit(); ccmd.waitUntilCompleted() + } + } + if [0, 1, 2, 5, 10, 20, 24, 28, 32, 36, 40, 42].contains(layerIdx) { + if N > 1 { + } + } + } + + // ── Tail: last token's output head + lmhead ── + // dsv4MhcExpand returns [N,4,hidden]; flatten to [N*4,hidden] so + // slicedRows indexes (token*4+channel) rows, not the token dim. + let hcFlat = hcState.reshaped(to: [N * 4, hidden]) + let lastH = hcFlat.slicedRows(start: (N - 1) * 4, count: 4).reshaped(to: [4, hidden]) + if let decodeState { + let scmd = device.makeCommandBuffer() + Ops.copy(lastH, into: decodeState.hcState, on: scmd) + scmd.commit(); scmd.waitUntilCompleted() + decodeState.position = N + decodeState.currentToken = tokens[N - 1] + } + let cmd = device.makeCommandBuffer() + let flatH = lastH.reshaped(to: [4 * hidden]) + // mHC rms-norm before the output_hc_fn mix (same fix as decode/mhcMix). + let pre4 = mhcMix(flatH: flatH, fnWeight: outputHcFn.asGgufMatmulWeight(), on: cmd) + cmd.commit(); cmd.waitUntilCompleted() + let pre4Host = pre4.toArray(as: Float.self) + let scaleHost = outputHcScale.toArray(as: Float.self) + let baseHost = outputHcBase.toArray(as: Float.self) + var preFinal = [Float](repeating: 0, count: 4) + for c in 0 ..< 4 { + let z = pre4Host[c] * scaleHost[0] + baseHost[c] + preFinal[c] = 1.0 / (1.0 + Foundation.exp(-z)) + cfg.hcEpsilon + } + let preTensor = Tensor.empty(shape: [4], dtype: .f32, device: device) + preTensor.copyIn(from: preFinal) + let cmd2 = device.makeCommandBuffer() + let x = Ops.dsv4MhcCollapse( + state: lastH, pre: preTensor, hiddenDim: hidden, nHc: 4, nTokens: 1, + outDtype: dt, on: cmd2 + ).reshaped(to: [hidden]) + let xNorm = Ops.rmsNorm(x, weight: outputNorm, eps: cfg.rmsNormEps, on: cmd2) + let logits: Tensor + if let lmQ8 = try? bundle.residentQ8("output.weight", device: device) { + logits = Tensor.empty(shape: [lmQ8.mOut], dtype: dt, device: device) + Ops.gemvQ8(q8: lmQ8, x: xNorm, on: cmd2, into: logits) + } else { + logits = Ops.gemv(weight: outputHead.asGgufMatmulWeight(), input: xNorm, on: cmd2) + } + cmd2.commit(); cmd2.waitUntilCompleted() + return logits + } + + /// One prefill layer over N tokens. Returns the new hcState [N,4,hidden]. + private func prefillLayer( + layer: DeepSeekV4Layer, hcState: Tensor, N: Int, hidden: Int, headDim: Int, + nHeads: Int, intermediate: Int, topK: Int, nExperts: Int, scaling: Float, + window: Int, scale: Float, dt: DType, tokens: [Int], decodeState: DecodeState? + ) throws -> Tensor { + let li = layer.layerIndex + func q8(_ name: String) -> ResidentQ8? { try? bundle.residentQ8(name, device: device) } + // Q8 GEMM from a resident Q8 weight (wraps its buffers as tensors). + func gq8(_ r: ResidentQ8, _ inp: Tensor, _ outT: Tensor, _ n: Int, _ c: MTLCommandBuffer) { + let nb = r.mOut * r.kIn / 32 + let qsT = Tensor(buffer: r.qs, offset: 0, shape: [nb * 8], dtype: .u32) + let dT = Tensor(buffer: r.d, offset: 0, shape: [nb], dtype: .f32) + // Cooperative-tensor MMA Q8 GEMM (~6× the scalar path) for the + // batched case; the scalar gemmQ8 for small n where MMA doesn't pay. + if n >= 32 { + Ops.gemmQ8Mpp(qs: qsT, dF32: dT, input: inp, out: outT, inDim: r.kIn, outDim: r.mOut, nRows: n, on: c) + } else { + Ops.gemmQ8(qs: qsT, dF32: dT, input: inp, out: outT, inDim: r.kIn, outDim: r.mOut, nRows: n, on: c) + } + } + + // ===== Attention sub-block (N tokens) ===== + let cmd = device.makeCommandBuffer() + let flatH = hcState.reshaped(to: [N, 4 * hidden]) + // mHC: mix = hc_attn_fn @ rms_norm_no_weight(flat) — the RMSNorm + // over the flattened 4-channel state per token was missing (same + // bug as decode's mhcMix). Without it the sinkhorn split is wrong. + let flatHNorm = Ops.rmsNormRows( + flatH, weight: hcFlatOnes(dt), eps: textConfig.rmsNormEps, nRows: N, rowSize: 4 * hidden, on: cmd) + let mixes = Ops.gemm(weight: layer.hcAttnFn.asGgufMatmulWeight(), input: flatHNorm, nRows: N, on: cmd) + let (preA, postA, combA) = Ops.dsv4MhcSinkhornSplit( + mixes: mixes, scale: layer.hcAttnScale, base: layer.hcAttnBase, + nTokens: N, eps: textConfig.hcEpsilon, sinkhornIters: textConfig.hcSinkhornIterations, on: cmd) + let x = Ops.dsv4MhcCollapse( + state: hcState, pre: preA, hiddenDim: hidden, nHc: 4, nTokens: N, outDtype: dt, on: cmd) + let xNorm = Ops.rmsNormRows( + x, weight: layer.attnNorm, eps: textConfig.rmsNormEps, nRows: N, rowSize: hidden, on: cmd) // [N,hidden] rows + + // Q chain (Q8 gemm). + let qaQ8 = q8("blk.\(li).attn_q_a.weight")! + let qA = Tensor.empty(shape: [N, qaQ8.mOut], dtype: dt) + gq8(qaQ8, xNorm, qA, N, cmd) + Ops.rmsNormRows( + qA, weight: layer.attnQANorm, eps: textConfig.rmsNormEps, nRows: N, rowSize: qaQ8.mOut, on: cmd, into: qA) + let qbQ8 = q8("blk.\(li).attn_q_b.weight")! + let q = Tensor.empty(shape: [N, qbQ8.mOut], dtype: dt) // [N, nHeads*headDim] + gq8(qbQ8, qA, q, N, cmd) + // Per-head unit RMS over N*nHeads rows. (No rope: position 0 = identity.) + Ops.rmsNormRows( + q, weight: Tensor.filled(1.0, shape: [headDim], dtype: dt, device: device), eps: textConfig.rmsNormEps, + nRows: N * nHeads, rowSize: headDim, on: cmd, into: q) + + // KV (Q8 gemm) → [N, headDim] (MQA 1 kv head). + let kvQ8 = q8("blk.\(li).attn_kv.weight")! + let kv = Tensor.empty(shape: [N, kvQ8.mOut], dtype: dt) + gq8(kvQ8, xNorm, kv, N, cmd) + let kvNorm = Tensor.empty(shape: [N, headDim], dtype: dt) + Ops.rmsNormRows( + kv, weight: layer.attnKVANorm, eps: textConfig.rmsNormEps, nRows: N, rowSize: headDim, on: cmd, into: kvNorm + ) + + // ── Per-position partial RoPE (token t at position t). The old + // code skipped rope ("position 0 = identity") which is only valid + // for N=1; real prompts have tokens at 0..N-1. Compressed layers use + // compress_rope_theta + YaRN, full layers rope_theta. ── + let qkRopeDim = textConfig.qkRopeHeadDim + let nNope = headDim - qkRopeDim + let layerRatio = li < layerCompressRatios.count ? layerCompressRatios[li] : 0 + let layerTheta = layerRatio != 0 ? textConfig.compressRopeTheta : textConfig.ropeTheta + let yp = yarnParams(ratio: layerRatio) + // Batched per-position partial RoPE: token t at position t, ONE + // dispatch each for q (nHeads) and kv (1 head) — was a per-token loop + // (2N tiny dispatches/layer, a top warm-prefill cost). + Ops.dsv4PartialRopeRows( + qk: q, out: q, nHeads: nHeads, headDim: headDim, nNope: nNope, + nTokens: N, basePosition: 0, thetaBase: layerTheta, inverse: false, + freqScale: yp.freqScale, extFactor: yp.extFactor, corrLow: yp.corrLow, corrHigh: yp.corrHigh, on: cmd) + Ops.dsv4PartialRopeRows( + qk: kvNorm, out: kvNorm, nHeads: 1, headDim: headDim, nNope: nNope, + nTokens: N, basePosition: 0, thetaBase: layerTheta, inverse: false, + freqScale: yp.freqScale, extFactor: yp.extFactor, corrLow: yp.corrLow, corrHigh: yp.corrHigh, on: cmd) + if let decodeState { + let layerState = decodeState.layerStates[li] + let winCount = min(N, layerState.nSWA) + let start = N - winCount + Ops.copy( + kvNorm.slicedRows(start: start, count: winCount), + into: layerState.swCache.slicedRows(start: 0, count: winCount), + on: cmd) + layerState.swCount = winCount + layerState.compCount = 0 + } + + // Causal sliding-window SDPA over the chunk. q [N,nHeads,headDim], kv [N,headDim]. + let attnOut = Tensor.empty(shape: [N, nHeads * headDim], dtype: dt) + Ops.sdpaPrefillD512Sink( + q: q, k: kvNorm, v: kvNorm, sinkLogit: layer.attnSinks, out: attnOut, + headDim: headDim, nQHeads: nHeads, kvStride: N, headsPerGroup: nHeads, + window: window, kvBase: 0, scale: scale, nQuery: N, on: cmd) + // Inverse partial RoPE on the attention output (V carries the K + // rotation in MQA; undo it per token before the O-LoRA). + Ops.dsv4PartialRopeRows( + qk: attnOut, out: attnOut, nHeads: nHeads, headDim: headDim, nNope: nNope, + nTokens: N, basePosition: 0, thetaBase: layerTheta, inverse: true, + freqScale: yp.freqScale, extFactor: yp.extFactor, corrLow: yp.corrLow, corrHigh: yp.corrHigh, on: cmd) + + // O-LoRA: 8 groups. oLow [N, 8*oLoraRank]. (f16 path — copy each + // group's [N,groupDim] contiguous, gemm, write.) + // Grouped O-LoRA-A: 8 groups, each a contiguous row block of the + // Q8 resident attn_output_a with its own [groupDim] input slice. + // Uses the proven groupedGemvQ8 per token-row — the f16 + // asGgufMatmulWeight().slicedRows() path is WRONG (the swap is a + // label-only view, so slicing its leading dim reads non-contiguous + // bytes). attnOut row = [8 groups × groupDim] contiguous input. + let oGroups = 8 + let oLoraRank = textConfig.oLoraRank // 1024 + let oLow = Tensor.empty(shape: [N, oGroups * oLoraRank], dtype: dt) + let oaQ8 = q8("blk.\(li).attn_output_a.weight")! + // Batched O-LoRA-A: amortized grouped GEMM via cooperative-tensor MMA + // for all N tokens (replaces the per-token gemv that was the #1 attn + // hotspot). oaQ8 is [oGroups*oLoraRank, perGroupIn]; the scalar grouped + // GEMM handles small n where MMA doesn't pay. + let oaNb = oaQ8.mOut * oaQ8.kIn / 32 + let oaQs = Tensor(buffer: oaQ8.qs, offset: 0, shape: [oaNb * 8], dtype: .u32) + let oaD = Tensor(buffer: oaQ8.d, offset: 0, shape: [oaNb], dtype: .f32) + if N >= 32 { + Ops.groupedGemmQ8Mpp( + qs: oaQs, dF32: oaD, input: attnOut, out: oLow, + inDim: oaQ8.kIn, outDim: oaQ8.mOut, nRows: N, + nGroups: oGroups, rowsPerGroup: oLoraRank, on: cmd) + } else { + Ops.groupedGemmQ8( + qs: oaQs, dF32: oaD, input: attnOut, out: oLow, + inDim: oaQ8.kIn, outDim: oaQ8.mOut, nRows: N, + nGroups: oGroups, rowsPerGroup: oLoraRank, on: cmd) + } + let obQ8 = q8("blk.\(li).attn_output_b.weight")! + let attnBlock = Tensor.empty(shape: [N, hidden], dtype: dt) + gq8(obQ8, oLow, attnBlock, N, cmd) + let hAfterAttn = Ops.dsv4MhcExpand( + blockOut: attnBlock, post: postA, comb: combA, residualState: hcState, + hiddenDim: hidden, nHc: 4, nTokens: N, on: cmd) + cmd.commit(); cmd.waitUntilCompleted() + if li == 0 { + } + + // ===== FFN sub-block (N tokens) ===== + let fcmd = device.makeCommandBuffer() + let flatH2 = hAfterAttn.reshaped(to: [N, 4 * hidden]) + let flatH2Norm = Ops.rmsNormRows( + flatH2, weight: hcFlatOnes(dt), eps: textConfig.rmsNormEps, nRows: N, rowSize: 4 * hidden, on: fcmd) + let mixes2 = Ops.gemm(weight: layer.hcFfnFn.asGgufMatmulWeight(), input: flatH2Norm, nRows: N, on: fcmd) + let (preF, postF, combF) = Ops.dsv4MhcSinkhornSplit( + mixes: mixes2, scale: layer.hcFfnScale, base: layer.hcFfnBase, + nTokens: N, eps: textConfig.hcEpsilon, sinkhornIters: textConfig.hcSinkhornIterations, on: fcmd) + let x2 = Ops.dsv4MhcCollapse( + state: hAfterAttn, pre: preF, hiddenDim: hidden, nHc: 4, nTokens: N, outDtype: dt, on: fcmd) + let xNorm2 = Ops.rmsNormRows( + x2, weight: layer.ffnNorm, eps: textConfig.rmsNormEps, nRows: N, rowSize: hidden, on: fcmd) // [N,hidden] + + // Router: logits [N,256] → sqrtsoftplus → readback → per-token top-6. + let rLogits = Ops.gemm(weight: layer.ffnGateInp.asGgufMatmulWeight(), input: xNorm2, nRows: N, on: fcmd) + let rF32 = Tensor.empty(shape: [N, nExperts], dtype: .f32) + Ops.castToF32(rLogits, into: rF32, on: fcmd) + // sqrtsoftplus is elementwise (bias indexed by flat element id), so + // tile the per-expert [256] bias across all N tokens → [N*256]. + let bias0 = layer.expProbsBias ?? Tensor.filled(0.0, shape: [nExperts], dtype: .f32, device: device) + let biasHost0 = bias0.toArray(as: Float.self) + var tiledBias = [Float](); tiledBias.reserveCapacity(N * nExperts) + for _ in 0 ..< N { tiledBias.append(contentsOf: biasHost0) } + let bias = Tensor.empty(shape: [N * nExperts], dtype: .f32, device: device) + bias.copyIn(from: tiledBias) + let (scU, scB) = Ops.dsv4MoeRouterSqrtsoftplus(logits: rF32, bias: bias, on: fcmd) + fcmd.commit(); fcmd.waitUntilCompleted() + let biasedH = scB.toArray(as: Float.self) + let unbiasedH = scU.toArray(as: Float.self) + + // Build (token,slot) rows, then expert-sort via a permutation so we + // can also produce invPerm (origIdx → sorted position) + per-(token, + // slot) weights for the single-dispatch batched unpermute later. + var rowsU: [(tok: Int, slot: Int, expert: Int, w: Float)] = [] + rowsU.reserveCapacity(N * topK) + var wOrig = [Float](repeating: 0, count: N * topK) // origIdx = tok*topK+slot + // Hash-routed early layers (il < n_hash_layer=3): experts come from + // the ffn_gate_tid2eid token→expert table, NOT router top-k. Weights + // still = probs[selected]/sum*scaling. (Same fix as decode.) + let tid2eidHostArr = layer.ffnGateTid2Eid.map { tid2eidHost(layerIndex: li, tensor: $0) } + for t in 0 ..< N { + let top: [Int] + if let table = tid2eidHostArr { + top = (0 ..< topK).map { Int(table[tokens[t] * topK + $0]) } + } else { + var idx = Array((0 ..< nExperts).map { ($0, biasedH[t * nExperts + $0]) }) + idx.sort { $0.1 > $1.1 } + top = idx.prefix(topK).map { $0.0 } + } + // routed_scaling_factor is applied AFTER the sum-to-1 renorm + // (per the DSv4 reference) — applying it before, as `unbiased*scaling`, + // cancels in the division and drops the 1.5× entirely. + let ws = top.map { unbiasedH[t * nExperts + $0] } + let sum = ws.reduce(0, +) + let nw = sum > 0 ? ws.map { ($0 / sum) * scaling } : Array(repeating: scaling / Float(topK), count: topK) + for k in 0 ..< topK { rowsU.append((t, k, top[k], nw[k])); wOrig[t * topK + k] = nw[k] } + } + // Stable expert-sort (token order preserved within an expert run). + let perm = Array(0 ..< rowsU.count).sorted { + rowsU[$0].expert != rowsU[$1].expert ? rowsU[$0].expert < rowsU[$1].expert : $0 < $1 + } + let rows = perm.map { (tok: rowsU[$0].tok, expert: rowsU[$0].expert, w: rowsU[$0].w) } + let M = rows.count + // invPerm[tok*topK+slot] = sorted row position of that (token,slot). + var invPermArr = [UInt32](repeating: 0, count: M) + for j in 0 ..< M { let o = rowsU[perm[j]]; invPermArr[o.tok * topK + o.slot] = UInt32(j) } + // Ensure routed experts resident; map expert→packed slot. + let routedExperts = Array(Set(rows.map { $0.expert })) + let gName = "blk.\(li).ffn_gate_exps.weight"; let uName = "blk.\(li).ffn_up_exps.weight"; + let dName = "blk.\(li).ffn_down_exps.weight" + // Prefill routes a whole chunk's tokens. The gate/up/down expert + // weights are read via the zero-copy u16 view path (raw-gather + + // view-bm64), which needs no repacked split pool — just a pool cap + // sized to the routed-expert count. `cap` bounds the raw-gather pool. + let prefillPoolCap: Int? = + routedExperts.count > RESIDENT_POOL_CAP ? max(routedExperts.count, 1) : nil + let cap = prefillPoolCap ?? RESIDENT_POOL_CAP + + // Permuted x and per-role packed-slot indices. + let fcmd2 = device.makeCommandBuffer() + // Permute-by-expert in ONE gather dispatch (was an M-row CPU copy + // loop = M tiny GPU dispatches that scaled with N*topK). xPerm[r] = + // xNorm2[rows[r].tok]. + let xPerm = Tensor.empty(shape: [M, hidden], dtype: dt) + let permTok = Tensor.empty(shape: [M], dtype: .u32, device: device) + permTok.copyIn(from: rows.map { UInt32($0.tok) }) + _ = Ops.gather(table: xNorm2, tokenIds: permTok, on: fcmd2, into: xPerm) + // gate/up BGEMM → [M, intermediate]. + let (grid, signs) = GGUFDequant.iq2xxsTables(device: device) + let gateP = Tensor.empty(shape: [M, intermediate], dtype: dt) + let upP = Tensor.empty(shape: [M, intermediate], dtype: dt) + // RAW-GATHER + u16 view-bm64: bulk-copy routed experts' raw blocks into + // a reliable makeBuffer (cheap bulk memcpy, not the 32768-tiny-memcpy + // deinterleave), then the amortized bm64 reads them via aligned u16 (no + // split pool, no mmap-residency-zeros bug). SLOT-indexed (the raw pool + // is compacted by routed expert). + guard + let rawG = try bundle.rawGatherBlocks( + named: gName, expertIndices: routedExperts, nExperts: nExperts, device: device, poolCap: cap, + reuseKey: "prefill_view_gate"), + let rawU = try bundle.rawGatherBlocks( + named: uName, expertIndices: routedExperts, nExperts: nExperts, device: device, poolCap: cap, + reuseKey: "prefill_view_up") + else { + throw PrefillError( + message: "prefill L\(li): gate/up expert tensors '\(gName)'/'\(uName)' not found in GGUF") + } + let iq2Stride = rawG.nBlocksPerExpert * 66 + let slotGIdx = Tensor.empty(shape: [M], dtype: .u32, device: device) + slotGIdx.copyIn(from: rows.map { UInt32(rawG.slotOf[$0.expert] ?? 0) }) + let slotUIdx = Tensor.empty(shape: [M], dtype: .u32, device: device) + slotUIdx.copyIn(from: rows.map { UInt32(rawU.slotOf[$0.expert] ?? 0) }) + Ops.moeBgemmIQ2XXSViewU16Bm64( + x: xPerm, viewBuf: rawG.buffer, viewByteOffset: 0, + grid: grid, signs: signs, indices: slotGIdx, out: gateP, + mTotal: M, nOut: intermediate, kIn: hidden, + tensorByteOff: 0, expertByteStride: iq2Stride, on: fcmd2) + Ops.moeBgemmIQ2XXSViewU16Bm64( + x: xPerm, viewBuf: rawU.buffer, viewByteOffset: 0, + grid: grid, signs: signs, indices: slotUIdx, out: upP, + mTotal: M, nOut: intermediate, kIn: hidden, + tensorByteOff: 0, expertByteStride: iq2Stride, on: fcmd2) + // swiglu — element-wise over the whole [M, intermediate] tile in ONE + // dispatch (was an M-row Swift slice loop + M dispatches/layer = ~264k + // dispatches at N=1024 × 43 layers, the per-token CPU/encode overhead + // that made t/s DROP with N). gateP/upP/innerP are contiguous → flat. + let innerP = Tensor.empty(shape: [M, intermediate], dtype: dt) + Ops.swigluLimitMany(gates: [gateP], ups: [upP], outs: [innerP], limit: textConfig.swigluLimit, on: fcmd2) + // down → [M, hidden]. ZERO-REPACK: read raw Q2_K blocks via the view + // kernel (no deinterleave pool), slot-indexed like the IQ2 gate/up + // view path. + let downP = Tensor.empty(shape: [M, hidden], dtype: dt) + guard + let rawD = try? bundle.rawGatherBlocks( + named: dName, expertIndices: routedExperts, nExperts: nExperts, device: device, poolCap: cap, + reuseKey: "prefill_view_down") + else { + throw PrefillError(message: "prefill L\(li): down expert tensor '\(dName)' not found in GGUF") + } + let slotDIdx = Tensor.empty(shape: [M], dtype: .u32, device: device) + slotDIdx.copyIn(from: rows.map { UInt32(rawD.slotOf[$0.expert] ?? 0) }) + Ops.moeBgemmQ2KViewU16Bm64( + x: innerP, viewBuf: rawD.buffer, viewByteOffset: 0, + indices: slotDIdx, out: downP, mTotal: M, nOut: hidden, kIn: intermediate, + tensorByteOff: 0, expertByteStride: rawD.byteStride, on: fcmd2) + fcmd2.commit(); fcmd2.waitUntilCompleted() + + // Unpermute + weighted combine → moeAccum [N,hidden] in ONE dispatch + // (was an M-row CPU loop of Tensor.filled+mul+add — the last per-token + // offender in the batched path). invPerm maps each (token,slot) to its + // expert-sorted row in downP; the kernel gathers + scales by topK weights. + let fcmd3 = device.makeCommandBuffer() + let moeAccum = Tensor.filled(0.0, shape: [N, hidden], dtype: dt, device: device) + let invPermT = Tensor.empty(shape: [N * topK], dtype: .u32, device: device) + invPermT.copyIn(from: invPermArr) + let wT = Tensor.empty(shape: [N * topK], dtype: dt, device: device) + if dt == .f16 { wT.copyIn(from: wOrig.map { Float16($0) }) } else { wT.copyIn(from: wOrig) } + Ops.moeUnpermute( + expertOutputs: downP, invPerm: invPermT, topKWeights: wT, into: moeAccum, + nRows: N, hidden: hidden, k: topK, on: fcmd3) + // Shared expert (Q8 gemm, M=N). + let sgQ8 = q8("blk.\(li).ffn_gate_shexp.weight")!; let suQ8 = q8("blk.\(li).ffn_up_shexp.weight")!; + let sdQ8 = q8("blk.\(li).ffn_down_shexp.weight")! + let sG = Tensor.empty(shape: [N, sgQ8.mOut], dtype: dt) + gq8(sgQ8, xNorm2, sG, N, fcmd3) + let sU = Tensor.empty(shape: [N, suQ8.mOut], dtype: dt) + gq8(suQ8, xNorm2, sU, N, fcmd3) + // shared-expert swiglu — one flat dispatch over [N, intermediate]. + let sInner = Tensor.empty(shape: [N, intermediate], dtype: dt) + Ops.swigluLimitMany(gates: [sG], ups: [sU], outs: [sInner], limit: textConfig.swigluLimit, on: fcmd3) + let shexpOut = Tensor.empty(shape: [N, sdQ8.mOut], dtype: dt) + gq8(sdQ8, sInner, shexpOut, N, fcmd3) + let blockOut = Ops.add(moeAccum, shexpOut, on: fcmd3) + let newH = Ops.dsv4MhcExpand( + blockOut: blockOut, post: postF, comb: combF, residualState: hAfterAttn, + hiddenDim: hidden, nHc: 4, nTokens: N, on: fcmd3) + fcmd3.commit(); fcmd3.waitUntilCompleted() + return newH + } +} diff --git a/Sources/FFAI/Models/Text/LlamaText.swift b/Sources/FFAI/Models/Text/LlamaText.swift index b83ae19d..1c5b59f6 100644 --- a/Sources/FFAI/Models/Text/LlamaText.swift +++ b/Sources/FFAI/Models/Text/LlamaText.swift @@ -553,25 +553,32 @@ public final class LlamaModel: LanguageModel { device: device ) } - case .auraQuantized(let scheme): + case .auraQuantized(let requestedScheme): + // Auto-asymmetric policy: bump K to 8-bit when GQA ≥ 6. + // Mirrors canonical TQ+'s TURBO_AUTO_ASYMMETRIC behavior. + // **Opt-in** — default OFF; set `FFAI_AURA_AUTO_ASYM=1` to + // enable. A per-load `LoadOptions` flag will replace the + // env knob in a follow-up. + let gqaFactor = nHeads / max(nKVHeads, 1) + let scheme: AURAScheme = AURAScheme.autoAsymmetricOptedIn + ? AURAScheme.autoAsymmetric( + requested: requestedScheme, gqaFactor: gqaFactor) + : requestedScheme // Codebooks are shared across layers; rotations are per-layer // (deterministic SRHT seeded by layer index). See Qwen3's // matching case for the longer explanation. - let kCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.keyBits) - let kBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.keyBits) - let vCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.valueBits) - let vBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.valueBits) - - let kCodebook = Tensor.empty(shape: [kCodebookData.count], dtype: .f32, device: device) - kCodebook.copyIn(from: kCodebookData) - let kBoundaries = Tensor.empty( - shape: [kBoundariesData.count], dtype: .f32, device: device) - kBoundaries.copyIn(from: kBoundariesData) - let vCodebook = Tensor.empty(shape: [vCodebookData.count], dtype: .f32, device: device) - vCodebook.copyIn(from: vCodebookData) - let vBoundaries = Tensor.empty( - shape: [vBoundariesData.count], dtype: .f32, device: device) - vBoundaries.copyIn(from: vBoundariesData) + // Codebook in cache dtype (matches encode/decode kernel + // signatures — no per-call cast). Boundaries stay f32: + // encoder-only and precision-sensitive at the Lloyd-Max + // comparison. + let kCodebook = AURACodebook.centroidsTensor( + dim: headDim, bits: scheme.keyBits, dtype: dtype, device: device) + let kBoundaries = AURACodebook.boundariesTensor( + dim: headDim, bits: scheme.keyBits, dtype: dtype, device: device) + let vCodebook = AURACodebook.centroidsTensor( + dim: headDim, bits: scheme.valueBits, dtype: dtype, device: device) + let vBoundaries = AURACodebook.boundariesTensor( + dim: headDim, bits: scheme.valueBits, dtype: dtype, device: device) let sharedK = Tensor.empty( shape: [nKVHeads, cap, headDim], diff --git a/Sources/FFAI/Models/Text/Qwen3Text.swift b/Sources/FFAI/Models/Text/Qwen3Text.swift index a502f589..13ca7144 100644 --- a/Sources/FFAI/Models/Text/Qwen3Text.swift +++ b/Sources/FFAI/Models/Text/Qwen3Text.swift @@ -314,12 +314,106 @@ public final class Qwen3Layer: Module { qForSdpa = qRotated } - let (cacheK, cacheV) = cache.prepareForAttention(on: cmd) - let attnOut = Ops.sdpaDecode( - q: qForSdpa, k: cacheK, v: cacheV, - nQHeads: nHeads, nKVHeads: nKVHeads, headDim: headDim, - nKV: cache.length, kvStride: cache.maxSeq, - scale: scale, on: cmd) + // Decode path selection: + // • `.compressed` (default) — score Q directly against the + // packed K codes. V is dequanted per tile on chip; no + // maxSeq-sized mirror is materialised. Two kernel variants: + // – `auraFlashSdpa2Pass` when supported (token-parallel + // FA-2; one TG per (q_head, block) — saturates the GPU + // at long context). + // – `auraFlashSdpa` (single-pass, one TG per q_head) + // fallback for combos the 2-pass kernel hasn't emitted. + // `kvStride = maxSeq` (NOT `length`) — the per-head row + // stride is the allocated cache stride, not the live row + // count. Passing `length` aliases reads across head + // boundaries (metaltile#203). + // • `.dequantMirror` — fully dequant the cache via + // `prepareForAttention` and run the standard sdpaDecode. + // Same quality as compressed, gives back the memory win. + // Non-AURA caches always take the dequantMirror branch. + let attnOut: Tensor + if let auraCache = cache as? AURAQuantizedKVCache, + auraCache.decodePath == .compressed, + Ops.supportsAuraFlashSdpa2Pass( + keyBits: auraCache.scheme.keyBits, + valueBits: auraCache.scheme.valueBits, + headDim: headDim, dtype: h.dtype) + { + // FA-2 block tile. Default 32 — `blockSizeSweep` bench at + // KV=256/1024 on M5 Max (Qwen3-0.6B-4bit aura4v4) shows + // bs=32 beats bs=64 by +2.5% to +5.5% and bs=128/256 are + // strictly worse. Smaller blocks distribute the + // single-simdgroup-per-(q_head, block) workload across more + // simdgroups, which matters more on Apple GPUs than the + // per-block work coalescing FA-2's bs=64 assumed for CUDA. + // `AuraFlashScratchCache.blockSizeOverride` is the bench + // knob for re-sweeping; production leaves it nil. + let blockSize = AuraFlashScratchCache.blockSizeOverride ?? 32 + let maxBlocks = (auraCache.maxSeq + blockSize - 1) / blockSize + let partials = AuraFlashScratchCache.partials( + nQHeads: nHeads, maxBlocks: maxBlocks, + headDim: headDim, dtype: h.dtype) + let outTensor = Tensor.empty( + shape: [nHeads, headDim], dtype: h.dtype, device: device) + Ops.auraFlashSdpa2Pass( + q: qForSdpa, + kPacked: auraCache.kPacked, kNorms: auraCache.kNorms, + kCodebook: auraCache.kCodebook, + vPacked: auraCache.vPacked, vNorms: auraCache.vNorms, + vCodebook: auraCache.vCodebook, + into: outTensor, + nQHeads: nHeads, nKVHeads: nKVHeads, headDim: headDim, + kPackedWidth: auraCache.kPackedWidth, + vPackedWidth: auraCache.vPackedWidth, + liveLength: auraCache.length, kvStride: auraCache.maxSeq, + keyBits: auraCache.scheme.keyBits, + valueBits: auraCache.scheme.valueBits, + scale: scale, + blockSize: blockSize, + partialO: partials.partialO, + partialM: partials.partialM, + partialL: partials.partialL, + on: cmd) + attnOut = outTensor + } else if let auraCache = cache as? AURAQuantizedKVCache, + auraCache.decodePath == .compressed, + Ops.supportsAuraFlashSdpa( + keyBits: auraCache.scheme.keyBits, + valueBits: auraCache.scheme.valueBits, + headDim: headDim, dtype: h.dtype) + { + let outTensor = Tensor.empty( + shape: [nHeads, headDim], dtype: h.dtype, device: device) + // Qwen3 dense has no attention sinks — the kernel gates the + // sinks load on `hasSinks=false`, but the wrapper still + // requires a same-dtype buffer to satisfy the precondition. + let sinksScratch = Tensor.empty( + shape: [nHeads], dtype: h.dtype, device: device) + Ops.auraFlashSdpa( + q: qForSdpa, sinks: sinksScratch, + kPacked: auraCache.kPacked, kNorms: auraCache.kNorms, + kCodebook: auraCache.kCodebook, + vPacked: auraCache.vPacked, vNorms: auraCache.vNorms, + vCodebook: auraCache.vCodebook, + into: outTensor, + nQHeads: nHeads, nKVHeads: nKVHeads, headDim: headDim, + kPackedWidth: auraCache.kPackedWidth, + vPackedWidth: auraCache.vPackedWidth, + liveLength: auraCache.length, kvStride: auraCache.maxSeq, + keyBits: auraCache.scheme.keyBits, + valueBits: auraCache.scheme.valueBits, + scale: scale, + hasSinks: false, windowSize: 0, + on: cmd) + attnOut = outTensor + } else { + let (cacheK, cacheV) = cache.prepareForAttention(on: cmd) + attnOut = Ops.sdpaDecode( + q: qForSdpa, k: cacheK, v: cacheV, + nQHeads: nHeads, nKVHeads: nKVHeads, headDim: headDim, + nKV: cache.length, kvStride: cache.maxSeq, + scale: scale, on: cmd) + } let attnReadyForOProj: Tensor if let auraCache = cache as? AURAQuantizedKVCache { @@ -583,27 +677,33 @@ public final class Qwen3Model: LanguageModel { device: device ) } - case .auraQuantized(let scheme): + case .auraQuantized(let requestedScheme): + // Auto-asymmetric policy: GQA ≥ 6 amplifies K-quantization + // error across the shared-K-head fan-out, so bump K to + // 8-bit when the model is in that regime (mirrors canonical + // TQ+'s TURBO_AUTO_ASYMMETRIC env behavior). **Opt-in.** + // Default OFF; set `FFAI_AURA_AUTO_ASYM=1` to enable. + let gqaFactor = nHeads / max(nKVHeads, 1) + let scheme: AURAScheme = AURAScheme.autoAsymmetricOptedIn + ? AURAScheme.autoAsymmetric( + requested: requestedScheme, gqaFactor: gqaFactor) + : requestedScheme // Codebooks are shared across layers (Lloyd-Max levels are // dim-only — no per-layer statistics baked in yet). Rotations // are per-layer: each Π_l is an SRHT matrix seeded by the // layer index, matching the AURA paper's "fresh rotation per // tensor" recipe for de-correlating activation statistics. - let kCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.keyBits) - let kBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.keyBits) - let vCodebookData = AURACodebook.centroids(dim: headDim, bits: scheme.valueBits) - let vBoundariesData = AURACodebook.boundaries(dim: headDim, bits: scheme.valueBits) - - let kCodebook = Tensor.empty(shape: [kCodebookData.count], dtype: .f32, device: device) - kCodebook.copyIn(from: kCodebookData) - let kBoundaries = Tensor.empty( - shape: [kBoundariesData.count], dtype: .f32, device: device) - kBoundaries.copyIn(from: kBoundariesData) - let vCodebook = Tensor.empty(shape: [vCodebookData.count], dtype: .f32, device: device) - vCodebook.copyIn(from: vCodebookData) - let vBoundaries = Tensor.empty( - shape: [vBoundariesData.count], dtype: .f32, device: device) - vBoundaries.copyIn(from: vBoundariesData) + // Codebook in cache dtype (matches encode/decode kernel + // signatures — no per-call cast). Boundaries stay f32: + // encoder-only and precision-sensitive at Lloyd-Max compare. + let kCodebook = AURACodebook.centroidsTensor( + dim: headDim, bits: scheme.keyBits, dtype: dtype, device: device) + let kBoundaries = AURACodebook.boundariesTensor( + dim: headDim, bits: scheme.keyBits, dtype: dtype, device: device) + let vCodebook = AURACodebook.centroidsTensor( + dim: headDim, bits: scheme.valueBits, dtype: dtype, device: device) + let vBoundaries = AURACodebook.boundariesTensor( + dim: headDim, bits: scheme.valueBits, dtype: dtype, device: device) // Shared working buffers — same pattern as affineQuantized: // bulk-dequant target shared across all layers. diff --git a/Sources/FFAI/Models/Text/Qwen3xText.swift b/Sources/FFAI/Models/Text/Qwen3xText.swift index 40f02c7f..8839b4e0 100644 --- a/Sources/FFAI/Models/Text/Qwen3xText.swift +++ b/Sources/FFAI/Models/Text/Qwen3xText.swift @@ -1422,6 +1422,7 @@ public final class Qwen35GDNMixer: Module { _ xNorm: Tensor, cache: Qwen35GDNLayerCache, cmd: MTLCommandBuffer, device: Device ) -> Tensor { + return Profile.signpost("gdn.forward") { () -> Tensor in // ── GPU phase 1: projections + conv + SiLU ──────────────────── // 4-projection shared-encoder fast path when all inProj are int4 // QuantizedLinear with the same groupSize. Saves 3 encoder @@ -1655,6 +1656,7 @@ public final class Qwen35GDNMixer: Module { phase3.waitUntilCompleted() } return result + } // Profile.signpost("gdn.forward") } /// T-batched GDN mixer forward. Returns `[T, hidden]` flat. Requires @@ -2088,6 +2090,7 @@ public final class Qwen35AttentionMixer: Module { _ xNorm: Tensor, position: Int, cache kv: KVCache, cmd: MTLCommandBuffer, device: Device ) -> Tensor { + return Profile.signpost("attn.forward") { () -> Tensor in // q_proj projects 2× heads when attn_output_gate is set: the // first `nHeads · headDim` elements are the queries, the second // half is the per-head sigmoid gate. @@ -2277,6 +2280,7 @@ public final class Qwen35AttentionMixer: Module { attnFlat, gate, on: cmd, into: attnFlatGatedScratch) } return oProj(attnFlat, on: cmd) + } // Profile.signpost("attn.forward") } /// T-batched attention forward. `xNormFlat` is `[T, hidden]` flat @@ -3017,20 +3021,24 @@ public final class Qwen35Model: LanguageModel { // The embedding + layers run on internal buffers — never `cmd`. var workCmd = device.makeCommandBuffer() - var h = embedTokens(tokenTensor, on: workCmd).reshaped(to: [hidden]) + var h = Profile.signpost("model.embed") { + embedTokens(tokenTensor, on: workCmd).reshaped(to: [hidden]) + } - for (i, layer) in layers.enumerated() { - h = layer.decode( - h, position: position, cache: caches[i], - cmd: workCmd, device: device) - // Refresh `workCmd` if the layer committed it. - let committed: Bool - switch layer { - case let l as Qwen35GDNLayer: committed = l.commitsCommandBuffer - case let l as Qwen35AttentionLayer: committed = l.commitsCommandBuffer - default: committed = false + Profile.signpost("model.layer_loop") { + for (i, layer) in layers.enumerated() { + h = layer.decode( + h, position: position, cache: caches[i], + cmd: workCmd, device: device) + // Refresh `workCmd` if the layer committed it. + let committed: Bool + switch layer { + case let l as Qwen35GDNLayer: committed = l.commitsCommandBuffer + case let l as Qwen35AttentionLayer: committed = l.commitsCommandBuffer + default: committed = false + } + if committed { workCmd = device.makeCommandBuffer() } } - if committed { workCmd = device.makeCommandBuffer() } } // If the last layer was a non-committing attention layer with a @@ -3049,8 +3057,10 @@ public final class Qwen35Model: LanguageModel { // Final norm + lm_head queue onto the caller's pristine `cmd`. // Fused rmsNorm+qgemv when lmHead is 4-bit QuantizedLinear with // matching constraints; falls back otherwise. - return qwen35FinalNormLmHead( - h: h, finalNorm: finalNorm, lmHead: lmHead, on: cmd) + return Profile.signpost("model.final_norm_lm_head") { + qwen35FinalNormLmHead( + h: h, finalNorm: finalNorm, lmHead: lmHead, on: cmd) + } } /// LanguageModel-protocol entry point for chunked prefill. Delegates diff --git a/Sources/FFAI/Ops/Ops.swift b/Sources/FFAI/Ops/Ops.swift index ea808b5b..94313aff 100644 --- a/Sources/FFAI/Ops/Ops.swift +++ b/Sources/FFAI/Ops/Ops.swift @@ -76,6 +76,207 @@ public enum Ops { // ─── Element-wise binary: add ──────────────────────────────────── + /// In-place scaled add: `accum[i] += scalar * src[i]`. + public static func axpyScalarInplace( + _ accum: Tensor, _ src: Tensor, scalar: Float, on cmd: MTLCommandBuffer + ) { + precondition(accum.shape == src.shape, "axpyScalarInplace: shape mismatch") + precondition(accum.dtype == src.dtype, "axpyScalarInplace: dtype mismatch") + let n = accum.elementCount + let (grid, tg) = elementwiseGrid(n) + switch accum.dtype { + case .f32: + MetalTileKernels.ffai_axpy_scalar_inplace_f32( + src: src.buffer, srcOffset: src.offset, + accum: accum.buffer, accumOffset: accum.offset, + scalar: scalar, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_axpy_scalar_inplace_f16( + src: src.buffer, srcOffset: src.offset, + accum: accum.buffer, accumOffset: accum.offset, + scalar: scalar, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_axpy_scalar_inplace_bf16( + src: src.buffer, srcOffset: src.offset, + accum: accum.buffer, accumOffset: accum.offset, + scalar: scalar, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("axpyScalarInplace: unsupported dtype \(accum.dtype)") + } + } + + /// Fused multi-expert (top-K=6) down gemv + weighted accumulate. + /// `accum[r] += Σ_k w[k] * (downs[k][r] · inners[k])`. + /// 1 dispatch replaces 18 (6 gemv + 6 mul + 6 add). + public static func moeDownWeightedSum6( + downs: [Tensor], inners: [Tensor], weights: Tensor, accum: Tensor, + on cmd: MTLCommandBuffer + ) { + precondition(downs.count == 6 && inners.count == 6, + "moeDownWeightedSum6: expects exactly 6 of each") + precondition(weights.dtype == .f32 && weights.elementCount >= 6, + "moeDownWeightedSum6: weights must be f32 of len 6") + let m = downs[0].shape[0] + let k = downs[0].shape[1] + precondition(k <= 2048, "moeDownWeightedSum6: k \(k) exceeds kernel TG staging cap 2048") + let dt = downs[0].dtype + let tgWidth = 256 + let grid = MTLSize(width: m * tgWidth, height: 1, depth: 1) + let tg = MTLSize(width: tgWidth, height: 1, depth: 1) + switch dt { + case .f16: + MetalTileKernels.ffai_moe_down_weighted_sum_6_f16( + down_0: downs[0].buffer, down_0Offset: downs[0].offset, + inner_0: inners[0].buffer, inner_0Offset: inners[0].offset, + down_1: downs[1].buffer, down_1Offset: downs[1].offset, + inner_1: inners[1].buffer, inner_1Offset: inners[1].offset, + down_2: downs[2].buffer, down_2Offset: downs[2].offset, + inner_2: inners[2].buffer, inner_2Offset: inners[2].offset, + down_3: downs[3].buffer, down_3Offset: downs[3].offset, + inner_3: inners[3].buffer, inner_3Offset: inners[3].offset, + down_4: downs[4].buffer, down_4Offset: downs[4].offset, + inner_4: inners[4].buffer, inner_4Offset: inners[4].offset, + down_5: downs[5].buffer, down_5Offset: downs[5].offset, + inner_5: inners[5].buffer, inner_5Offset: inners[5].offset, + weights: weights.buffer, weightsOffset: weights.offset, + accum: accum.buffer, accumOffset: accum.offset, + k: UInt32(k), + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_down_weighted_sum_6_f32( + down_0: downs[0].buffer, down_0Offset: downs[0].offset, + inner_0: inners[0].buffer, inner_0Offset: inners[0].offset, + down_1: downs[1].buffer, down_1Offset: downs[1].offset, + inner_1: inners[1].buffer, inner_1Offset: inners[1].offset, + down_2: downs[2].buffer, down_2Offset: downs[2].offset, + inner_2: inners[2].buffer, inner_2Offset: inners[2].offset, + down_3: downs[3].buffer, down_3Offset: downs[3].offset, + inner_3: inners[3].buffer, inner_3Offset: inners[3].offset, + down_4: downs[4].buffer, down_4Offset: downs[4].offset, + inner_4: inners[4].buffer, inner_4Offset: inners[4].offset, + down_5: downs[5].buffer, down_5Offset: downs[5].offset, + inner_5: inners[5].buffer, inner_5Offset: inners[5].offset, + weights: weights.buffer, weightsOffset: weights.offset, + accum: accum.buffer, accumOffset: accum.offset, + k: UInt32(k), + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_down_weighted_sum_6_bf16( + down_0: downs[0].buffer, down_0Offset: downs[0].offset, + inner_0: inners[0].buffer, inner_0Offset: inners[0].offset, + down_1: downs[1].buffer, down_1Offset: downs[1].offset, + inner_1: inners[1].buffer, inner_1Offset: inners[1].offset, + down_2: downs[2].buffer, down_2Offset: downs[2].offset, + inner_2: inners[2].buffer, inner_2Offset: inners[2].offset, + down_3: downs[3].buffer, down_3Offset: downs[3].offset, + inner_3: inners[3].buffer, inner_3Offset: inners[3].offset, + down_4: downs[4].buffer, down_4Offset: downs[4].offset, + inner_4: inners[4].buffer, inner_4Offset: inners[4].offset, + down_5: downs[5].buffer, down_5Offset: downs[5].offset, + inner_5: inners[5].buffer, inner_5Offset: inners[5].offset, + weights: weights.buffer, weightsOffset: weights.offset, + accum: accum.buffer, accumOffset: accum.offset, + k: UInt32(k), + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("moeDownWeightedSum6: unsupported dtype \(dt)") + } + } + + /// Fused gate gemv + up gemv + SwiGLU. 1 dispatch replaces 3. + /// `inner[r] = silu(gate_w[r] · x) * (up_w[r] · x)`. + public static func gateUpSwigluFused( + gateW: Tensor, upW: Tensor, x: Tensor, inner: Tensor, + on cmd: MTLCommandBuffer + ) { + precondition(gateW.shape.count == 2 && upW.shape.count == 2, "gateUpSwigluFused: weights 2D") + precondition(x.shape.count == 1 && inner.shape.count == 1, "gateUpSwigluFused: vec/inner 1D") + precondition(gateW.shape == upW.shape, "gateUpSwigluFused: gate/up shape mismatch") + precondition(gateW.shape[0] == inner.shape[0], "gateUpSwigluFused: m mismatch") + precondition(gateW.shape[1] == x.shape[0], "gateUpSwigluFused: k mismatch") + let m = gateW.shape[0] + let k = gateW.shape[1] + let tgWidth = 256 + let grid = MTLSize(width: m * tgWidth, height: 1, depth: 1) + let tg = MTLSize(width: tgWidth, height: 1, depth: 1) + switch gateW.dtype { + case .f32: + MetalTileKernels.ffai_gate_up_swiglu_fused_f32( + gate_w: gateW.buffer, gate_wOffset: gateW.offset, + up_w: upW.buffer, up_wOffset: upW.offset, + x: x.buffer, xOffset: x.offset, + inner: inner.buffer, innerOffset: inner.offset, + k: UInt32(k), + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_gate_up_swiglu_fused_f16( + gate_w: gateW.buffer, gate_wOffset: gateW.offset, + up_w: upW.buffer, up_wOffset: upW.offset, + x: x.buffer, xOffset: x.offset, + inner: inner.buffer, innerOffset: inner.offset, + k: UInt32(k), + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gate_up_swiglu_fused_bf16( + gate_w: gateW.buffer, gate_wOffset: gateW.offset, + up_w: upW.buffer, up_wOffset: upW.offset, + x: x.buffer, xOffset: x.offset, + inner: inner.buffer, innerOffset: inner.offset, + k: UInt32(k), + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("gateUpSwigluFused: unsupported dtype \(gateW.dtype)") + } + } + + /// Fused gemv + weighted in-place accumulate: + /// `accum[r] += weight * (Σ_j mat[r, j] * vec[j])`. + /// Saves 2 dispatches per call (vs gemv → Tensor.filled(w) → mul → add). + public static func gemvAxpyInplace( + mat: Tensor, vec: Tensor, accum: Tensor, weight: Float, + on cmd: MTLCommandBuffer + ) { + precondition(mat.shape.count == 2, "gemvAxpyInplace: mat must be 2D") + precondition(vec.shape.count == 1, "gemvAxpyInplace: vec must be 1D") + precondition(accum.shape.count == 1, "gemvAxpyInplace: accum must be 1D") + precondition(mat.shape[1] == vec.shape[0], "gemvAxpyInplace: k mismatch") + precondition(mat.shape[0] == accum.shape[0], "gemvAxpyInplace: m mismatch") + precondition(mat.dtype == vec.dtype && mat.dtype == accum.dtype, "gemvAxpyInplace: dtype mismatch") + let m = mat.shape[0] + let k = mat.shape[1] + let tgWidth = 256 + let grid = MTLSize(width: m * tgWidth, height: 1, depth: 1) + let tg = MTLSize(width: tgWidth, height: 1, depth: 1) + switch mat.dtype { + case .f32: + MetalTileKernels.ffai_gemv_axpy_inplace_f32( + mat: mat.buffer, matOffset: mat.offset, + vec: vec.buffer, vecOffset: vec.offset, + accum: accum.buffer, accumOffset: accum.offset, + k: UInt32(k), weight: weight, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_gemv_axpy_inplace_f16( + mat: mat.buffer, matOffset: mat.offset, + vec: vec.buffer, vecOffset: vec.offset, + accum: accum.buffer, accumOffset: accum.offset, + k: UInt32(k), weight: weight, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gemv_axpy_inplace_bf16( + mat: mat.buffer, matOffset: mat.offset, + vec: vec.buffer, vecOffset: vec.offset, + accum: accum.buffer, accumOffset: accum.offset, + k: UInt32(k), weight: weight, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("gemvAxpyInplace: unsupported dtype \(mat.dtype)") + } + } + public static func add( _ a: Tensor, _ b: Tensor, on cmd: MTLCommandBuffer, into out: Tensor? = nil @@ -621,18 +822,10 @@ public enum Ops { // Allocate one or two eps buffers depending on whether the two // eps values match — RMSNorm pairs typically come from the // same checkpoint config and so share a single eps in practice. - let epsBuf1: MTLBuffer = { - let b = device.makeBuffer(length: 4) - var v = eps1 - memcpy(b.contents(), &v, 4) - return b - }() + let epsBuf1: MTLBuffer = device.scalarBuffer(eps1) let epsBuf2: MTLBuffer = { if eps1 == eps2 { return epsBuf1 } - let b = device.makeBuffer(length: 4) - var v = eps2 - memcpy(b.contents(), &v, 4) - return b + return device.scalarBuffer(eps2) }() @inline(__always) func dispatch( @@ -700,9 +893,7 @@ public enum Ops { let normed = normedOut ?? Tensor.empty(shape: a.shape, dtype: a.dtype) // eps as a 1-element f32 buffer. - var epsValue = eps - let epsBuf = device.makeBuffer(length: 4) - memcpy(epsBuf.contents(), &epsValue, 4) + let epsBuf = device.scalarBuffer(eps) // TPG = n / 4 (kernel vectorises in 4-elem chunks). One // threadgroup per row → grid = nRows * (n/4) threads × 1 × 1. @@ -757,9 +948,7 @@ public enum Ops { eps: Float, n: Int, nRows: Int, on cmd: MTLCommandBuffer ) { // eps as a 1-element f32 buffer. - var epsValue = eps - let epsBuf = device.makeBuffer(length: 4) - memcpy(epsBuf.contents(), &epsValue, 4) + let epsBuf = device.scalarBuffer(eps) // Fast kernel needs TPG = n/4 with TPG a multiple of 32 and // ≤ 1024. Anything outside that — too wide, too narrow, or not @@ -3589,6 +3778,7 @@ public enum Ops { head_dim: UInt32(headDim), n_kv: UInt32(nKV), kv_stride: UInt32(kvStride), heads_per_group: UInt32(headsPerGroup), + has_sink: 0, sink_logit: 0.0, scale: scale, gridSize: grid, threadgroupSize: tg, on: cmd) case (64, .f16): @@ -3600,6 +3790,7 @@ public enum Ops { head_dim: UInt32(headDim), n_kv: UInt32(nKV), kv_stride: UInt32(kvStride), heads_per_group: UInt32(headsPerGroup), + has_sink: 0, sink_logit: 0.0, scale: scale, gridSize: grid, threadgroupSize: tg, on: cmd) case (64, .bf16): @@ -3611,6 +3802,7 @@ public enum Ops { head_dim: UInt32(headDim), n_kv: UInt32(nKV), kv_stride: UInt32(kvStride), heads_per_group: UInt32(headsPerGroup), + has_sink: 0, sink_logit: 0.0, scale: scale, gridSize: grid, threadgroupSize: tg, on: cmd) case (256, .f32): @@ -3622,6 +3814,7 @@ public enum Ops { head_dim: UInt32(headDim), n_kv: UInt32(nKV), kv_stride: UInt32(kvStride), heads_per_group: UInt32(headsPerGroup), + has_sink: 0, sink_logit: 0.0, scale: scale, gridSize: grid, threadgroupSize: tg, on: cmd) case (256, .f16): @@ -3633,6 +3826,7 @@ public enum Ops { head_dim: UInt32(headDim), n_kv: UInt32(nKV), kv_stride: UInt32(kvStride), heads_per_group: UInt32(headsPerGroup), + has_sink: 0, sink_logit: 0.0, scale: scale, gridSize: grid, threadgroupSize: tg, on: cmd) case (256, .bf16): @@ -3644,6 +3838,7 @@ public enum Ops { head_dim: UInt32(headDim), n_kv: UInt32(nKV), kv_stride: UInt32(kvStride), heads_per_group: UInt32(headsPerGroup), + has_sink: 0, sink_logit: 0.0, scale: scale, gridSize: grid, threadgroupSize: tg, on: cmd) // d512 routes to the dedicated `ffai_sdpa_decode_d512_*` kernel. @@ -4202,10 +4397,22 @@ public enum Ops { on cmd: MTLCommandBuffer ) { precondition( - rotation.dtype == .f32 && boundaries.dtype == .f32 && codebook.dtype == .f32, - "Ops.auraEncode: rotation/boundaries/codebook must be f32") + rotation.dtype == input.dtype && boundaries.dtype == input.dtype, + "Ops.auraEncode: rotation/boundaries dtype must match input " + + "dtype (got rotation=\(rotation.dtype), " + + "boundaries=\(boundaries.dtype) vs input=\(input.dtype)) " + + "— metaltile #226 unified to Tensor; the Π matrix " + + "dominates encoder bandwidth so narrowing it at f16/bf16 " + + "halves the dominant read") + precondition( + codebook.dtype == input.dtype, + "Ops.auraEncode: codebook dtype must match input dtype " + + "(got \(codebook.dtype) vs \(input.dtype))") precondition(packedOut.dtype == .u32, "Ops.auraEncode: packed_out must be u32") - precondition(normsOut.dtype == .f32, "Ops.auraEncode: norms_out must be f32") + precondition( + normsOut.dtype == input.dtype, + "Ops.auraEncode: norms_out dtype must match input dtype " + + "(got \(normsOut.dtype) vs \(input.dtype))") // Kernel-invariant validation — see OpsValidation.swift. if let reason = OpsValidation.validateAuraEncode(rows: rows, dim: dim, bits: bits) { preconditionFailure("Ops.auraEncode: \(reason)") @@ -4380,8 +4587,10 @@ public enum Ops { ) { precondition(packed.dtype == .u32, "Ops.auraDequantRotated: packed must be u32") precondition( - norms.dtype == .f32 && codebook.dtype == .f32, - "Ops.auraDequantRotated: norms/codebook must be f32") + norms.dtype == out.dtype && codebook.dtype == out.dtype, + "Ops.auraDequantRotated: norms/codebook must match output " + + "dtype (got norms=\(norms.dtype), codebook=\(codebook.dtype) " + + "vs out=\(out.dtype)) — post-AURA-dtype-unification") let stride = cacheStride ?? tokens // Kernel-invariant validation (cacheStride row-stride contract, // packedWidth dim coverage, bit-width). See @@ -4518,6 +4727,492 @@ public enum Ops { /// * `x.dtype == rotation.dtype` (gemv requires matched dtypes — /// the caller is expected to keep an activation-dtype copy of /// the rotation alongside the f32 copy required by `auraEncode`) + /// AURA flash-SDPA (compressed decode). Walks the packed K + V + /// buffers directly — no dequant-to-mirror — saving a per-decode + /// `nKVHeads × maxSeq × headDim` materialisation. At Qwen3-1.7B / + /// maxSeq=32K that's ~1.8 GB of working-buffer reads eliminated per + /// decode step. + /// + /// Caller must: + /// - have already applied Π to `q` (matches the existing + /// `dequantMirror` decode path — Π applied to Q before SDPA so + /// scores cancel; Π^T applied to the output before oProj). + /// - confirm `supportsFlashSdpa(scheme:headDim:)` is true; this + /// wrapper only covers the metaltile flash variants currently + /// emitted: (kb=4, vb=2) and (kb=4, vb=4) × d ∈ {64, 128}. + /// - own + pass a `sinks` tensor of shape `[nQHeads]` (zeroed + + /// `hasSinks=false` gives a no-op attention-sink path). + /// + /// The kernel takes `q_rot` as fp32; this wrapper casts on the + /// caller's behalf into a scratch buffer if needed. Output dtype + /// is the model's activation dtype. + /// - Parameter liveLength: number of populated K/V rows the loop + /// should iterate over (the attention upper bound). + /// - Parameter kvStride: per-KV-head row stride of the cache — + /// typically `maxSeq` of the AURA KVCache. MUST equal the cache's + /// allocated stride, not `liveLength`, otherwise off-head reads + /// alias into the previous head's tail bytes when the cache isn't + /// fully populated. + public static func auraFlashSdpa( + q: Tensor, sinks: Tensor, + kPacked: Tensor, kNorms: Tensor, kCodebook: Tensor, + vPacked: Tensor, vNorms: Tensor, vCodebook: Tensor, + into out: Tensor, + nQHeads: Int, nKVHeads: Int, headDim: Int, + kPackedWidth: Int, vPackedWidth: Int, + liveLength: Int, kvStride: Int, + keyBits: Int, valueBits: Int, + scale: Float, + hasSinks: Bool = false, windowSize: Int = 0, + on cmd: MTLCommandBuffer + ) { + precondition( + kvStride >= liveLength, + "Ops.auraFlashSdpa: kvStride (\(kvStride)) must be ≥ liveLength (\(liveLength))") + precondition( + nQHeads % nKVHeads == 0, + "Ops.auraFlashSdpa: nQHeads (\(nQHeads)) must be divisible by nKVHeads (\(nKVHeads))") + precondition( + kPacked.dtype == .u32 && vPacked.dtype == .u32, + "Ops.auraFlashSdpa: packed K/V must be u32") + precondition( + kNorms.dtype == out.dtype && vNorms.dtype == out.dtype + && kCodebook.dtype == out.dtype && vCodebook.dtype == out.dtype, + "Ops.auraFlashSdpa: norms + codebooks must match activation " + + "dtype (got \(kNorms.dtype) vs \(out.dtype))") + precondition( + sinks.dtype == out.dtype, + "Ops.auraFlashSdpa: sinks must match activation dtype " + + "(got \(sinks.dtype) vs \(out.dtype))") + precondition( + q.dtype == out.dtype, + "Ops.auraFlashSdpa: q dtype must match activation dtype " + + "(got \(q.dtype) vs \(out.dtype))") + + // The flash kernel's contract (aura_flash_sdpa.rs header): + // `q_rot` is WHT-rotated AND **pre-scaled** by the caller. The + // kernel computes `(Q · centroid) * k_norm` with no internal + // scale — caller must bake the softmax scale (1/√headDim) into + // Q before dispatch. Scratch + scale buffer cached by + // (shape, dtype, scale) so the hot decode path doesn't pay 2× + // Metal buffer allocations per attn layer per token. + // + // Post-dtype-unification the multiply happens in the activation + // dtype — kernel does cast-at-load to f32 internally, matching + // the precision of every other AURA kernel (encode / score / + // value / 2-pass), and the C++ TQ+ fork's production pattern. + let qScratch = AuraFlashScratchCache.qScratch( + shape: q.shape, dtype: out.dtype) + let scaleBuf = AuraFlashScratchCache.scaleBuffer( + shape: q.shape, scale: scale, dtype: out.dtype) + _ = Ops.mul(q, scaleBuf, on: cmd, into: qScratch) + + let dim = UInt32(headDim) + let kpw = UInt32(kPackedWidth) + let vpw = UInt32(vPackedWidth) + let tokens = UInt32(liveLength) + let kvStrideU = UInt32(kvStride) + let repeatCount = UInt32(nQHeads / nKVHeads) + let numQ = UInt32(nQHeads) + let sinksFlag: UInt32 = hasSinks ? 1 : 0 + let win = UInt32(windowSize) + + // Grid invariant from `aura_flash_sdpa.rs`: one simdgroup per + // Q head. grid = [32, nQHeads, 1], tg = [32, 1, 1]. + let grid = MTLSize(width: 32, height: nQHeads, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + + switch (keyBits, valueBits, headDim, out.dtype) { + case (4, 2, 128, .f32): + MetalTileKernels.aura_flash_sdpa_kb4_vb2_d128_f32( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + sinks: sinks.buffer, sinksOffset: sinks.offset, + out: out.buffer, outOffset: out.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, num_q_heads: numQ, + has_sinks: sinksFlag, window_size: win, + gridSize: grid, threadgroupSize: tg, on: cmd) + case (4, 2, 128, .f16): + MetalTileKernels.aura_flash_sdpa_kb4_vb2_d128_f16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + sinks: sinks.buffer, sinksOffset: sinks.offset, + out: out.buffer, outOffset: out.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, num_q_heads: numQ, + has_sinks: sinksFlag, window_size: win, + gridSize: grid, threadgroupSize: tg, on: cmd) + case (4, 2, 128, .bf16): + MetalTileKernels.aura_flash_sdpa_kb4_vb2_d128_bf16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + sinks: sinks.buffer, sinksOffset: sinks.offset, + out: out.buffer, outOffset: out.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, num_q_heads: numQ, + has_sinks: sinksFlag, window_size: win, + gridSize: grid, threadgroupSize: tg, on: cmd) + case (4, 4, 128, .f32): + MetalTileKernels.aura_flash_sdpa_kb4_vb4_d128_f32( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + sinks: sinks.buffer, sinksOffset: sinks.offset, + out: out.buffer, outOffset: out.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, num_q_heads: numQ, + has_sinks: sinksFlag, window_size: win, + gridSize: grid, threadgroupSize: tg, on: cmd) + case (4, 4, 128, .f16): + MetalTileKernels.aura_flash_sdpa_kb4_vb4_d128_f16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + sinks: sinks.buffer, sinksOffset: sinks.offset, + out: out.buffer, outOffset: out.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, num_q_heads: numQ, + has_sinks: sinksFlag, window_size: win, + gridSize: grid, threadgroupSize: tg, on: cmd) + case (4, 4, 128, .bf16): + MetalTileKernels.aura_flash_sdpa_kb4_vb4_d128_bf16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + sinks: sinks.buffer, sinksOffset: sinks.offset, + out: out.buffer, outOffset: out.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, num_q_heads: numQ, + has_sinks: sinksFlag, window_size: win, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + preconditionFailure( + "Ops.auraFlashSdpa: unsupported (keyBits=\(keyBits), " + + "valueBits=\(valueBits), headDim=\(headDim), " + + "dtype=\(out.dtype)). Caller must check " + + "supportsFlashSdpa(scheme:headDim:dtype:) first.") + } + } + + /// Predicate for the `auraFlashSdpa` supported-scheme set. Returns + /// true when the metaltile flash kernel exists for this combo. + public static func supportsAuraFlashSdpa( + keyBits: Int, valueBits: Int, headDim: Int, dtype: DType + ) -> Bool { + guard [DType.f32, .f16, .bf16].contains(dtype) else { return false } + // d=64 metaltile kernels exist but FFAI side hasn't wired their + // Ops dispatch yet — gate to d=128 (Qwen3 / Llama-3 family). + guard headDim == 128 else { return false } + if keyBits == 4 && (valueBits == 2 || valueBits == 4) { return true } + return false + } + + /// AURA flash-SDPA — two-pass token-parallel variant. Same external + /// contract as `auraFlashSdpa` but dispatches `aura_flash_p1` + + /// `aura_flash_pass2` instead of the single-pass kernel. Pass 1 + /// fans tokens across `num_blocks × nQHeads` simdgroups (single-pass + /// dispatches one simdgroup per Q head, which starves the GPU at + /// long context). Pass 2 reduces the per-block partials. + /// + /// **Block-size contract.** `num_blocks = ceil(liveLength / + /// blockSize)`. `blockSize = 64` is the FA-2 conventional choice and + /// saturates an M5 Max around liveLength≈4K (~16 q-heads × 64 blocks + /// = 1024 active simdgroups). The kernel takes `block_size` as a + /// runtime scalar so the wrapper is free to tune per-call. + /// + /// Uses the non-causal `aura_flash_p1` variant — safe for decode + /// T=1 because `q_position == liveLength-1` and all populated K rows + /// satisfy `t ≤ q_position`. The causal variant exists for kb4_vb2 + /// only; using non-causal everywhere unifies the dispatch surface. + /// + /// Caller owns the partials. Reuse across decode steps is safe — the + /// shapes depend only on `(nQHeads, headDim, blockSize, kvStride)` + /// and the wrapper writes the full surface each call. + public static func auraFlashSdpa2Pass( + q: Tensor, + kPacked: Tensor, kNorms: Tensor, kCodebook: Tensor, + vPacked: Tensor, vNorms: Tensor, vCodebook: Tensor, + into out: Tensor, + nQHeads: Int, nKVHeads: Int, headDim: Int, + kPackedWidth: Int, vPackedWidth: Int, + liveLength: Int, kvStride: Int, + keyBits: Int, valueBits: Int, + scale: Float, + blockSize: Int, + partialO: Tensor, partialM: Tensor, partialL: Tensor, + on cmd: MTLCommandBuffer + ) { + precondition( + kvStride >= liveLength, + "Ops.auraFlashSdpa2Pass: kvStride (\(kvStride)) must be ≥ liveLength (\(liveLength))") + precondition( + nQHeads % nKVHeads == 0, + "Ops.auraFlashSdpa2Pass: nQHeads (\(nQHeads)) must be divisible by nKVHeads (\(nKVHeads))") + precondition( + kPacked.dtype == .u32 && vPacked.dtype == .u32, + "Ops.auraFlashSdpa2Pass: packed K/V must be u32") + precondition( + kNorms.dtype == out.dtype && vNorms.dtype == out.dtype + && kCodebook.dtype == out.dtype && vCodebook.dtype == out.dtype, + "Ops.auraFlashSdpa2Pass: norms + codebooks must match " + + "activation dtype (got \(kNorms.dtype) vs \(out.dtype))") + precondition( + q.dtype == out.dtype, + "Ops.auraFlashSdpa2Pass: q dtype must match activation dtype") + precondition( + blockSize > 0 && blockSize % 32 == 0, + "Ops.auraFlashSdpa2Pass: blockSize (\(blockSize)) must be a " + + "positive multiple of 32") + let numBlocks = (liveLength + blockSize - 1) / blockSize + precondition( + partialO.dtype == out.dtype && partialM.dtype == out.dtype + && partialL.dtype == out.dtype, + "Ops.auraFlashSdpa2Pass: partials must match activation dtype") + precondition( + partialO.elementCount >= nQHeads * numBlocks * headDim, + "Ops.auraFlashSdpa2Pass: partialO too small for nQHeads * numBlocks * headDim") + precondition( + partialM.elementCount >= nQHeads * numBlocks + && partialL.elementCount >= nQHeads * numBlocks, + "Ops.auraFlashSdpa2Pass: partialM/L too small for nQHeads * numBlocks") + + // Same q pre-scale flow as the single-pass wrapper — kernel + // expects pre-scaled Q in activation dtype. + let qScratch = AuraFlashScratchCache.qScratch( + shape: q.shape, dtype: out.dtype) + let scaleBuf = AuraFlashScratchCache.scaleBuffer( + shape: q.shape, scale: scale, dtype: out.dtype) + _ = Ops.mul(q, scaleBuf, on: cmd, into: qScratch) + + let dim = UInt32(headDim) + let kpw = UInt32(kPackedWidth) + let vpw = UInt32(vPackedWidth) + let tokens = UInt32(liveLength) + let kvStrideU = UInt32(kvStride) + let repeatCount = UInt32(nQHeads / nKVHeads) + let numBlocksU = UInt32(numBlocks) + let blockSizeU = UInt32(blockSize) + // Causal masking unused by the non-causal variant; pass 0. + let qPosition: UInt32 = 0 + + // Pass 1: Grid3D (lane, q_idx, block_idx), tg = (32, 1, 1). + // Raw threads = [32, nQHeads, numBlocks] groups into + // [1, nQHeads, numBlocks] TGs of 32 lanes each. + let p1Grid = MTLSize(width: 32, height: nQHeads, depth: numBlocks) + let p1Tg = MTLSize(width: 32, height: 1, depth: 1) + // Pass 2: tg = (32, 1, 1) per q_idx; kernel reads + // `q_idx = tgid_x`. Raw threads = [nQHeads*32, 1, 1] groups into + // [nQHeads, 1, 1] TGs, one per q_idx — matches the end-to-end + // `aura_flash_gpu_correctness.rs` dispatch shape. + let p2Grid = MTLSize(width: nQHeads * 32, height: 1, depth: 1) + let p2Tg = MTLSize(width: 32, height: 1, depth: 1) + + switch (keyBits, valueBits, headDim, out.dtype) { + case (4, 2, 128, .f32): + MetalTileKernels.aura_flash_p1_kb4_vb2_d128_f32( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, + num_blocks: numBlocksU, block_size: blockSizeU, + q_position: qPosition, + gridSize: p1Grid, threadgroupSize: p1Tg, on: cmd) + MetalTileKernels.aura_flash_pass2_d128_f32( + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + output: out.buffer, outputOffset: out.offset, + dim: dim, num_blocks: numBlocksU, + gridSize: p2Grid, threadgroupSize: p2Tg, on: cmd) + case (4, 2, 128, .f16): + MetalTileKernels.aura_flash_p1_kb4_vb2_d128_f16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, + num_blocks: numBlocksU, block_size: blockSizeU, + q_position: qPosition, + gridSize: p1Grid, threadgroupSize: p1Tg, on: cmd) + MetalTileKernels.aura_flash_pass2_d128_f16( + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + output: out.buffer, outputOffset: out.offset, + dim: dim, num_blocks: numBlocksU, + gridSize: p2Grid, threadgroupSize: p2Tg, on: cmd) + case (4, 2, 128, .bf16): + MetalTileKernels.aura_flash_p1_kb4_vb2_d128_bf16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, + num_blocks: numBlocksU, block_size: blockSizeU, + q_position: qPosition, + gridSize: p1Grid, threadgroupSize: p1Tg, on: cmd) + MetalTileKernels.aura_flash_pass2_d128_bf16( + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + output: out.buffer, outputOffset: out.offset, + dim: dim, num_blocks: numBlocksU, + gridSize: p2Grid, threadgroupSize: p2Tg, on: cmd) + case (4, 4, 128, .f32): + MetalTileKernels.aura_flash_p1_kb4_vb4_d128_f32( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, + num_blocks: numBlocksU, block_size: blockSizeU, + q_position: qPosition, + gridSize: p1Grid, threadgroupSize: p1Tg, on: cmd) + MetalTileKernels.aura_flash_pass2_d128_f32( + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + output: out.buffer, outputOffset: out.offset, + dim: dim, num_blocks: numBlocksU, + gridSize: p2Grid, threadgroupSize: p2Tg, on: cmd) + case (4, 4, 128, .f16): + MetalTileKernels.aura_flash_p1_kb4_vb4_d128_f16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, + num_blocks: numBlocksU, block_size: blockSizeU, + q_position: qPosition, + gridSize: p1Grid, threadgroupSize: p1Tg, on: cmd) + MetalTileKernels.aura_flash_pass2_d128_f16( + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + output: out.buffer, outputOffset: out.offset, + dim: dim, num_blocks: numBlocksU, + gridSize: p2Grid, threadgroupSize: p2Tg, on: cmd) + case (4, 4, 128, .bf16): + MetalTileKernels.aura_flash_p1_kb4_vb4_d128_bf16( + q_rot: qScratch.buffer, q_rotOffset: qScratch.offset, + key_packed: kPacked.buffer, key_packedOffset: kPacked.offset, + key_norms: kNorms.buffer, key_normsOffset: kNorms.offset, + key_codebook: kCodebook.buffer, key_codebookOffset: kCodebook.offset, + val_packed: vPacked.buffer, val_packedOffset: vPacked.offset, + val_norms: vNorms.buffer, val_normsOffset: vNorms.offset, + val_codebook: vCodebook.buffer, val_codebookOffset: vCodebook.offset, + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + dim: dim, key_packed_width: kpw, value_packed_width: vpw, + tokens: tokens, kv_stride: kvStrideU, + repeat_count: repeatCount, + num_blocks: numBlocksU, block_size: blockSizeU, + q_position: qPosition, + gridSize: p1Grid, threadgroupSize: p1Tg, on: cmd) + MetalTileKernels.aura_flash_pass2_d128_bf16( + o_partials: partialO.buffer, o_partialsOffset: partialO.offset, + m_partials: partialM.buffer, m_partialsOffset: partialM.offset, + l_partials: partialL.buffer, l_partialsOffset: partialL.offset, + output: out.buffer, outputOffset: out.offset, + dim: dim, num_blocks: numBlocksU, + gridSize: p2Grid, threadgroupSize: p2Tg, on: cmd) + default: + preconditionFailure( + "Ops.auraFlashSdpa2Pass: unsupported (keyBits=\(keyBits), " + + "valueBits=\(valueBits), headDim=\(headDim), " + + "dtype=\(out.dtype)). Caller must check " + + "supportsAuraFlashSdpa2Pass(...) first.") + } + } + + /// Predicate for the `auraFlashSdpa2Pass` supported-scheme set. + /// Matches `supportsAuraFlashSdpa` today — both wrappers cover the + /// same metaltile-emitted (kb=4, vb∈{2,4}) × d=128 cells. + public static func supportsAuraFlashSdpa2Pass( + keyBits: Int, valueBits: Int, headDim: Int, dtype: DType + ) -> Bool { + guard [DType.f32, .f16, .bf16].contains(dtype) else { return false } + guard headDim == 128 else { return false } + if keyBits == 4 && (valueBits == 2 || valueBits == 4) { return true } + return false + } + public static func auraRotatePerHead( _ x: Tensor, rotation: Tensor, nHeads: Int, headDim: Int, @@ -6101,9 +6796,7 @@ public enum Ops { inDim % groupSize == 0, "Ops.rmsNormQgemvInt4Fast: in_dim must divide group_size=64") // eps as a 1-element f32 buffer. - var epsValue = eps - let epsBuf = device.makeBuffer(length: 4) - memcpy(epsBuf.contents(), &epsValue, 4) + let epsBuf = device.scalarBuffer(eps) let tg = MTLSize(width: 64, height: 1, depth: 1) let nTiles = outDim / 8 let grid = MTLSize(width: nTiles * 64, height: 1, depth: 1) @@ -6205,9 +6898,7 @@ public enum Ops { let grid = MTLSize(width: nTiles * tpg, height: 1, depth: 1) let tg = MTLSize(width: tpg, height: 1, depth: 1) // eps as a 1-element f32 buffer. - var epsValue = eps - let epsBuf = device.makeBuffer(length: 4) - memcpy(epsBuf.contents(), &epsValue, 4) + let epsBuf = device.scalarBuffer(eps) switch out.dtype { case .f32: MetalTileKernels.ffai_gated_rms_norm_qgemv_int4_fast_f32( @@ -6496,6 +7187,59 @@ public enum Ops { enc.endEncoding() } + /// DSv4 SwiGLU-with-limit (single): `silu(min(gate,limit)) * clip(up,±limit)`. + public static func swigluLimit( + gate: Tensor, up: Tensor, limit: Float, on cmd: MTLCommandBuffer, + into out: Tensor? = nil + ) -> Tensor { + precondition(gate.dtype == up.dtype && gate.elementCount == up.elementCount, + "Ops.swigluLimit: gate/up mismatch") + let result = out ?? Tensor.empty(shape: gate.shape, dtype: gate.dtype) + swigluLimitMany(gates: [gate], ups: [up], outs: [result], limit: limit, on: cmd) + return result + } + + /// DSv4 SwiGLU-with-limit, N dispatches sharing one encoder: + /// `out = silu(min(gate, limit)) * clip(up, -limit, +limit)`. DSv4 + /// trains with swiglu_limit=10; the clamp is essential — unclamped + /// silu(gate)*up overflows fp16 in the deep high-magnitude layers. + public static func swigluLimitMany( + gates: [Tensor], ups: [Tensor], outs: [Tensor], limit: Float, + on cmd: MTLCommandBuffer + ) { + let n = gates.count + precondition(ups.count == n && outs.count == n, "Ops.swigluLimitMany: count mismatch") + guard n > 0 else { return } + let dtype = gates[0].dtype + let psoName: String + switch dtype { + case .f32: psoName = "ffai_dsv4_swiglu_limit_f32" + case .f16: psoName = "ffai_dsv4_swiglu_limit_f16" + case .bf16: psoName = "ffai_dsv4_swiglu_limit_bf16" + default: fatalError("Ops.swigluLimitMany: unsupported dtype \(dtype)") + } + let pso = PSOCache.shared.pipelineState(for: psoName) + guard let enc = cmd.makeComputeCommandEncoder() else { return } + enc.setComputePipelineState(pso) + var lim = limit + for i in 0 ..< n { + let count = gates[i].elementCount + precondition(ups[i].elementCount == count && outs[i].elementCount == count, + "Ops.swigluLimitMany: shape mismatch at index \(i)") + precondition(ups[i].dtype == dtype && outs[i].dtype == dtype, + "Ops.swigluLimitMany: dtype mismatch at index \(i)") + let tgWidth = min(count, 256) + enc.setBuffer(gates[i].buffer, offset: gates[i].offset, index: 0) + enc.setBuffer(ups[i].buffer, offset: ups[i].offset, index: 1) + enc.setBuffer(outs[i].buffer, offset: outs[i].offset, index: 2) + enc.setBytes(&lim, length: 4, index: 3) + enc.dispatchThreads( + MTLSize(width: count, height: 1, depth: 1), + threadsPerThreadgroup: MTLSize(width: tgWidth, height: 1, depth: 1)) + } + enc.endEncoding() + } + // ─── MoE gather quantised matmul, scalar m1 ──────────────────────── // // Wraps `mt_moe_gather_qmm_int4_{f32,f16,bf16}`. One TG per @@ -6608,9 +7352,7 @@ public enum Ops { x.dtype == weight.dtype && weight.dtype == bias.dtype, "Ops.layerNorm: x/weight/bias dtype mismatch") let result = out ?? Tensor.empty(shape: x.shape, dtype: x.dtype) - var epsValue = eps - let epsBuf = device.makeBuffer(length: 4) - memcpy(epsBuf.contents(), &epsValue, 4) + let epsBuf = device.scalarBuffer(eps) // TPG=1024 per the kernel's reduce-tree contract. One TG per row. let tgWidth = 1024 let grid = MTLSize(width: nRows * tgWidth, height: 1, depth: 1) @@ -7218,3 +7960,142 @@ public enum Ops { return result } } + +/// Process-wide scratch cache for `Ops.auraFlashSdpa`. The flash +/// wrapper allocates a same-shape Q scratch (for the scale multiply) +/// and a same-shape scale buffer (filled with the softmax scale value) +/// on every call. Per-decode-token across ~28 attention layers, those +/// Metal buffer allocations cost ~3 ms — enough to make the +/// compressed path lose to dequant-mirror at short KV. This cache +/// allocates each scratch ONCE per (shape, dtype, scale) tuple and +/// reuses it across all subsequent calls. The decode pipeline is +/// single-inference-per-instance (see `Qwen35AttentionMixer` threading +/// contract) so an `NSLock` around the dictionary is sufficient. +/// +/// Post-AURA-dtype-unification, the Q scratch + scale buffer follow +/// the activation dtype directly (was f32-only pre-unification, paired +/// with the legacy `Tensor` kernel sig). +final class AuraFlashScratchCache: @unchecked Sendable { + static let shared = AuraFlashScratchCache() + + private let lock = NSLock() + private var qScratchByKey: [ScratchKey: Tensor] = [:] + private var scaleBufByKey: [ScaleKey: Tensor] = [:] + private var partialsByKey: [PartialsKey: PartialsScratch] = [:] + + /// Optional override for the FA-2 block size used by + /// `Ops.auraFlashSdpa2Pass` via `Qwen3Layer.forward`. Set to a value + /// in {32, 64, 128, 256} (or any multiple of 32) to override the + /// default 64. Bench-only knob — production should leave nil. + /// `nonisolated(unsafe)` because the single-inference-per-instance + /// threading contract (see `AuraFlashScratchCache` header) makes + /// races a non-issue and the bench surface needs simple writes. + nonisolated(unsafe) public static var blockSizeOverride: Int? + + private struct ScratchKey: Hashable { + let count: Int + let dtype: DType + } + + private struct ScaleKey: Hashable { + let count: Int + let dtype: DType + let scaleBits: UInt32 + } + + /// Keys the 2-pass partial-O / M / L scratch by the longest decode- + /// time shape: `(nQHeads, maxBlocks, headDim, dtype)`. Cache hot path + /// allocates once at first call (sized for `maxSeq/blockSize` blocks) + /// and reuses; the kernel overwrites the full surface each call. + private struct PartialsKey: Hashable { + let nQHeads: Int + let maxBlocks: Int + let headDim: Int + let dtype: DType + } + + /// Bundled partials triple — `Ops.auraFlashSdpa2Pass` takes the three + /// buffers separately so the caller can reuse them across schemes. + struct PartialsScratch { + let partialO: Tensor + let partialM: Tensor + let partialL: Tensor + } + + static func qScratch(shape: [Int], dtype: DType) -> Tensor { + shared.qScratch(shape: shape, dtype: dtype) + } + + static func scaleBuffer(shape: [Int], scale: Float, dtype: DType) -> Tensor { + shared.scaleBuffer(shape: shape, scale: scale, dtype: dtype) + } + + /// Cached 2-pass partials sized for the worst-case `(nQHeads, + /// maxBlocks, headDim, dtype)`. `maxBlocks` should be sized for the + /// cache's `maxSeq / blockSize` so all decode-step sizes fit. + static func partials( + nQHeads: Int, maxBlocks: Int, headDim: Int, dtype: DType + ) -> PartialsScratch { + shared.partials( + nQHeads: nQHeads, maxBlocks: maxBlocks, headDim: headDim, dtype: dtype) + } + + private func partials( + nQHeads: Int, maxBlocks: Int, headDim: Int, dtype: DType + ) -> PartialsScratch { + let key = PartialsKey( + nQHeads: nQHeads, maxBlocks: maxBlocks, headDim: headDim, dtype: dtype) + lock.lock() + defer { lock.unlock() } + if let cached = partialsByKey[key] { return cached } + let o = Tensor.empty( + shape: [nQHeads, maxBlocks, headDim], dtype: dtype, device: .shared) + let m = Tensor.empty( + shape: [nQHeads, maxBlocks], dtype: dtype, device: .shared) + let l = Tensor.empty( + shape: [nQHeads, maxBlocks], dtype: dtype, device: .shared) + let scratch = PartialsScratch(partialO: o, partialM: m, partialL: l) + partialsByKey[key] = scratch + return scratch + } + + private func qScratch(shape: [Int], dtype: DType) -> Tensor { + let count = shape.reduce(1, *) + let key = ScratchKey(count: count, dtype: dtype) + lock.lock() + defer { lock.unlock() } + if let cached = qScratchByKey[key], cached.shape == shape { + return cached + } + let fresh = Tensor.empty(shape: shape, dtype: dtype, device: .shared) + qScratchByKey[key] = fresh + return fresh + } + + private func scaleBuffer(shape: [Int], scale: Float, dtype: DType) -> Tensor { + let count = shape.reduce(1, *) + let key = ScaleKey(count: count, dtype: dtype, scaleBits: scale.bitPattern) + lock.lock() + defer { lock.unlock() } + if let cached = scaleBufByKey[key], cached.shape == shape { + return cached + } + // Host-side conversion from Float into the target dtype. The + // scale is a single value broadcast across the buffer; bf16/f16 + // narrow it once at construction. + let fresh = Tensor.empty(shape: shape, dtype: dtype, device: .shared) + switch dtype { + case .f32: + fresh.copyIn(from: [Float](repeating: scale, count: count)) + case .f16: + fresh.copyIn(from: [Float16](repeating: Float16(scale), count: count)) + case .bf16: + let bf16Bits = UInt16(truncatingIfNeeded: scale.bitPattern >> 16) + fresh.copyIn(from: [UInt16](repeating: bf16Bits, count: count)) + default: + fatalError("AuraFlashScratchCache: unsupported scale dtype \(dtype)") + } + scaleBufByKey[key] = fresh + return fresh + } +} diff --git a/Sources/FFAI/Ops/OpsDSv4.swift b/Sources/FFAI/Ops/OpsDSv4.swift new file mode 100644 index 00000000..824f5840 --- /dev/null +++ b/Sources/FFAI/Ops/OpsDSv4.swift @@ -0,0 +1,635 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// DeepSeek V4 architecture-specific kernel wrappers. Each Op below +// dispatches one of the metaltile `ffai_dsv4_*` (plus `ffai_moe_router_*` +// and `ffai_sdpa_decode_d512_sink`) kernels with the right +// dtype-suffixed entry point, grid shape, and constexpr bindings. + +import Foundation +import Metal +import MetalTileSwift + +extension Ops { + + // ─── MoE router ────────────────────────────────────────────────── + + /// DSv4 MoE router scoring: `sqrt(softplus(logits))` with a + /// `noaux_tc` bias-correction. Returns `(score_unbiased, score_biased)` + /// side-by-side — downstream top-k uses `score_biased` for + /// selection and `score_unbiased * routed_scaling_factor` for the + /// gather weight. + public static func dsv4MoeRouterSqrtsoftplus( + logits: Tensor, bias: Tensor, on cmd: MTLCommandBuffer, + scoreUnbiased: Tensor? = nil, scoreBiased: Tensor? = nil + ) -> (scoreUnbiased: Tensor, scoreBiased: Tensor) { + precondition(logits.dtype == .f32, "dsv4MoeRouterSqrtsoftplus: logits must be f32") + precondition(bias.dtype == .f32, "dsv4MoeRouterSqrtsoftplus: bias must be f32") + precondition( + logits.elementCount == bias.elementCount, + "dsv4MoeRouterSqrtsoftplus: logits / bias element-count mismatch") + let n = logits.elementCount + let unbiased = scoreUnbiased ?? Tensor.empty(shape: [n], dtype: .f32) + let biased = scoreBiased ?? Tensor.empty(shape: [n], dtype: .f32) + let (grid, tg) = elementwiseGrid(n) + MetalTileKernels.ffai_moe_router_sqrtsoftplus_f32( + logits: logits.buffer, logitsOffset: logits.offset, + bias: bias.buffer, biasOffset: bias.offset, + score_unbiased: unbiased.buffer, score_unbiasedOffset: unbiased.offset, + score_biased: biased.buffer, score_biasedOffset: biased.offset, + gridSize: grid, threadgroupSize: tg, on: cmd) + return (unbiased, biased) + } + + // ─── MXFP4 dequant (OCP FP4 e2m1, block size 32) ──────────────── + + /// Dequantize a buffer of OCP-spec MXFP4 blocks. Each block + /// carries 16 packed bytes (32 × 4-bit codes) + one host-extracted + /// fp32 scale (from the E8M0 raw scale byte). + public static func dsv4Mxfp4Dequant( + qsPacked: Tensor, scales: Tensor, lut: Tensor, + nValues: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(qsPacked.dtype == .u32, "dsv4Mxfp4Dequant: qsPacked must be u32") + precondition(scales.dtype == .f32, "dsv4Mxfp4Dequant: scales must be f32") + precondition(lut.dtype == .f32, "dsv4Mxfp4Dequant: lut must be f32") + precondition(lut.elementCount == 16, "dsv4Mxfp4Dequant: lut must be 16 entries") + precondition(nValues % 32 == 0, "dsv4Mxfp4Dequant: nValues must be multiple of 32") + let result = out ?? Tensor.empty(shape: [nValues], dtype: outDtype) + let (grid, tg) = elementwiseGrid(nValues) + let n = UInt32(nValues) + switch outDtype { + case .f32: + MetalTileKernels.ffai_dsv4_mxfp4_dequant_f32( + qs_packed: qsPacked.buffer, qs_packedOffset: qsPacked.offset, + scales: scales.buffer, scalesOffset: scales.offset, + lut: lut.buffer, lutOffset: lut.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_mxfp4_dequant_f16( + qs_packed: qsPacked.buffer, qs_packedOffset: qsPacked.offset, + scales: scales.buffer, scalesOffset: scales.offset, + lut: lut.buffer, lutOffset: lut.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_mxfp4_dequant_bf16( + qs_packed: qsPacked.buffer, qs_packedOffset: qsPacked.offset, + scales: scales.buffer, scalesOffset: scales.offset, + lut: lut.buffer, lutOffset: lut.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4Mxfp4Dequant: unsupported output dtype \(outDtype)") + } + return result + } + + // ─── FP8 block dequant (e4m3, 128×128 block scales) ───────────── + + /// Dequantize FP8 e4m3 weights using a 256-entry LUT (byte → fp32) + /// and per-(128×128)-block fp32 scales. Apple has no native FP8 + /// type — the LUT path is the proven fast path. + public static func dsv4Fp8BlockDequant( + weightBytes: Tensor, scales: Tensor, lut: Tensor, + mDim: Int, nDim: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(weightBytes.dtype == .u8, "dsv4Fp8BlockDequant: weightBytes must be u8") + precondition(scales.dtype == .f32, "dsv4Fp8BlockDequant: scales must be f32") + precondition(lut.dtype == .f32, "dsv4Fp8BlockDequant: lut must be f32") + precondition(lut.elementCount == 256, "dsv4Fp8BlockDequant: lut must be 256 entries") + precondition(mDim % 128 == 0 && nDim % 128 == 0, "dsv4Fp8BlockDequant: dims must be multiples of 128") + let total = mDim * nDim + let result = out ?? Tensor.empty(shape: [mDim, nDim], dtype: outDtype) + let (grid, tg) = elementwiseGrid(total) + let m = UInt32(mDim) + let n = UInt32(nDim) + switch outDtype { + case .f32: + MetalTileKernels.ffai_dsv4_fp8_block_dequant_f32( + weight_bytes: weightBytes.buffer, weight_bytesOffset: weightBytes.offset, + scales: scales.buffer, scalesOffset: scales.offset, + fp8_lut: lut.buffer, fp8_lutOffset: lut.offset, + out: result.buffer, outOffset: result.offset, + m_dim: m, n_dim: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_fp8_block_dequant_f16( + weight_bytes: weightBytes.buffer, weight_bytesOffset: weightBytes.offset, + scales: scales.buffer, scalesOffset: scales.offset, + fp8_lut: lut.buffer, fp8_lutOffset: lut.offset, + out: result.buffer, outOffset: result.offset, + m_dim: m, n_dim: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_fp8_block_dequant_bf16( + weight_bytes: weightBytes.buffer, weight_bytesOffset: weightBytes.offset, + scales: scales.buffer, scalesOffset: scales.offset, + fp8_lut: lut.buffer, fp8_lutOffset: lut.offset, + out: result.buffer, outOffset: result.offset, + m_dim: m, n_dim: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4Fp8BlockDequant: unsupported output dtype \(outDtype)") + } + return result + } + + // ─── CSA / HCA compressor pool ─────────────────────────────────── + + /// Softmax-gated weighted pool used by CSA (`pool_len=8`) and HCA + /// (`pool_len=128`) compressors: + /// `out[d] = sum_w softmax(gate)[w] * (raw_kv[w, d] + ape[w, d])` + public static func dsv4CompressorPool( + rawKv: Tensor, gate: Tensor, ape: Tensor, + headDim: Int, poolLen: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(gate.dtype == .f32, "dsv4CompressorPool: gate must be f32") + precondition(rawKv.dtype == outDtype, "dsv4CompressorPool: rawKv dtype must match outDtype") + precondition(ape.dtype == outDtype, "dsv4CompressorPool: ape dtype must match outDtype") + let result = out ?? Tensor.empty(shape: [headDim], dtype: outDtype) + let (grid, tg) = elementwiseGrid(headDim) + let hd = UInt32(headDim) + let pl = UInt32(poolLen) + switch outDtype { + case .f32: + MetalTileKernels.ffai_dsv4_compressor_pool_f32( + raw_kv: rawKv.buffer, raw_kvOffset: rawKv.offset, + gate: gate.buffer, gateOffset: gate.offset, + ape: ape.buffer, apeOffset: ape.offset, + compressed: result.buffer, compressedOffset: result.offset, + head_dim: hd, pool_len: pl, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_compressor_pool_f16( + raw_kv: rawKv.buffer, raw_kvOffset: rawKv.offset, + gate: gate.buffer, gateOffset: gate.offset, + ape: ape.buffer, apeOffset: ape.offset, + compressed: result.buffer, compressedOffset: result.offset, + head_dim: hd, pool_len: pl, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_compressor_pool_bf16( + raw_kv: rawKv.buffer, raw_kvOffset: rawKv.offset, + gate: gate.buffer, gateOffset: gate.offset, + ape: ape.buffer, apeOffset: ape.offset, + compressed: result.buffer, compressedOffset: result.offset, + head_dim: hd, pool_len: pl, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4CompressorPool: unsupported output dtype \(outDtype)") + } + return result + } + + // ─── mHC dynamic residual mix ─────────────────────────────────── + + /// Compute the dynamic per-token `(pre, post, comb)` control + /// tensors from the 24-mix output of `hc_*_fn @ flatten(H)`: + /// pre [n_tokens, 4] = sigmoid(...) + eps + /// post [n_tokens, 4] = 2 * sigmoid(...) + /// comb [n_tokens, 4, 4] = Sinkhorn(softmax_over_src(...) + eps) + /// Sinkhorn-Knopp normalization runs for `sinkhornIters` iterations. + public static func dsv4MhcSinkhornSplit( + mixes: Tensor, scale: Tensor, base: Tensor, + nTokens: Int, eps: Float, sinkhornIters: Int, + on cmd: MTLCommandBuffer, + pre: Tensor? = nil, post: Tensor? = nil, comb: Tensor? = nil + ) -> (pre: Tensor, post: Tensor, comb: Tensor) { + precondition(scale.dtype == .f32, "dsv4MhcSinkhornSplit: scale must be f32") + precondition(base.dtype == .f32, "dsv4MhcSinkhornSplit: base must be f32") + let pre = pre ?? Tensor.empty(shape: [nTokens, 4], dtype: .f32) + let post = post ?? Tensor.empty(shape: [nTokens, 4], dtype: .f32) + let comb = comb ?? Tensor.empty(shape: [nTokens, 4, 4], dtype: .f32) + let (grid, tg) = elementwiseGrid(nTokens) + let nt = UInt32(nTokens) + let iters = UInt32(sinkhornIters) + switch mixes.dtype { + case .f32: + MetalTileKernels.ffai_dsv4_mhc_sinkhorn_split_f32( + mixes: mixes.buffer, mixesOffset: mixes.offset, + scale: scale.buffer, scaleOffset: scale.offset, + base: base.buffer, baseOffset: base.offset, + pre: pre.buffer, preOffset: pre.offset, + post: post.buffer, postOffset: post.offset, + comb: comb.buffer, combOffset: comb.offset, + n_tokens: nt, eps: eps, sinkhorn_iters: iters, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_mhc_sinkhorn_split_f16( + mixes: mixes.buffer, mixesOffset: mixes.offset, + scale: scale.buffer, scaleOffset: scale.offset, + base: base.buffer, baseOffset: base.offset, + pre: pre.buffer, preOffset: pre.offset, + post: post.buffer, postOffset: post.offset, + comb: comb.buffer, combOffset: comb.offset, + n_tokens: nt, eps: eps, sinkhorn_iters: iters, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_mhc_sinkhorn_split_bf16( + mixes: mixes.buffer, mixesOffset: mixes.offset, + scale: scale.buffer, scaleOffset: scale.offset, + base: base.buffer, baseOffset: base.offset, + pre: pre.buffer, preOffset: pre.offset, + post: post.buffer, postOffset: post.offset, + comb: comb.buffer, combOffset: comb.offset, + n_tokens: nt, eps: eps, sinkhorn_iters: iters, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4MhcSinkhornSplit: unsupported mixes dtype \(mixes.dtype)") + } + return (pre, post, comb) + } + + /// mHC collapse — `x[d, t] = sum_c pre[t, c] * H[d, c, t]`. + public static func dsv4MhcCollapse( + state: Tensor, pre: Tensor, + hiddenDim: Int, nHc: Int, nTokens: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(pre.dtype == .f32, "dsv4MhcCollapse: pre must be f32") + precondition(state.dtype == outDtype, "dsv4MhcCollapse: state dtype must match outDtype") + let result = out ?? Tensor.empty(shape: [nTokens, hiddenDim], dtype: outDtype) + let (grid, tg) = elementwiseGrid(hiddenDim) + let hd = UInt32(hiddenDim) + let nc = UInt32(nHc) + let nt = UInt32(nTokens) + switch outDtype { + case .f32: + MetalTileKernels.ffai_dsv4_mhc_collapse_f32( + state: state.buffer, stateOffset: state.offset, + pre: pre.buffer, preOffset: pre.offset, + out: result.buffer, outOffset: result.offset, + hidden_dim: hd, n_hc: nc, n_tokens: nt, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_mhc_collapse_f16( + state: state.buffer, stateOffset: state.offset, + pre: pre.buffer, preOffset: pre.offset, + out: result.buffer, outOffset: result.offset, + hidden_dim: hd, n_hc: nc, n_tokens: nt, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_mhc_collapse_bf16( + state: state.buffer, stateOffset: state.offset, + pre: pre.buffer, preOffset: pre.offset, + out: result.buffer, outOffset: result.offset, + hidden_dim: hd, n_hc: nc, n_tokens: nt, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4MhcCollapse: unsupported output dtype \(outDtype)") + } + return result + } + + /// mHC expand — channel-wise residual remix: + /// `H_new[d, dst, t] = block_out[d, t] * post[t, dst] + /// + sum_src comb[t, dst, src] * residual[d, src, t]` + public static func dsv4MhcExpand( + blockOut: Tensor, post: Tensor, comb: Tensor, residualState: Tensor, + hiddenDim: Int, nHc: Int, nTokens: Int, + on cmd: MTLCommandBuffer, into state: Tensor? = nil + ) -> Tensor { + precondition(post.dtype == .f32, "dsv4MhcExpand: post must be f32") + precondition(comb.dtype == .f32, "dsv4MhcExpand: comb must be f32") + precondition( + residualState.dtype == blockOut.dtype, + "dsv4MhcExpand: residualState / blockOut dtype mismatch") + let result = state ?? Tensor.empty(shape: [nTokens, nHc, hiddenDim], dtype: blockOut.dtype) + let (grid, tg) = elementwiseGrid(hiddenDim) + let hd = UInt32(hiddenDim) + let nc = UInt32(nHc) + let nt = UInt32(nTokens) + switch blockOut.dtype { + case .f32: + MetalTileKernels.ffai_dsv4_mhc_expand_f32( + block_out: blockOut.buffer, block_outOffset: blockOut.offset, + post: post.buffer, postOffset: post.offset, + comb: comb.buffer, combOffset: comb.offset, + residual_state: residualState.buffer, residual_stateOffset: residualState.offset, + state: result.buffer, stateOffset: result.offset, + hidden_dim: hd, n_hc: nc, n_tokens: nt, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_mhc_expand_f16( + block_out: blockOut.buffer, block_outOffset: blockOut.offset, + post: post.buffer, postOffset: post.offset, + comb: comb.buffer, combOffset: comb.offset, + residual_state: residualState.buffer, residual_stateOffset: residualState.offset, + state: result.buffer, stateOffset: result.offset, + hidden_dim: hd, n_hc: nc, n_tokens: nt, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_mhc_expand_bf16( + block_out: blockOut.buffer, block_outOffset: blockOut.offset, + post: post.buffer, postOffset: post.offset, + comb: comb.buffer, combOffset: comb.offset, + residual_state: residualState.buffer, residual_stateOffset: residualState.offset, + state: result.buffer, stateOffset: result.offset, + hidden_dim: hd, n_hc: nc, n_tokens: nt, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4MhcExpand: unsupported blockOut dtype \(blockOut.dtype)") + } + return result + } + + // ─── Lightning Indexer ─────────────────────────────────────────── + + /// Per-position aggregate score: + /// `score[t] = sum_h w[h] * ReLU(q_idx[h] · k_idx[t, h])`. + public static func dsv4IndexerScore( + qIdx: Tensor, kIdx: Tensor, w: Tensor, + nHeads: Int, dIdx: Int, nKv: Int, + on cmd: MTLCommandBuffer, into score: Tensor? = nil + ) -> Tensor { + precondition(w.dtype == .f32, "dsv4IndexerScore: w must be f32") + precondition(qIdx.dtype == kIdx.dtype, "dsv4IndexerScore: qIdx / kIdx dtype mismatch") + let result = score ?? Tensor.empty(shape: [nKv], dtype: .f32) + let (grid, tg) = elementwiseGrid(nKv) + let nh = UInt32(nHeads) + let di = UInt32(dIdx) + let nk = UInt32(nKv) + switch qIdx.dtype { + case .f32: + MetalTileKernels.ffai_dsv4_indexer_score_f32( + q_idx: qIdx.buffer, q_idxOffset: qIdx.offset, + k_idx: kIdx.buffer, k_idxOffset: kIdx.offset, + w: w.buffer, wOffset: w.offset, + score: result.buffer, scoreOffset: result.offset, + n_heads: nh, d_idx: di, n_kv: nk, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_indexer_score_f16( + q_idx: qIdx.buffer, q_idxOffset: qIdx.offset, + k_idx: kIdx.buffer, k_idxOffset: kIdx.offset, + w: w.buffer, wOffset: w.offset, + score: result.buffer, scoreOffset: result.offset, + n_heads: nh, d_idx: di, n_kv: nk, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_indexer_score_bf16( + q_idx: qIdx.buffer, q_idxOffset: qIdx.offset, + k_idx: kIdx.buffer, k_idxOffset: kIdx.offset, + w: w.buffer, wOffset: w.offset, + score: result.buffer, scoreOffset: result.offset, + n_heads: nh, d_idx: di, n_kv: nk, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4IndexerScore: unsupported qIdx dtype \(qIdx.dtype)") + } + return result + } + + /// Single-block bitonic top-K over `score[n_kv]`. Returns the + /// `k` largest entries' original cache-position indices as `u32`. + /// Caller must satisfy `n_kv <= 1024`. + public static func dsv4IndexerTopkBlock( + score: Tensor, nKv: Int, k: Int, + on cmd: MTLCommandBuffer, into outIndices: Tensor? = nil + ) -> Tensor { + precondition(score.dtype == .f32, "dsv4IndexerTopkBlock: score must be f32") + precondition(nKv <= 1024, "dsv4IndexerTopkBlock: nKv > 1024 needs the multi-block variant") + precondition(k <= nKv, "dsv4IndexerTopkBlock: k must be <= nKv") + let result = outIndices ?? Tensor.empty(shape: [k], dtype: .u32) + MetalTileKernels.ffai_dsv4_indexer_topk_block_f32( + score: score.buffer, scoreOffset: score.offset, + out_indices: result.buffer, out_indicesOffset: result.offset, + n_kv: UInt32(nKv), k: UInt32(k), + gridSize: MTLSize(width: 1, height: 1, depth: 1), + threadgroupSize: MTLSize(width: 256, height: 1, depth: 1), + on: cmd) + return result + } + + // ─── SDPA: HCA dense + attn_sink ──────────────────────────────── + + /// Single-token SDPA decode for `head_dim == 512` with a per-head + /// learnable softmax sink term. Used by DSv4 full-attention layers + /// (0, 1, 42) and HCA dense layers. + public static func dsv4SdpaDecodeD512Sink( + q: Tensor, k: Tensor, v: Tensor, sinkLogit: Tensor, + nQHeads: Int, nKvHeads: Int, headDim: Int, nKv: Int, kvStride: Int, + scale: Float, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(headDim == 512, "dsv4SdpaDecodeD512Sink: head_dim must be 512") + precondition(nQHeads % nKvHeads == 0, "dsv4SdpaDecodeD512Sink: GQA must be integer") + precondition(sinkLogit.dtype == .f32, "dsv4SdpaDecodeD512Sink: sinkLogit must be f32") + let result = out ?? Tensor.empty(shape: [nQHeads, headDim], dtype: outDtype) + // dispatchThreads → gridSize is in THREADS. One threadgroup (512 + // threads) per q head, so the grid is nQHeads*512 threads wide. + // (A bare `nQHeads` collapses to a single partial threadgroup with + // tgid_x=0, computing ONLY head 0 and leaving heads 1..63 zeroed.) + let gridSize = MTLSize(width: nQHeads * 512, height: 1, depth: 1) + let tg = MTLSize(width: 512, height: 1, depth: 1) + let hd = UInt32(headDim) + let nk = UInt32(nKv) + let ks = UInt32(kvStride) + let hpg = UInt32(nQHeads / nKvHeads) + switch outDtype { + case .f32: + MetalTileKernels.ffai_sdpa_decode_d512_sink_f32( + q: q.buffer, qOffset: q.offset, + k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, + sink_logit: sinkLogit.buffer, sink_logitOffset: sinkLogit.offset, + out: result.buffer, outOffset: result.offset, + head_dim: hd, n_kv: nk, kv_stride: ks, heads_per_group: hpg, scale: scale, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_sdpa_decode_d512_sink_f16( + q: q.buffer, qOffset: q.offset, + k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, + sink_logit: sinkLogit.buffer, sink_logitOffset: sinkLogit.offset, + out: result.buffer, outOffset: result.offset, + head_dim: hd, n_kv: nk, kv_stride: ks, heads_per_group: hpg, scale: scale, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_sdpa_decode_d512_sink_bf16( + q: q.buffer, qOffset: q.offset, + k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, + sink_logit: sinkLogit.buffer, sink_logitOffset: sinkLogit.offset, + out: result.buffer, outOffset: result.offset, + head_dim: hd, n_kv: nk, kv_stride: ks, heads_per_group: hpg, scale: scale, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4SdpaDecodeD512Sink: unsupported output dtype \(outDtype)") + } + return result + } + + // ─── Partial RoPE (tail-only rotation) ────────────────────────── + + /// Rotates only the tail `n_rot = head_dim - n_nope` dims of each + /// head. Caller initialises `out` with the nope passthrough (or + /// passes the same buffer for `qk` and `out` to rotate in-place). + /// `inverse = true` un-rotates the attention output. + public static func dsv4PartialRope( + qk: Tensor, out: Tensor, + nHeads: Int, headDim: Int, nNope: Int, position: Int, + thetaBase: Float, inverse: Bool, + freqScale: Float = 1.0, extFactor: Float = 0.0, + corrLow: Float = 0.0, corrHigh: Float = 0.0, + on cmd: MTLCommandBuffer + ) { + let halfRot = (headDim - nNope) / 2 + let gridSize = MTLSize(width: nHeads, height: halfRot, depth: 1) + let tg = MTLSize(width: 1, height: 1, depth: 1) + let hd = UInt32(headDim) + let nNopeU = UInt32(nNope) + let halfRotU = UInt32(halfRot) + let posU = UInt32(position) + let invFlag: UInt32 = inverse ? 1 : 0 + switch qk.dtype { + case .f32: + MetalTileKernels.ffai_dsv4_partial_rope_f32( + qk: qk.buffer, qkOffset: qk.offset, + out: out.buffer, outOffset: out.offset, + head_dim: hd, n_nope: nNopeU, half_rot: halfRotU, + position: posU, theta_base: thetaBase, inverse_flag: invFlag, + freq_scale: freqScale, ext_factor: extFactor, corr_low: corrLow, corr_high: corrHigh, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_partial_rope_f16( + qk: qk.buffer, qkOffset: qk.offset, + out: out.buffer, outOffset: out.offset, + head_dim: hd, n_nope: nNopeU, half_rot: halfRotU, + position: posU, theta_base: thetaBase, inverse_flag: invFlag, + freq_scale: freqScale, ext_factor: extFactor, corr_low: corrLow, corr_high: corrHigh, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_partial_rope_bf16( + qk: qk.buffer, qkOffset: qk.offset, + out: out.buffer, outOffset: out.offset, + head_dim: hd, n_nope: nNopeU, half_rot: halfRotU, + position: posU, theta_base: thetaBase, inverse_flag: invFlag, + freq_scale: freqScale, ext_factor: extFactor, corr_low: corrLow, corr_high: corrHigh, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4PartialRope: unsupported qk dtype \(qk.dtype)") + } + } + + /// BATCHED partial RoPE over `nTokens` rows in ONE dispatch (token t at + /// position basePosition+t). qk/out [nTokens, nHeads, headDim]. Replaces + /// the prefill per-token rope loop (N dispatches → 1). + public static func dsv4PartialRopeRows( + qk: Tensor, out: Tensor, + nHeads: Int, headDim: Int, nNope: Int, nTokens: Int, basePosition: Int, + thetaBase: Float, inverse: Bool, + freqScale: Float = 1.0, extFactor: Float = 0.0, + corrLow: Float = 0.0, corrHigh: Float = 0.0, + on cmd: MTLCommandBuffer + ) { + let halfRot = (headDim - nNope) / 2 + let gridSize = MTLSize(width: nHeads, height: halfRot, depth: nTokens) + let tg = MTLSize(width: 1, height: 1, depth: 1) + let hd = UInt32(headDim); let nNopeU = UInt32(nNope); let halfRotU = UInt32(halfRot) + let nHeadsU = UInt32(nHeads); let baseU = UInt32(basePosition) + let invFlag: UInt32 = inverse ? 1 : 0 + switch qk.dtype { + case .f32: + MetalTileKernels.ffai_dsv4_partial_rope_rows_f32( + qk: qk.buffer, qkOffset: qk.offset, out: out.buffer, outOffset: out.offset, + head_dim: hd, n_nope: nNopeU, half_rot: halfRotU, n_heads: nHeadsU, + base_position: baseU, theta_base: thetaBase, inverse_flag: invFlag, + freq_scale: freqScale, ext_factor: extFactor, corr_low: corrLow, corr_high: corrHigh, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_partial_rope_rows_f16( + qk: qk.buffer, qkOffset: qk.offset, out: out.buffer, outOffset: out.offset, + head_dim: hd, n_nope: nNopeU, half_rot: halfRotU, n_heads: nHeadsU, + base_position: baseU, theta_base: thetaBase, inverse_flag: invFlag, + freq_scale: freqScale, ext_factor: extFactor, corr_low: corrLow, corr_high: corrHigh, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_partial_rope_rows_bf16( + qk: qk.buffer, qkOffset: qk.offset, out: out.buffer, outOffset: out.offset, + head_dim: hd, n_nope: nNopeU, half_rot: halfRotU, n_heads: nHeadsU, + base_position: baseU, theta_base: thetaBase, inverse_flag: invFlag, + freq_scale: freqScale, ext_factor: extFactor, corr_low: corrLow, corr_high: corrHigh, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4PartialRopeRows: unsupported qk dtype \(qk.dtype)") + } + } + + // ─── SDPA: CSA sparse-gather ──────────────────────────────────── + + /// Sparse-gather SDPA decode for `head_dim == 512`. Attention is + /// taken over the cache positions listed in `selectedIndices` + /// (typically Lightning Indexer top-K unioned with the trailing + /// sliding window). + public static func dsv4CsaSdpaDecode( + q: Tensor, k: Tensor, v: Tensor, selectedIndices: Tensor, + nQHeads: Int, nKvHeads: Int, headDim: Int, + nSelected: Int, kvStride: Int, scale: Float, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(headDim == 512, "dsv4CsaSdpaDecode: head_dim must be 512") + precondition(selectedIndices.dtype == .u32, "dsv4CsaSdpaDecode: selectedIndices must be u32") + precondition(nQHeads % nKvHeads == 0, "dsv4CsaSdpaDecode: GQA must be integer") + let result = out ?? Tensor.empty(shape: [nQHeads, headDim], dtype: outDtype) + // dispatchThreads → gridSize in THREADS: nQHeads*512 (one 512-thread + // threadgroup per q head). A bare nQHeads computes only head 0. + let gridSize = MTLSize(width: nQHeads * 512, height: 1, depth: 1) + let tg = MTLSize(width: 512, height: 1, depth: 1) + let hd = UInt32(headDim) + let ns = UInt32(nSelected) + let ks = UInt32(kvStride) + let hpg = UInt32(nQHeads / nKvHeads) + switch outDtype { + case .f32: + MetalTileKernels.ffai_dsv4_csa_sdpa_decode_f32( + q: q.buffer, qOffset: q.offset, + k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, + selected_indices: selectedIndices.buffer, selected_indicesOffset: selectedIndices.offset, + out: result.buffer, outOffset: result.offset, + head_dim: hd, n_selected: ns, kv_stride: ks, heads_per_group: hpg, scale: scale, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_dsv4_csa_sdpa_decode_f16( + q: q.buffer, qOffset: q.offset, + k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, + selected_indices: selectedIndices.buffer, selected_indicesOffset: selectedIndices.offset, + out: result.buffer, outOffset: result.offset, + head_dim: hd, n_selected: ns, kv_stride: ks, heads_per_group: hpg, scale: scale, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_dsv4_csa_sdpa_decode_bf16( + q: q.buffer, qOffset: q.offset, + k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, + selected_indices: selectedIndices.buffer, selected_indicesOffset: selectedIndices.offset, + out: result.buffer, outOffset: result.offset, + head_dim: hd, n_selected: ns, kv_stride: ks, heads_per_group: hpg, scale: scale, + gridSize: gridSize, threadgroupSize: tg, on: cmd) + default: + fatalError("dsv4CsaSdpaDecode: unsupported output dtype \(outDtype)") + } + return result + } +} diff --git a/Sources/FFAI/Ops/OpsGGUF.swift b/Sources/FFAI/Ops/OpsGGUF.swift new file mode 100644 index 00000000..22dc9a83 --- /dev/null +++ b/Sources/FFAI/Ops/OpsGGUF.swift @@ -0,0 +1,536 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// GGUF block-dequant Ops — Swift wrappers over the metaltile +// `ffai_gguf_dequant_*` kernel family. +// +// Each Op takes the GPU-resident split the loader produces (packed +// quants + per-block scales + LUT tables) and dispatches the matching +// per-dtype kernel. The input/output dtypes are fixed by the kernel +// family: +// +// - `Tensor` / `Tensor` — packed quant bytes +// - `Tensor` — per-block fp16-converted scales +// - `Tensor` — iq2xxs grid + ksigns tables +// - `Tensor` — output (T = f32 / f16 / bf16) + +import Foundation +import Metal +import MetalTileSwift + +extension Ops { + /// Q8_0 — `out[i] = qs_signed[i] * scales[i/32]`. Block size 32. + /// + /// - Parameters: + /// - qsSigned: `[n_blocks * 32]` `u8` — int8 quants, sign-reconstructed + /// inside the kernel via `select(q >= 128, q-256, q)`. + /// - scales: `[n_blocks]` `f32` — host-extracted block super-scales + /// (fp16 → f32 at load time). + /// - outDtype: target output dtype. Allocates the result tensor. + public static func ggufDequantQ8_0( + qsSigned: Tensor, scales: Tensor, nValues: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(qsSigned.dtype == .u8, "ggufDequantQ8_0: qsSigned must be u8") + precondition(scales.dtype == .f32, "ggufDequantQ8_0: scales must be f32") + precondition(nValues % 32 == 0, "ggufDequantQ8_0: nValues must be multiple of 32") + let result = out ?? Tensor.empty(shape: [nValues], dtype: outDtype) + let (grid, tg) = elementwiseGrid(nValues) + let n = UInt32(nValues) + switch outDtype { + case .f32: + MetalTileKernels.ffai_gguf_dequant_q8_0_f32( + qs_signed: qsSigned.buffer, qs_signedOffset: qsSigned.offset, + scales: scales.buffer, scalesOffset: scales.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_gguf_dequant_q8_0_f16( + qs_signed: qsSigned.buffer, qs_signedOffset: qsSigned.offset, + scales: scales.buffer, scalesOffset: scales.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gguf_dequant_q8_0_bf16( + qs_signed: qsSigned.buffer, qs_signedOffset: qsSigned.offset, + scales: scales.buffer, scalesOffset: scales.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("ggufDequantQ8_0: unsupported output dtype \(outDtype)") + } + return result + } + + /// Q2_K — `out[i] = d * scale_4bit * q_2bit - dmin * min_4bit`. + /// Block size 256, two-level scales. + public static func ggufDequantQ2_K( + qsPacked: Tensor, scales: Tensor, dF32: Tensor, dminF32: Tensor, + nValues: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(qsPacked.dtype == .u32, "ggufDequantQ2_K: qsPacked must be u32") + precondition(scales.dtype == .u8, "ggufDequantQ2_K: scales must be u8") + precondition(dF32.dtype == .f32, "ggufDequantQ2_K: d_f32 must be f32") + precondition(dminF32.dtype == .f32, "ggufDequantQ2_K: dmin_f32 must be f32") + precondition(nValues % 256 == 0, "ggufDequantQ2_K: nValues must be multiple of 256") + let result = out ?? Tensor.empty(shape: [nValues], dtype: outDtype) + let (grid, tg) = elementwiseGrid(nValues) + let n = UInt32(nValues) + switch outDtype { + case .f32: + MetalTileKernels.ffai_gguf_dequant_q2_k_f32( + qs_packed: qsPacked.buffer, qs_packedOffset: qsPacked.offset, + scales: scales.buffer, scalesOffset: scales.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + dmin_f32: dminF32.buffer, dmin_f32Offset: dminF32.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_gguf_dequant_q2_k_f16( + qs_packed: qsPacked.buffer, qs_packedOffset: qsPacked.offset, + scales: scales.buffer, scalesOffset: scales.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + dmin_f32: dminF32.buffer, dmin_f32Offset: dminF32.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gguf_dequant_q2_k_bf16( + qs_packed: qsPacked.buffer, qs_packedOffset: qsPacked.offset, + scales: scales.buffer, scalesOffset: scales.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + dmin_f32: dminF32.buffer, dmin_f32Offset: dminF32.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: grid, threadgroupSize: tg, on: cmd) + default: + fatalError("ggufDequantQ2_K: unsupported output dtype \(outDtype)") + } + return result + } + + /// Fused 6-expert IQ2_XXS gather GEMV. `qsAll`/`dAll` hold the + /// slot-major split buffers for `nSlots` routed experts (from + /// `bundle.stageGatherIQ2XXS`); `x` is the shared activation + /// `[kIn]`. Writes `out[nSlots * mOut]` = per-expert gemv result, + /// inline-dequanting the quant bytes — no f16 weight buffer, ONE + /// dispatch for all experts of the role. + public static func moeGatherGemvIQ2XXS( + x: Tensor, qsAll: Tensor, dAll: Tensor, expertIds: Tensor, grid: Tensor, signs: Tensor, + nSlots: Int, mOut: Int, kIn: Int, + on cmd: MTLCommandBuffer, into out: Tensor + ) { + precondition(qsAll.dtype == .u32, "moeGatherGemvIQ2XXS: qsAll must be u32") + precondition(dAll.dtype == .f32, "moeGatherGemvIQ2XXS: dAll must be f32") + precondition(expertIds.dtype == .u32, "moeGatherGemvIQ2XXS: expertIds must be u32") + precondition(kIn % 256 == 0, "moeGatherGemvIQ2XXS: kIn must be multiple of 256") + let tgWidth = 32 + let grd = MTLSize(width: mOut * tgWidth, height: nSlots, depth: 1) + let tg = MTLSize(width: tgWidth, height: 1, depth: 1) + let kInU = UInt32(kIn) + let mOutU = UInt32(mOut) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_gather_gemv_iq2xxs_f16( + x: x.buffer, xOffset: x.offset, + qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, + expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_gather_gemv_iq2xxs_f32( + x: x.buffer, xOffset: x.offset, + qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, + expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_gather_gemv_iq2xxs_bf16( + x: x.buffer, xOffset: x.offset, + qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, + expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: + fatalError("moeGatherGemvIQ2XXS: unsupported dtype \(out.dtype)") + } + } + + /// Q8_0 inline-dequant gemv: `out[m] = Σ_k dequant(W[m,k])·x[k]`, + /// reading the resident Q8 split (qs int8 + per-block f32 scale). + public static func gemvQ8( + q8: ResidentQ8, x: Tensor, on cmd: MTLCommandBuffer, into out: Tensor + ) { + let tg = MTLSize(width: 32, height: 1, depth: 1) + let grd = MTLSize(width: q8.mOut * 32, height: 1, depth: 1) + let qsT = q8.qs; let dT = q8.d + let kInU = UInt32(q8.kIn); let mOutU = UInt32(q8.mOut) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_gemv_q8_f16( + qs: qsT, d_f32: dT, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_gemv_q8_f32( + qs: qsT, d_f32: dT, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gemv_q8_bf16( + qs: qsT, d_f32: dT, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, gridSize: grd, threadgroupSize: tg, on: cmd) + default: + fatalError("gemvQ8: unsupported dtype \(out.dtype)") + } + } + + /// Grouped Q8 gemv: each contiguous block of `rowsPerGroup` output + /// rows reads its own `kIn`-slice of `x`. Fuses the 8-group O-LoRA + /// into one dispatch. `x` must hold `(mOut/rowsPerGroup) * kIn` values. + public static func groupedGemvQ8( + q8: ResidentQ8, x: Tensor, rowsPerGroup: Int, + on cmd: MTLCommandBuffer, into out: Tensor + ) { + let tg = MTLSize(width: 32, height: 1, depth: 1) + let grd = MTLSize(width: q8.mOut * 32, height: 1, depth: 1) + let kInU = UInt32(q8.kIn); let mOutU = UInt32(q8.mOut); let rpg = UInt32(rowsPerGroup) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_grouped_gemv_q8_f16( + qs: q8.qs, d_f32: q8.d, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_grouped_gemv_q8_f32( + qs: q8.qs, d_f32: q8.d, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_grouped_gemv_q8_bf16( + qs: q8.qs, d_f32: q8.d, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: + fatalError("groupedGemvQ8: unsupported dtype \(out.dtype)") + } + } + + /// BATCHED grouped Q8 gemv over `nTokens` rows in ONE dispatch. x is + /// [nTokens, nGroups*kIn], out [nTokens, mOut]. Replaces the prefill + /// O-LoRA per-token loop (N dispatches → 1). + public static func groupedGemvQ8Rows( + q8: ResidentQ8, x: Tensor, rowsPerGroup: Int, nTokens: Int, + on cmd: MTLCommandBuffer, into out: Tensor + ) { + let tg = MTLSize(width: 32, height: 1, depth: 1) + let grd = MTLSize(width: q8.mOut * 32, height: nTokens, depth: 1) + let kInU = UInt32(q8.kIn); let mOutU = UInt32(q8.mOut); let rpg = UInt32(rowsPerGroup) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_grouped_gemv_q8_rows_f16( + qs: q8.qs, d_f32: q8.d, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_grouped_gemv_q8_rows_f32( + qs: q8.qs, d_f32: q8.d, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_grouped_gemv_q8_rows_bf16( + qs: q8.qs, d_f32: q8.d, x: x.buffer, xOffset: x.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: + fatalError("groupedGemvQ8Rows: unsupported dtype \(out.dtype)") + } + } + + /// Q8 gemv over a row sub-range of a resident Q8 weight (for the + /// grouped O-LoRA: each group is a contiguous row block with its own + /// input slice). `rowStart`/`nRows` select W rows; byte offsets into + /// the qs/d buffers are derived from the row-contiguous block layout. + public static func gemvQ8Rows( + q8: ResidentQ8, rowStart: Int, nRows: Int, x: Tensor, + on cmd: MTLCommandBuffer, into out: Tensor + ) { + let bpr = q8.kIn / 32 // blocks per row + let qsOff = rowStart * bpr * 8 * 4 // u32 → bytes + let dOff = rowStart * bpr * 4 // f32 → bytes + let tg = MTLSize(width: 32, height: 1, depth: 1) + let grd = MTLSize(width: nRows * 32, height: 1, depth: 1) + let kInU = UInt32(q8.kIn); let mOutU = UInt32(nRows) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_gemv_q8_f16( + qs: q8.qs, qsOffset: qsOff, d_f32: q8.d, d_f32Offset: dOff, + x: x.buffer, xOffset: x.offset, out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_gemv_q8_f32( + qs: q8.qs, qsOffset: qsOff, d_f32: q8.d, d_f32Offset: dOff, + x: x.buffer, xOffset: x.offset, out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gemv_q8_bf16( + qs: q8.qs, qsOffset: qsOff, d_f32: q8.d, d_f32Offset: dOff, + x: x.buffer, xOffset: x.offset, out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, gridSize: grd, threadgroupSize: tg, on: cmd) + default: + fatalError("gemvQ8Rows: unsupported dtype \(out.dtype)") + } + } + + /// DSv4 GPU router top-K: top-K experts by biased score, weights = + /// unbiased[chosen] renormalized sum-to-1. Keeps routing on the GPU + /// (no CPU readback). `scoreBiased`/`scoreUnbiased` are f32 [nExperts]; + /// writes `indicesOut` [k] u32 + `weightsOut` [k] f32. + public static func dsv4RouterTopK( + scoreBiased: Tensor, scoreUnbiased: Tensor, + indicesOut: Tensor, weightsOut: Tensor, + nExperts: Int, k: Int, on cmd: MTLCommandBuffer + ) { + let grd = MTLSize(width: 32, height: 1, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + MetalTileKernels.mt_dsv4_router_topk_f32( + score_biased: scoreBiased.buffer, score_biasedOffset: scoreBiased.offset, + score_unbiased: scoreUnbiased.buffer, score_unbiasedOffset: scoreUnbiased.offset, + indices_out: indicesOut.buffer, indices_outOffset: indicesOut.offset, + weights_out: weightsOut.buffer, weights_outOffset: weightsOut.offset, + n_experts: UInt32(nExperts), k: UInt32(k), + gridSize: grd, threadgroupSize: tg, on: cmd) + } + + /// `out[i] = table[idx[i]]` (u32 gather) — remap routed expert ids + /// into resident-pool packed slots on the GPU. + public static func remapU32( + table: Tensor, idx: Tensor, out: Tensor, n: Int, on cmd: MTLCommandBuffer + ) { + let tg = MTLSize(width: min(n, 32), height: 1, depth: 1) + let grd = MTLSize(width: n, height: 1, depth: 1) + MetalTileKernels.mt_remap_u32_f32( + table: table.buffer, tableOffset: table.offset, + idx: idx.buffer, idxOffset: idx.offset, + out: out.buffer, outOffset: out.offset, + n: UInt32(n), gridSize: grd, threadgroupSize: tg, on: cmd) + } + + /// Fused 6-expert Q2_K gather down-projection + router-weighted sum. + /// `innersAll` holds the per-slot SwiGLU inner `[nSlots * kIn]`; the + /// Q2_K split buffers hold all routed experts' down weights. Writes + /// `out[mOut]` = Σ_slot weights[slot] · (downW_slot · inner_slot) — + /// the routed MoE output — in ONE dispatch. + public static func moeGatherDownQ2K( + innersAll: Tensor, qsAll: Tensor, scalesAll: Tensor, + dAll: Tensor, dminAll: Tensor, expertIds: Tensor, weights: Tensor, + nSlots: Int, mOut: Int, kIn: Int, + on cmd: MTLCommandBuffer, into out: Tensor + ) { + precondition(qsAll.dtype == .u32 && scalesAll.dtype == .u8) + precondition(dAll.dtype == .f32 && dminAll.dtype == .f32 && weights.dtype == .f32) + precondition(expertIds.dtype == .u32, "moeGatherDownQ2K: expertIds must be u32") + precondition(kIn % 256 == 0, "moeGatherDownQ2K: kIn must be multiple of 256") + let tgWidth = 32 + let grd = MTLSize(width: mOut * tgWidth, height: 1, depth: 1) + let tg = MTLSize(width: tgWidth, height: 1, depth: 1) + let kInU = UInt32(kIn); let mOutU = UInt32(mOut); let nSlotsU = UInt32(nSlots) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_gather_down_q2k_f16( + inners_all: innersAll.buffer, inners_allOffset: innersAll.offset, + qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + scales_all: scalesAll.buffer, scales_allOffset: scalesAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, + dmin_all: dminAll.buffer, dmin_allOffset: dminAll.offset, + expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + weights: weights.buffer, weightsOffset: weights.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, n_slots: nSlotsU, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_gather_down_q2k_f32( + inners_all: innersAll.buffer, inners_allOffset: innersAll.offset, + qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + scales_all: scalesAll.buffer, scales_allOffset: scalesAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, + dmin_all: dminAll.buffer, dmin_allOffset: dminAll.offset, + expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + weights: weights.buffer, weightsOffset: weights.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, n_slots: nSlotsU, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_gather_down_q2k_bf16( + inners_all: innersAll.buffer, inners_allOffset: innersAll.offset, + qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + scales_all: scalesAll.buffer, scales_allOffset: scalesAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, + dmin_all: dminAll.buffer, dmin_allOffset: dminAll.offset, + expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + weights: weights.buffer, weightsOffset: weights.offset, + out: out.buffer, outOffset: out.offset, + k_in: kInU, m_out: mOutU, n_slots: nSlotsU, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: + fatalError("moeGatherDownQ2K: unsupported dtype \(out.dtype)") + } + } + + /// IQ2_XXS — codebook lookup against `iq2xxs_grid[256][8]` modulated + /// by the `ksigns_iq2xs[128]` sign-mask table. Block size 256. + public static func ggufDequantIQ2_XXS( + qsU32: Tensor, dF32: Tensor, grid: Tensor, signs: Tensor, + nValues: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(qsU32.dtype == .u32, "ggufDequantIQ2_XXS: qsU32 must be u32") + precondition(dF32.dtype == .f32, "ggufDequantIQ2_XXS: d_f32 must be f32") + precondition(grid.dtype == .u8, "ggufDequantIQ2_XXS: grid must be u8") + precondition(signs.dtype == .u8, "ggufDequantIQ2_XXS: signs must be u8") + precondition( + grid.elementCount == 2048, "ggufDequantIQ2_XXS: grid must be 2048 bytes (256×8)") + precondition(signs.elementCount == 128, "ggufDequantIQ2_XXS: signs must be 128 bytes") + precondition(nValues % 256 == 0, "ggufDequantIQ2_XXS: nValues must be multiple of 256") + let result = out ?? Tensor.empty(shape: [nValues], dtype: outDtype) + let (gridDim, tg) = elementwiseGrid(nValues) + let n = UInt32(nValues) + switch outDtype { + case .f32: + MetalTileKernels.ffai_gguf_dequant_iq2_xxs_f32( + qs_u32: qsU32.buffer, qs_u32Offset: qsU32.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: gridDim, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_gguf_dequant_iq2_xxs_f16( + qs_u32: qsU32.buffer, qs_u32Offset: qsU32.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: gridDim, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gguf_dequant_iq2_xxs_bf16( + qs_u32: qsU32.buffer, qs_u32Offset: qsU32.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: gridDim, threadgroupSize: tg, on: cmd) + default: + fatalError("ggufDequantIQ2_XXS: unsupported output dtype \(outDtype)") + } + return result + } + + /// IQ2_XXS qs extract — raw bytes → packed qs_u32 staging buffer. + /// Tiny GPU prologue kernel that replaces the per-block CPU + /// memcpy(64) loop. 1 thread per output u32 word. + public static func ggufIQ2_XXS_extractQs( + rawBytes: Tensor, qsU32: Tensor, nBlocks: Int, + on cmd: MTLCommandBuffer + ) { + precondition(rawBytes.dtype == .u8) + precondition(qsU32.dtype == .u32) + let (gridDim, tg) = elementwiseGrid(nBlocks * 16) + MetalTileKernels.ffai_gguf_iq2_xxs_extract_qs_u32( + raw_bytes: rawBytes.buffer, raw_bytesOffset: rawBytes.offset, + qs_u32: qsU32.buffer, qs_u32Offset: qsU32.offset, + n_blocks: UInt32(nBlocks), + gridSize: gridDim, threadgroupSize: tg, on: cmd) + } + + /// IQ2_XXS dequant — raw-bytes variant. Reads qs from the on-disk + /// 66-byte block layout directly, skipping the CPU preprocess that + /// split each block into a separate qs_u32 buffer. `dF32` is still + /// pre-staged (the DSL has no bit_cast intrinsic for + /// in-kernel fp16 → f32, and staging the 32K-element d-vector on + /// CPU is ~30 ms per token vs ~470 ms the qs memcpy was costing). + public static func ggufDequantIQ2_XXS_raw( + rawBytes: Tensor, dF32: Tensor, grid: Tensor, signs: Tensor, + nValues: Int, outDtype: DType, + on cmd: MTLCommandBuffer, into out: Tensor? = nil + ) -> Tensor { + precondition(rawBytes.dtype == .u8, "ggufDequantIQ2_XXS_raw: rawBytes must be u8") + precondition(dF32.dtype == .f32, "ggufDequantIQ2_XXS_raw: d_f32 must be f32") + precondition(grid.dtype == .u8, "ggufDequantIQ2_XXS_raw: grid must be u8") + precondition(signs.dtype == .u8, "ggufDequantIQ2_XXS_raw: signs must be u8") + precondition(nValues % 256 == 0, "ggufDequantIQ2_XXS_raw: nValues must be multiple of 256") + let result = out ?? Tensor.empty(shape: [nValues], dtype: outDtype) + let (gridDim, tg) = elementwiseGrid(nValues) + let n = UInt32(nValues) + switch outDtype { + case .f32: + MetalTileKernels.ffai_gguf_dequant_iq2_xxs_raw_f32( + raw_bytes: rawBytes.buffer, raw_bytesOffset: rawBytes.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: gridDim, threadgroupSize: tg, on: cmd) + case .f16: + MetalTileKernels.ffai_gguf_dequant_iq2_xxs_raw_f16( + raw_bytes: rawBytes.buffer, raw_bytesOffset: rawBytes.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: gridDim, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gguf_dequant_iq2_xxs_raw_bf16( + raw_bytes: rawBytes.buffer, raw_bytesOffset: rawBytes.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, + grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, + out: result.buffer, outOffset: result.offset, + n_values: n, + gridSize: gridDim, threadgroupSize: tg, on: cmd) + default: + fatalError("ggufDequantIQ2_XXS_raw: unsupported output dtype \(outDtype)") + } + return result + } +} diff --git a/Sources/FFAI/Ops/OpsMath.swift b/Sources/FFAI/Ops/OpsMath.swift index 7ced070f..c5bf406e 100644 --- a/Sources/FFAI/Ops/OpsMath.swift +++ b/Sources/FFAI/Ops/OpsMath.swift @@ -486,10 +486,8 @@ extension Ops { let dev = Device.shared switch out.dtype { case .f32: - let startBuf = dev.makeBuffer(length: 4) - let stepBuf = dev.makeBuffer(length: 4) - startBuf.contents().bindMemory(to: Float.self, capacity: 1).pointee = start - stepBuf.contents().bindMemory(to: Float.self, capacity: 1).pointee = step + let startBuf = dev.scalarBuffer(start) + let stepBuf = dev.scalarBuffer(step) let (grid, tg) = elementwiseGrid(n) MetalTileKernels.mt_arange_f32( out: out.buffer, outOffset: out.offset, diff --git a/Sources/FFAI/Ops/OpsPrefill.swift b/Sources/FFAI/Ops/OpsPrefill.swift new file mode 100644 index 00000000..494cd3d5 --- /dev/null +++ b/Sources/FFAI/Ops/OpsPrefill.swift @@ -0,0 +1,601 @@ +// Copyright 2026 Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +// +// Prefill matmul Ops — batched (M>1) GGUF-quant matmuls that read each +// weight once and reuse it across all rows in a chunk (the amortization +// that turns DSv4 prefill from per-token gemv into batched throughput). +// Backed by the metaltile kernels ffai_gemm_q8 (dense/attn/lmhead), +// ffai_moe_gather_bgemm_iq2xxs_mpp (gate/up), ffai_moe_gather_bgemm_q2k_mpp +// (down). Bindings use dispatchThreads, so gridSize is in THREADS. + +import Foundation +import Metal +import MetalTileSwift + +extension Ops { + /// Q8_0 tiled GEMM: `out[r, :] = dequant(W) · input[r, :]` for `nRows` + /// rows. `W` is the resident Q8 split `[outDim, inDim]`. 32×32 tile, + /// 1024 threads/TG. `inDim % 16 == 0` (and `% 32` for the Q8 block). + public static func gemmQ8( + qs: Tensor, dF32: Tensor, input: Tensor, out: Tensor, + inDim: Int, outDim: Int, nRows: Int, on cmd: MTLCommandBuffer + ) { + precondition(qs.dtype == .u32 && dF32.dtype == .f32) + let gx = (outDim + 31) / 32 + let gy = (nRows + 31) / 32 + let grd = MTLSize(width: gx * 1024, height: gy, depth: 1) + let tg = MTLSize(width: 1024, height: 1, depth: 1) + let i = UInt32(inDim); let o = UInt32(outDim); let n = UInt32(nRows) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_gemm_q8_f16( + qs: qs.buffer, qsOffset: qs.offset, d_f32: dF32.buffer, d_f32Offset: dF32.offset, + input: input.buffer, inputOffset: input.offset, out: out.buffer, outOffset: out.offset, + in_dim: i, out_dim: o, n_rows: n, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_gemm_q8_f32( + qs: qs.buffer, qsOffset: qs.offset, d_f32: dF32.buffer, d_f32Offset: dF32.offset, + input: input.buffer, inputOffset: input.offset, out: out.buffer, outOffset: out.offset, + in_dim: i, out_dim: o, n_rows: n, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gemm_q8_bf16( + qs: qs.buffer, qsOffset: qs.offset, d_f32: dF32.buffer, d_f32Offset: dF32.offset, + input: input.buffer, inputOffset: input.offset, out: out.buffer, outOffset: out.offset, + in_dim: i, out_dim: o, n_rows: n, gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("gemmQ8: unsupported dtype \(out.dtype)") + } + } + + /// Multi-query causal sliding-window SDPA (d512, sink, MQA) for prefill. + /// q/out `[nQuery, nQHeads, headDim]`; k/v `[kvStride, headDim]` per kv + /// head (absolute position). Query q_pos attends KV + /// `[max(0, kvBase+q_pos+1-window) .. kvBase+q_pos]`. Threads = TGs×32. + public static func sdpaPrefillD512Sink( + q: Tensor, k: Tensor, v: Tensor, sinkLogit: Tensor, out: Tensor, + headDim: Int, nQHeads: Int, kvStride: Int, headsPerGroup: Int, + window: Int, kvBase: Int, scale: Float, nQuery: Int, on cmd: MTLCommandBuffer + ) { + let grd = MTLSize(width: nQHeads * 32, height: nQuery, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let hd = UInt32(headDim); let nq = UInt32(nQHeads); let ks = UInt32(kvStride) + let hpg = UInt32(headsPerGroup); let w = UInt32(window); let kb = UInt32(kvBase) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_sdpa_prefill_d512_sink_f16( + q: q.buffer, qOffset: q.offset, k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, sink_logit: sinkLogit.buffer, sink_logitOffset: sinkLogit.offset, + out: out.buffer, outOffset: out.offset, head_dim: hd, n_q_heads: nq, kv_stride: ks, + heads_per_group: hpg, window: w, kv_base: kb, scale: scale, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_sdpa_prefill_d512_sink_f32( + q: q.buffer, qOffset: q.offset, k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, sink_logit: sinkLogit.buffer, sink_logitOffset: sinkLogit.offset, + out: out.buffer, outOffset: out.offset, head_dim: hd, n_q_heads: nq, kv_stride: ks, + heads_per_group: hpg, window: w, kv_base: kb, scale: scale, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_sdpa_prefill_d512_sink_bf16( + q: q.buffer, qOffset: q.offset, k: k.buffer, kOffset: k.offset, + v: v.buffer, vOffset: v.offset, sink_logit: sinkLogit.buffer, sink_logitOffset: sinkLogit.offset, + out: out.buffer, outOffset: out.offset, head_dim: hd, n_q_heads: nq, kv_stride: ks, + heads_per_group: hpg, window: w, kv_base: kb, scale: scale, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("sdpaPrefillD512Sink: unsupported dtype \(out.dtype)") + } + } + + /// IQ2_XXS grouped BGEMM (prefill gate/up). Rows pre-permuted by expert; + /// `indices[row]` = expert id (into the resident pool). `qs`/`dF32` hold + /// all experts. out `[mTotal, nOut]`. BM=16/BN=32 MMA, 32 threads/TG. + public static func moeBgemmIQ2XXS( + x: Tensor, qsAll: Tensor, dAll: Tensor, grid: Tensor, signs: Tensor, + indices: Tensor, out: Tensor, mTotal: Int, nOut: Int, kIn: Int, on cmd: MTLCommandBuffer + ) { + precondition(qsAll.dtype == .u32 && dAll.dtype == .f32 && indices.dtype == .u32) + let grd = MTLSize(width: (nOut / 32) * 32, height: (mTotal + 15) / 16, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_gather_bgemm_iq2xxs_mpp_f16( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + d_f32: dAll.buffer, d_f32Offset: dAll.offset, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_gather_bgemm_iq2xxs_mpp_f32( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + d_f32: dAll.buffer, d_f32Offset: dAll.offset, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_gather_bgemm_iq2xxs_mpp_bf16( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + d_f32: dAll.buffer, d_f32Offset: dAll.offset, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmIQ2XXS: unsupported dtype \(out.dtype)") + } + } + + /// ZERO-COPY IQ2_XXS grouped BGEMM reading raw blocks from an mmap + /// view buffer (no repack pool). `viewBuf` is the no-copy MTLBuffer; + /// bound twice (as u8 for qs bytes, f16 for block d). `indices[row]` + /// is the EXPERT id; `tensorByteOff`/`expertByteStride` locate the + /// tensor + per-expert stride within the view (bytes). + public static func moeBgemmIQ2XXSView( + x: Tensor, viewBuf: MTLBuffer, viewByteOffset: Int, + grid: Tensor, signs: Tensor, indices: Tensor, out: Tensor, + mTotal: Int, nOut: Int, kIn: Int, tensorByteOff: Int, expertByteStride: Int, + on cmd: MTLCommandBuffer + ) { + precondition(indices.dtype == .u32) + let grd = MTLSize(width: (nOut / 32) * 32, height: (mTotal + 15) / 16, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + // qs read raw from the no-copy view (zero-copy bulk); d from a small + // separate per-expert f32 array (avoids aliasing the buffer as f16). + let tOff = UInt32(tensorByteOff + viewByteOffset) + let eStride = UInt32(expertByteStride) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_view_f16( + x: x.buffer, xOffset: x.offset, view_u8: viewBuf, view_u8Offset: 0, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_view_f32( + x: x.buffer, xOffset: x.offset, view_u8: viewBuf, view_u8Offset: 0, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_view_bf16( + x: x.buffer, xOffset: x.offset, view_u8: viewBuf, view_u8Offset: 0, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmIQ2XXSView: unsupported dtype \(out.dtype)") + } + } + + /// POOL-FREE amortized view bgemm: bm64 MMA reading raw resident IQ2 + /// blocks via aligned u16 (114 GB/s raw read; 34 GB/s amortized — faster + /// than the pool bm64, with NO repack). viewBuf = resident mmap view; + /// indices = GLOBAL expert ids. See moe_bgemm_iq2xxs_view_u16_bm64.rs. + public static func moeBgemmIQ2XXSViewU16Bm64( + x: Tensor, viewBuf: MTLBuffer, viewByteOffset: Int, + grid: Tensor, signs: Tensor, indices: Tensor, out: Tensor, + mTotal: Int, nOut: Int, kIn: Int, tensorByteOff: Int, expertByteStride: Int, + on cmd: MTLCommandBuffer + ) { + precondition(indices.dtype == .u32) + let grd = MTLSize(width: nOut / 64, height: (mTotal + 63) / 64, depth: 1) + let tg = MTLSize(width: 128, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + let tOff = UInt32(tensorByteOff + viewByteOffset); let eStride = UInt32(expertByteStride) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_view_u16_bm64_f16_threadgroups( + x: x.buffer, xOffset: x.offset, view_u16: viewBuf, view_u16Offset: 0, view_f16: viewBuf, view_f16Offset: 0, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_view_u16_bm64_f32_threadgroups( + x: x.buffer, xOffset: x.offset, view_u16: viewBuf, view_u16Offset: 0, view_f16: viewBuf, view_f16Offset: 0, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_view_u16_bm64_bf16_threadgroups( + x: x.buffer, xOffset: x.offset, view_u16: viewBuf, view_u16Offset: 0, view_f16: viewBuf, view_f16Offset: 0, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmIQ2XXSViewU16Bm64: unsupported dtype \(out.dtype)") + } + } + + /// Q2_K grouped BGEMM (prefill down). See `moeBgemmIQ2XXS`. + public static func moeBgemmQ2K( + x: Tensor, qsAll: Tensor, scalesAll: Tensor, dAll: Tensor, dminAll: Tensor, + indices: Tensor, out: Tensor, mTotal: Int, nOut: Int, kIn: Int, on cmd: MTLCommandBuffer + ) { + precondition(qsAll.dtype == .u32 && scalesAll.dtype == .u8 && dAll.dtype == .f32 && dminAll.dtype == .f32) + let grd = MTLSize(width: (nOut / 32) * 32, height: (mTotal + 15) / 16, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_gather_bgemm_q2k_mpp_f16( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_gather_bgemm_q2k_mpp_f32( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_gather_bgemm_q2k_mpp_bf16( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmQ2K: unsupported dtype \(out.dtype)") + } + } + + /// ZERO-COPY Q2_K grouped BGEMM (prefill down): reads raw 84-byte Q2_K + /// blocks straight from a no-copy mmap view buffer, indexed by expert id. + /// See `moeBgemmIQ2XXSView`. expertByteStride = nBlocksPerExpert*84. + /// Q2_K view-BM64: raw 84-byte Q2_K blocks → bm64 64×64×32 coop_tile MMA, + /// NO deinterleave pool. Same speed as the pool bm64, eliminates the ~342ms/ + /// layer Q2_K pool build. indices = slot/expert id; d/dmin via the f16 view + /// (= viewBuf), scales/qs via the u16 view. Live-compiled (name has bgemm). + public static func moeBgemmQ2KViewU16Bm64( + x: Tensor, viewBuf: MTLBuffer, viewByteOffset: Int, + indices: Tensor, out: Tensor, + mTotal: Int, nOut: Int, kIn: Int, tensorByteOff: Int, expertByteStride: Int, + on cmd: MTLCommandBuffer + ) { + precondition(indices.dtype == .u32) + let grd = MTLSize(width: nOut / 64, height: (mTotal + 63) / 64, depth: 1) + let tg = MTLSize(width: 128, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + let tOff = UInt32(tensorByteOff + viewByteOffset); let eStride = UInt32(expertByteStride) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_bgemm_q2k_view_u16_bm64_f16_threadgroups( + x: x.buffer, xOffset: x.offset, view_u16: viewBuf, view_u16Offset: 0, view_f16: viewBuf, view_f16Offset: 0, + indices: indices.buffer, indicesOffset: indices.offset, out: out.buffer, outOffset: out.offset, + m_total: m, n_out: n, k_in: k, tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_bgemm_q2k_view_u16_bm64_f32_threadgroups( + x: x.buffer, xOffset: x.offset, view_u16: viewBuf, view_u16Offset: 0, view_f16: viewBuf, view_f16Offset: 0, + indices: indices.buffer, indicesOffset: indices.offset, out: out.buffer, outOffset: out.offset, + m_total: m, n_out: n, k_in: k, tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_bgemm_q2k_view_u16_bm64_bf16_threadgroups( + x: x.buffer, xOffset: x.offset, view_u16: viewBuf, view_u16Offset: 0, view_f16: viewBuf, view_f16Offset: 0, + indices: indices.buffer, indicesOffset: indices.offset, out: out.buffer, outOffset: out.offset, + m_total: m, n_out: n, k_in: k, tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmQ2KViewU16Bm64: unsupported dtype \(out.dtype)") + } + } + + public static func moeBgemmQ2KView( + x: Tensor, viewBuf: MTLBuffer, viewByteOffset: Int, + indices: Tensor, out: Tensor, + mTotal: Int, nOut: Int, kIn: Int, tensorByteOff: Int, expertByteStride: Int, + on cmd: MTLCommandBuffer + ) { + precondition(indices.dtype == .u32) + let grd = MTLSize(width: (nOut / 32) * 32, height: (mTotal + 15) / 16, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + let tOff = UInt32(tensorByteOff + viewByteOffset) + let eStride = UInt32(expertByteStride) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_bgemm_q2k_view_f16( + x: x.buffer, xOffset: x.offset, view_u8: viewBuf, view_u8Offset: 0, + indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_bgemm_q2k_view_f32( + x: x.buffer, xOffset: x.offset, view_u8: viewBuf, view_u8Offset: 0, + indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_bgemm_q2k_view_bf16( + x: x.buffer, xOffset: x.offset, view_u8: viewBuf, view_u8Offset: 0, + indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + tensor_byte_off: tOff, expert_byte_stride: eStride, gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmQ2KView: unsupported dtype \(out.dtype)") + } + } + + /// FAST prefill MoE IQ2_XXS GEMV-over-rows: direct simd_sum dot-product + /// over all M rows in one dispatch (~24x the coop-tile bgemm). Reads the + /// SAME resident split pool the bgemm uses; `expertIds[row]` = the row's + /// packed pool slot. out[row, mOut]. grid (mOut, mTotal). + public static func moeGemvRowsIQ2XXS( + x: Tensor, qsAll: Tensor, dAll: Tensor, expertIds: Tensor, grid: Tensor, signs: Tensor, + out: Tensor, mTotal: Int, mOut: Int, kIn: Int, on cmd: MTLCommandBuffer + ) { + precondition(qsAll.dtype == .u32 && dAll.dtype == .f32 && expertIds.dtype == .u32) + let grd = MTLSize(width: mOut * 32, height: mTotal, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let k = UInt32(kIn); let mo = UInt32(mOut); let mt = UInt32(mTotal) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_gemv_rows_iq2xxs_f16( + x: x.buffer, xOffset: x.offset, qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_gemv_rows_iq2xxs_f32( + x: x.buffer, xOffset: x.offset, qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_gemv_rows_iq2xxs_bf16( + x: x.buffer, xOffset: x.offset, qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeGemvRowsIQ2XXS: unsupported dtype \(out.dtype)") + } + } + + /// Dense Q8 GEMM via cooperative-tensor MMA (~6× the scalar gemmQ8). + /// Drop-in for gemmQ8 (q/kv/q_a/q_b/O-LoRA-B/shared-expert). Live-compiled + /// (name has _mpp_ → PSOCache.isMppKernel). 64×64×32 coop_tile, 128 thr/tg. + public static func gemmQ8Mpp( + qs: Tensor, dF32: Tensor, input: Tensor, out: Tensor, + inDim: Int, outDim: Int, nRows: Int, on cmd: MTLCommandBuffer + ) { + precondition(qs.dtype == .u32 && dF32.dtype == .f32) + let grd = MTLSize(width: (outDim + 63) / 64, height: (nRows + 63) / 64, depth: 1) + let tg = MTLSize(width: 128, height: 1, depth: 1) + let n = UInt32(nRows); let o = UInt32(outDim); let k = UInt32(inDim) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_gemm_q8_mpp_f16( + x: input.buffer, xOffset: input.offset, qs: qs.buffer, qsOffset: qs.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, out: out.buffer, outOffset: out.offset, + n_rows: n, out_dim: o, k_in: k, gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_gemm_q8_mpp_f32( + x: input.buffer, xOffset: input.offset, qs: qs.buffer, qsOffset: qs.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, out: out.buffer, outOffset: out.offset, + n_rows: n, out_dim: o, k_in: k, gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_gemm_q8_mpp_bf16( + x: input.buffer, xOffset: input.offset, qs: qs.buffer, qsOffset: qs.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, out: out.buffer, outOffset: out.offset, + n_rows: n, out_dim: o, k_in: k, gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("gemmQ8Mpp: unsupported dtype \(out.dtype)") + } + } + + /// GROUPED Q8 GEMM via cooperative-tensor MMA — the MMA O-LoRA-A (vs the + /// scalar groupedGemmQ8). 64×64×32 coop_tile, live-compiled (_mpp_). + public static func groupedGemmQ8Mpp( + qs: Tensor, dF32: Tensor, input: Tensor, out: Tensor, + inDim: Int, outDim: Int, nRows: Int, nGroups: Int, rowsPerGroup: Int, on cmd: MTLCommandBuffer + ) { + precondition(qs.dtype == .u32 && dF32.dtype == .f32) + let grd = MTLSize(width: (outDim + 63) / 64, height: (nRows + 63) / 64, depth: 1) + let tg = MTLSize(width: 128, height: 1, depth: 1) + let n = UInt32(nRows); let o = UInt32(outDim); let k = UInt32(inDim) + let ng = UInt32(nGroups); let rpg = UInt32(rowsPerGroup) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_grouped_gemm_q8_mpp_f16( + x: input.buffer, xOffset: input.offset, qs: qs.buffer, qsOffset: qs.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, out: out.buffer, outOffset: out.offset, + n_rows: n, out_dim: o, k_in: k, n_groups: ng, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_grouped_gemm_q8_mpp_f32( + x: input.buffer, xOffset: input.offset, qs: qs.buffer, qsOffset: qs.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, out: out.buffer, outOffset: out.offset, + n_rows: n, out_dim: o, k_in: k, n_groups: ng, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_grouped_gemm_q8_mpp_bf16( + x: input.buffer, xOffset: input.offset, qs: qs.buffer, qsOffset: qs.offset, + d_f32: dF32.buffer, d_f32Offset: dF32.offset, out: out.buffer, outOffset: out.offset, + n_rows: n, out_dim: o, k_in: k, n_groups: ng, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("groupedGemmQ8Mpp: unsupported dtype \(out.dtype)") + } + } + + /// GROUPED Q8 GEMM (scalar) — amortized O-LoRA-A (replaces the per-token + /// groupedGemvQ8Rows, the #1 prefill attention hotspot ~47ms/layer). + /// Tiled 32×32, weight dequanted once/tile, input grouped: output col o + /// reads input group g=o/rowsPerGroup of an (nGroups*inDim)-wide row. + public static func groupedGemmQ8( + qs: Tensor, dF32: Tensor, input: Tensor, out: Tensor, + inDim: Int, outDim: Int, nRows: Int, nGroups: Int, rowsPerGroup: Int, on cmd: MTLCommandBuffer + ) { + precondition(qs.dtype == .u32 && dF32.dtype == .f32) + let gx = (outDim + 31) / 32; let gy = (nRows + 31) / 32 + let grd = MTLSize(width: gx * 1024, height: gy, depth: 1) + let tg = MTLSize(width: 1024, height: 1, depth: 1) + let i = UInt32(inDim); let o = UInt32(outDim); let n = UInt32(nRows) + let ng = UInt32(nGroups); let rpg = UInt32(rowsPerGroup) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_grouped_gemm_q8_f16( + qs: qs.buffer, qsOffset: qs.offset, d_f32: dF32.buffer, d_f32Offset: dF32.offset, + input: input.buffer, inputOffset: input.offset, out: out.buffer, outOffset: out.offset, + in_dim: i, out_dim: o, n_rows: n, n_groups: ng, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_grouped_gemm_q8_f32( + qs: qs.buffer, qsOffset: qs.offset, d_f32: dF32.buffer, d_f32Offset: dF32.offset, + input: input.buffer, inputOffset: input.offset, out: out.buffer, outOffset: out.offset, + in_dim: i, out_dim: o, n_rows: n, n_groups: ng, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_grouped_gemm_q8_bf16( + qs: qs.buffer, qsOffset: qs.offset, d_f32: dF32.buffer, d_f32Offset: dF32.offset, + input: input.buffer, inputOffset: input.offset, out: out.buffer, outOffset: out.offset, + in_dim: i, out_dim: o, n_rows: n, n_groups: ng, rows_per_group: rpg, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("groupedGemmQ8: unsupported dtype \(out.dtype)") + } + } + + /// WEIGHT-STATIONARY prefill MoE IQ2_XXS gemv — dequants each expert's + /// weight row ONCE into threadgroup mem and reuses it across its rows in + /// the tile (amortized like bm64 but at gemv speed, ~3.7x bm64). Plain + /// gemv (no MMA) so it loads correctly from the metallib. rowsPerTile=8. + public static func moeGemvWsIQ2XXS( + x: Tensor, qsAll: Tensor, dAll: Tensor, expertIds: Tensor, grid: Tensor, signs: Tensor, + out: Tensor, mTotal: Int, mOut: Int, kIn: Int, rowsPerTile: Int = 8, on cmd: MTLCommandBuffer + ) { + precondition(qsAll.dtype == .u32 && dAll.dtype == .f32 && expertIds.dtype == .u32) + let nTiles = (mTotal + rowsPerTile - 1) / rowsPerTile + let grd = MTLSize(width: mOut * 32, height: nTiles, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let k = UInt32(kIn); let mo = UInt32(mOut); let mt = UInt32(mTotal); let rpt = UInt32(rowsPerTile) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_gemv_ws_iq2xxs_f16( + x: x.buffer, xOffset: x.offset, qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, rows_per_tile: rpt, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_gemv_ws_iq2xxs_f32( + x: x.buffer, xOffset: x.offset, qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, rows_per_tile: rpt, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_gemv_ws_iq2xxs_bf16( + x: x.buffer, xOffset: x.offset, qs_all: qsAll.buffer, qs_allOffset: qsAll.offset, + d_all: dAll.buffer, d_allOffset: dAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + grid: grid.buffer, gridOffset: grid.offset, signs: signs.buffer, signsOffset: signs.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, rows_per_tile: rpt, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeGemvWsIQ2XXS: unsupported dtype \(out.dtype)") + } + } + + /// FAST prefill MoE Q2_K GEMV-over-rows (down). See moeGemvRowsIQ2XXS. + public static func moeGemvRowsQ2K( + x: Tensor, qsAll: Tensor, scalesAll: Tensor, dAll: Tensor, dminAll: Tensor, expertIds: Tensor, + out: Tensor, mTotal: Int, mOut: Int, kIn: Int, on cmd: MTLCommandBuffer + ) { + precondition(qsAll.dtype == .u32 && scalesAll.dtype == .u8 && dAll.dtype == .f32 && dminAll.dtype == .f32 && expertIds.dtype == .u32) + let grd = MTLSize(width: mOut * 32, height: mTotal, depth: 1) + let tg = MTLSize(width: 32, height: 1, depth: 1) + let k = UInt32(kIn); let mo = UInt32(mOut); let mt = UInt32(mTotal) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_gemv_rows_q2k_f16( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_gemv_rows_q2k_f32( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_gemv_rows_q2k_bf16( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, expert_ids: expertIds.buffer, expert_idsOffset: expertIds.offset, + out: out.buffer, outOffset: out.offset, k_in: k, m_out: mo, m_total: mt, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeGemvRowsQ2K: unsupported dtype \(out.dtype)") + } + } + + /// bm64 IQ2_XXS BGEMM (64×64×32, 4 simdgroups) — ~2x the 16×32 bgemm, + /// amortized + byte-exact. Same pool/indices as moeBgemmIQ2XXS; grid is + /// n_out/64 × ceil(M/64), 128 threads/tg. + public static func moeBgemmIQ2XXSBm64( + x: Tensor, qsAll: Tensor, dAll: Tensor, grid: Tensor, signs: Tensor, + indices: Tensor, out: Tensor, mTotal: Int, nOut: Int, kIn: Int, on cmd: MTLCommandBuffer + ) { + precondition(qsAll.dtype == .u32 && dAll.dtype == .f32 && indices.dtype == .u32) + // coop_tile kernel → MUST dispatch THREADGROUPS (dispatchThreads breaks + // simdgroup-matrix). gridSize = threadgroup counts. + let grd = MTLSize(width: nOut / 64, height: (mTotal + 63) / 64, depth: 1) + let tg = MTLSize(width: 128, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_bm64_f16_threadgroups( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + d_f32: dAll.buffer, d_f32Offset: dAll.offset, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_bm64_f32_threadgroups( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + d_f32: dAll.buffer, d_f32Offset: dAll.offset, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_bgemm_iq2xxs_bm64_bf16_threadgroups( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + d_f32: dAll.buffer, d_f32Offset: dAll.offset, grid: grid.buffer, gridOffset: grid.offset, + signs: signs.buffer, signsOffset: signs.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmIQ2XXSBm64: unsupported dtype \(out.dtype)") + } + } + + /// bm64 Q2_K BGEMM (down). See moeBgemmIQ2XXSBm64. + public static func moeBgemmQ2KBm64( + x: Tensor, qsAll: Tensor, scalesAll: Tensor, dAll: Tensor, dminAll: Tensor, + indices: Tensor, out: Tensor, mTotal: Int, nOut: Int, kIn: Int, on cmd: MTLCommandBuffer + ) { + precondition(qsAll.dtype == .u32 && scalesAll.dtype == .u8 && dAll.dtype == .f32 && dminAll.dtype == .f32 && indices.dtype == .u32) + // coop_tile → dispatchThreadgroups (gridSize in threadgroups). + let grd = MTLSize(width: nOut / 64, height: (mTotal + 63) / 64, depth: 1) + let tg = MTLSize(width: 128, height: 1, depth: 1) + let m = UInt32(mTotal); let n = UInt32(nOut); let k = UInt32(kIn) + switch out.dtype { + case .f16: + MetalTileKernels.ffai_moe_bgemm_q2k_bm64_f16_threadgroups( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .f32: + MetalTileKernels.ffai_moe_bgemm_q2k_bm64_f32_threadgroups( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + case .bf16: + MetalTileKernels.ffai_moe_bgemm_q2k_bm64_bf16_threadgroups( + x: x.buffer, xOffset: x.offset, qs: qsAll.buffer, qsOffset: qsAll.offset, + scales: scalesAll.buffer, scalesOffset: scalesAll.offset, d_f32: dAll.buffer, d_f32Offset: dAll.offset, + dmin_f32: dminAll.buffer, dmin_f32Offset: dminAll.offset, indices: indices.buffer, indicesOffset: indices.offset, + out: out.buffer, outOffset: out.offset, m_total: m, n_out: n, k_in: k, + gridSize: grd, threadgroupSize: tg, on: cmd) + default: fatalError("moeBgemmQ2KBm64: unsupported dtype \(out.dtype)") + } + } +} diff --git a/Sources/FFAI/Stats/MemoryStats.swift b/Sources/FFAI/Stats/MemoryStats.swift index 9ce22470..9933e970 100644 --- a/Sources/FFAI/Stats/MemoryStats.swift +++ b/Sources/FFAI/Stats/MemoryStats.swift @@ -48,6 +48,31 @@ public struct MemorySnapshot: Sendable, Equatable { timestamp: Date() ) } + + /// System-wide free memory as a percentage of `hw.memsize` + /// (`free + inactive` pages), or `nil` if the mach query fails. + /// Process-independent — used by loaders / prefill freeze-guards to + /// bail before the machine pages to death. The single source for + /// "how much headroom does the box have right now". + public static func systemFreePercent() -> Double? { + var total: UInt64 = 0 + var sz = MemoryLayout.size + if sysctlbyname("hw.memsize", &total, &sz, nil, 0) != 0 || total == 0 { return nil } + var stats = vm_statistics64_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size) + let kr = withUnsafeMutablePointer(to: &stats) { ptr -> kern_return_t in + ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in + host_statistics64(mach_host_self(), HOST_VM_INFO64, intPtr, &count) + } + } + guard kr == KERN_SUCCESS else { return nil } + var pageSize: UInt64 = 16384 + var psz = MemoryLayout.size + _ = sysctlbyname("hw.pagesize", &pageSize, &psz, nil, 0) + let freeBytes = (UInt64(stats.free_count) + UInt64(stats.inactive_count)) * pageSize + return Double(freeBytes) / Double(total) * 100.0 + } } /// Aggregates phase-boundary snapshots + per-token peak samples for one diff --git a/Sources/FFAI/Telemetry/KLDivergence.swift b/Sources/FFAI/Telemetry/KLDivergence.swift new file mode 100644 index 00000000..9ac8a522 --- /dev/null +++ b/Sources/FFAI/Telemetry/KLDivergence.swift @@ -0,0 +1,203 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// KLDivergence — per-position + aggregate metrics for comparing a +// codec's next-token distribution against an fp16/fp16 baseline. The +// regression gate for every TQ+ / AURA quality port (matched-norm L2, +// InnerQ equalization, per-group FP8 scale, sparse-V threshold, etc.) +// — without these metrics we can't tell if a codec change improved or +// degraded distributional fidelity. +// +// Mirrors the canonical TQ+ harness output from +// `bench-tq+/harness/kld_vs_baseline.py` in +// the reference TQ+ implementation: same field names, same percentile +// list, so plot scripts + cross-codec comparisons remain compatible. + +import Foundation + +public enum KLDivergence { + + /// Per-position quality metrics from comparing one codec's logits + /// to a baseline at the same context position. + public struct PositionMetrics: Sendable { + /// KL(baseline ‖ codec). Always ≥ 0. + public let kld: Double + /// argmax over baseline log-probs. + public let baselineTopIdx: Int + /// argmax over codec log-probs. + public let codecTopIdx: Int + /// `exp(logSoftmax(baselineLogits)[nextTokenId])` — baseline's + /// assigned probability of the next ground-truth token. + public let baselineNextProb: Double + /// Same but under the codec's distribution. + public let codecNextProb: Double + } + + /// Aggregate metrics across all measured positions. Same field set + /// as llama-perplexity's `--kl-divergence` output so downstream + /// plotting / summary scripts can consume FFAI runs. + public struct AggregateMetrics: Sendable { + public let meanKld: Double + public let medianKld: Double + public let maxKld: Double + /// 99.9 / 99 / 95 / 90 / 10 / 5 / 1 percentile KLDs. + public let kld999: Double + public let kld99: Double + public let kld95: Double + public let kld90: Double + public let kld10: Double + public let kld05: Double + public let kld01: Double + /// Fraction of positions where baseline argmax == codec argmax. + /// Greedy decode is preserved when this stays > 0.99 even if + /// KLD is non-trivial — distributional drift only matters at + /// sampling temperature > 0. + public let sameTopFraction: Double + /// Mean |P_base(next) − P_codec(next)|. + public let meanDp: Double + public let rmsDp: Double + public let maxDp: Double + public let nPositions: Int + } + + /// Compute per-position metrics from raw logit vectors. `nextTokenId` + /// is the ground-truth token at `position + 1` in the corpus — + /// what the model should have predicted at `position`. + /// + /// Both inputs must be the same length (vocab size). Uses + /// numerically-stable log-softmax in Double precision regardless + /// of the logits' input dtype. + public static func positionMetrics( + baselineLogits: [Float], codecLogits: [Float], + nextTokenId: Int + ) -> PositionMetrics { + precondition( + baselineLogits.count == codecLogits.count, + "KLDivergence.positionMetrics: logits vocab size mismatch " + + "(\(baselineLogits.count) vs \(codecLogits.count))") + precondition( + nextTokenId >= 0 && nextTokenId < baselineLogits.count, + "KLDivergence.positionMetrics: nextTokenId \(nextTokenId) " + + "out of [0, \(baselineLogits.count))") + + let logPbase = logSoftmax(baselineLogits) + let logPcodec = logSoftmax(codecLogits) + + var kld: Double = 0 + var topBase = 0 + var topCodec = 0 + var bestBase: Double = -.infinity + var bestCodec: Double = -.infinity + for i in 0 ..< logPbase.count { + let pb = exp(logPbase[i]) + kld += pb * (logPbase[i] - logPcodec[i]) + if logPbase[i] > bestBase { + bestBase = logPbase[i] + topBase = i + } + if logPcodec[i] > bestCodec { + bestCodec = logPcodec[i] + topCodec = i + } + } + // KL ≥ 0 in theory; clamp tiny negative drift from fp rounding. + if kld < 0 { kld = 0 } + let pBaseNext = exp(logPbase[nextTokenId]) + let pCodecNext = exp(logPcodec[nextTokenId]) + return PositionMetrics( + kld: kld, + baselineTopIdx: topBase, codecTopIdx: topCodec, + baselineNextProb: pBaseNext, codecNextProb: pCodecNext) + } + + /// Aggregate per-position metrics into the summary stats reported + /// by llama-perplexity's `--kl-divergence` mode. Sorts the KLD + /// list once internally; cost is O(N log N). + public static func aggregate(_ perPosition: [PositionMetrics]) -> AggregateMetrics { + precondition( + !perPosition.isEmpty, + "KLDivergence.aggregate: positions list must not be empty") + let klds = perPosition.map { $0.kld } + let sorted = klds.sorted() + let n = perPosition.count + + let meanKld = klds.reduce(0, +) / Double(n) + let dps = perPosition.map { abs($0.baselineNextProb - $0.codecNextProb) } + let sameTop = perPosition.reduce(into: 0) { acc, m in + if m.baselineTopIdx == m.codecTopIdx { acc += 1 } + } + + return AggregateMetrics( + meanKld: meanKld, + medianKld: quantile(sorted, 0.5), + maxKld: sorted.last ?? 0, + kld999: quantile(sorted, 0.999), + kld99: quantile(sorted, 0.99), + kld95: quantile(sorted, 0.95), + kld90: quantile(sorted, 0.90), + kld10: quantile(sorted, 0.10), + kld05: quantile(sorted, 0.05), + kld01: quantile(sorted, 0.01), + sameTopFraction: Double(sameTop) / Double(n), + meanDp: dps.reduce(0, +) / Double(n), + rmsDp: (dps.map { $0 * $0 }.reduce(0, +) / Double(n)).squareRoot(), + maxDp: dps.max() ?? 0, + nPositions: n) + } + + /// Human-readable single-line summary matching the format + /// `kld_vs_baseline.py` prints per codec row. + public static func summaryLine( + label: String, metrics: AggregateMetrics + ) -> String { + func fmt(_ v: Double) -> String { String(format: "%.4f", v) } + return "\(label): mean_kld=\(fmt(metrics.meanKld)) " + + "med=\(fmt(metrics.medianKld)) " + + "95%=\(fmt(metrics.kld95)) " + + "99%=\(fmt(metrics.kld99)) " + + "99.9%=\(fmt(metrics.kld999)) " + + "max=\(fmt(metrics.maxKld)) " + + "same_top=\(String(format: "%.4f", metrics.sameTopFraction)) " + + "mean_dp=\(fmt(metrics.meanDp)) " + + "n=\(metrics.nPositions)" + } + + // MARK: - Private helpers + + /// Numerically-stable log-softmax in Double. Subtract max for + /// stability, then `x - log(Σ exp(x'))`. + private static func logSoftmax(_ logits: [Float]) -> [Double] { + var maxL: Float = -.infinity + for v in logits where v > maxL { maxL = v } + var sumExp: Double = 0 + let centered = logits.map { Double($0 - maxL) } + for v in centered { sumExp += exp(v) } + let logSumExp = log(sumExp) + return centered.map { $0 - logSumExp } + } + + /// Linear-interpolation quantile (numpy default; matches what the + /// llama-perplexity `--kl-divergence` percentiles report). The + /// high-percentile rows (99 / 99.9) need to surface heavy-tail + /// outliers, which a nearest-rank-lower variant would lose. + /// `q` in [0, 1]. + private static func quantile(_ sorted: [Double], _ q: Double) -> Double { + if sorted.isEmpty { return 0 } + let pos = Double(sorted.count - 1) * q + let lo = Int(pos.rounded(.down)) + let hi = min(sorted.count - 1, lo + 1) + let frac = pos - Double(lo) + return sorted[lo] + frac * (sorted[hi] - sorted[lo]) + } +} diff --git a/Sources/FFAI/Telemetry/LogitsEmitter.swift b/Sources/FFAI/Telemetry/LogitsEmitter.swift new file mode 100644 index 00000000..6941419c --- /dev/null +++ b/Sources/FFAI/Telemetry/LogitsEmitter.swift @@ -0,0 +1,99 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// LogitsEmitter — drives a per-token forward loop over a corpus and +// returns the per-position full-vocab logit traces needed by +// `KLDivergence.positionMetrics`. The pair (baseline trace, codec +// trace) is the input to the regression gate for every TQ+ / AURA +// quality port. + +import Foundation +import Metal + +public enum LogitsEmitter { + + /// Run `tokenIds` through `model` one token at a time, returning + /// the post-`lmHead` logits at every position as `[T, vocab]` + /// `Float`s. The caller owns the model + decides which KV cache + /// type to back it with (fp16 baseline vs AURA codec). + /// + /// **Cost:** one CPU-side `commit` + `waitUntilCompleted` per + /// token. Use small corpora (a few hundred tokens) for KLD + /// validation — at 1.7B model + 256-tok corpus this completes in + /// ~10s on M5 Max. + /// + /// `maxSeq` is passed through to the cache allocator. Keep it + /// only as large as the input so cache allocation stays bounded — + /// the default Qwen3 maxSeq is 256K and allocating that much KV + /// for a 64-token corpus wastes seconds + several GB. + public static func emit( + model: any LanguageModel, tokenIds: [Int], + maxSeq: Int? = nil, + device: Device = .shared + ) -> [[Float]] { + precondition(!tokenIds.isEmpty, "LogitsEmitter.emit: empty token list") + let cap = maxSeq ?? max(tokenIds.count, 256) + let caches = model.makeLayerCaches(maxSeq: cap, device: device) + var trace: [[Float]] = [] + trace.reserveCapacity(tokenIds.count) + for (i, tok) in tokenIds.enumerated() { + // Use the no-cmd default extension so each token gets a + // fresh cmd-buffer that is committed + waited internally — + // this matches the pattern in existing FFAI bench tests + // (Qwen35MoEBenchIntegrationTests.decodeBenchT1) and avoids + // any subtle issue with our caller-supplied cmd buffer + // interacting with families that allocate their own work + // buffers inside forward(...). + let logits = model.forward( + tokenId: tok, position: i, + caches: caches, device: device) + // Use toFloatArray so we handle f32 / f16 / bf16 logits dtypes + // correctly — `toArray(as: Float.self)` reinterprets raw bytes + // and segfaults on the half-precision logits Qwen3 emits. + trace.append(logits.toFloatArray()) + } + return trace + } + + /// Compare two logit traces produced by `emit(...)` (same corpus, + /// different KV cache types) into the aggregated KLD metrics + /// reported by the canonical `llama-perplexity --kl-divergence` + /// harness. `tokenIds[t + 1]` is the ground-truth "next token" at + /// position `t`; the last position is dropped since there's no + /// next-token reference for it. + public static func compare( + baseline: [[Float]], codec: [[Float]], tokenIds: [Int] + ) -> KLDivergence.AggregateMetrics { + precondition( + baseline.count == codec.count + && baseline.count == tokenIds.count, + "LogitsEmitter.compare: trace and tokenIds length mismatch " + + "(baseline=\(baseline.count), codec=\(codec.count), " + + "tokens=\(tokenIds.count))") + precondition( + tokenIds.count >= 2, + "LogitsEmitter.compare: need at least 2 positions to score " + + "(position t predicts tokenIds[t + 1])") + var positions: [KLDivergence.PositionMetrics] = [] + positions.reserveCapacity(tokenIds.count - 1) + for t in 0 ..< (tokenIds.count - 1) { + positions.append( + KLDivergence.positionMetrics( + baselineLogits: baseline[t], + codecLogits: codec[t], + nextTokenId: tokenIds[t + 1])) + } + return KLDivergence.aggregate(positions) + } +} diff --git a/Sources/FFAI/Tensor.swift b/Sources/FFAI/Tensor.swift index 60cb24f7..0345a448 100644 --- a/Sources/FFAI/Tensor.swift +++ b/Sources/FFAI/Tensor.swift @@ -37,14 +37,32 @@ public struct Tensor: @unchecked Sendable { public var elementCount: Int { shape.reduce(1, *) } public var byteCount: Int { elementCount * dtype.byteSize } - /// Allocate a new contiguous tensor. Caller-owned buffer. + /// Allocate a new contiguous tensor. When `device.scratchModeActive` + /// is true, routes through the scratch slab so transients within + /// a `withScratch { ... }` scope don't hammer Metal's internal + /// driver pool (per-token forward path). public static func empty(shape: [Int], dtype: DType, device: Device = .shared) -> Tensor { + if device.scratchModeActive { + return Tensor.scratch(shape: shape, dtype: dtype, device: device) + } let count = shape.reduce(1, *) let bytes = count * dtype.byteSize return Tensor( buffer: device.makeBuffer(length: bytes), offset: 0, shape: shape, dtype: dtype) } + /// Allocate a sub-block-local tensor into the device's scratch + /// slab. Slice becomes invalid after `device.resetScratch()` — + /// use only for transients whose lifetime is bounded by the + /// caller's `device.withScratch { ... }` scope. Carry-over state + /// must use `Tensor.empty(...)` instead. + public static func scratch(shape: [Int], dtype: DType, device: Device = .shared) -> Tensor { + let count = shape.reduce(1, *) + let bytes = count * dtype.byteSize + let (buf, offset) = device.allocScratch(bytes: bytes) + return Tensor(buffer: buf, offset: offset, shape: shape, dtype: dtype) + } + /// Reshape (no copy). Element count must match. public func reshaped(to newShape: [Int]) -> Tensor { let newCount = newShape.reduce(1, *) diff --git a/Sources/MetalTileSwift/PSOCache.swift b/Sources/MetalTileSwift/PSOCache.swift index 36fe3708..c2f8dfdb 100644 --- a/Sources/MetalTileSwift/PSOCache.swift +++ b/Sources/MetalTileSwift/PSOCache.swift @@ -113,7 +113,7 @@ public final class PSOCache: @unchecked Sendable { // bit-deterministic wrong output (e.g. cos 0.816 vs 0.999 oracle). // Live-compile via `makeLibrary(source:)` resolves MPP against the // running OS's header, dodging the skew. See ollama #15594, #14432, - // llama.cpp PR #16634 for the same class of bug. + // (a known upstream Metal-compiler bug of the same class). let function: MTLFunction if Self.isMppKernel(name) { function = try liveCompileMppFunction(name) diff --git a/Tests/FFAITests/Benchmark/DispatchOverheadBench.swift b/Tests/FFAITests/Benchmark/DispatchOverheadBench.swift new file mode 100644 index 00000000..66a63f89 --- /dev/null +++ b/Tests/FFAITests/Benchmark/DispatchOverheadBench.swift @@ -0,0 +1,63 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +// +// Swift -> Metal per-dispatch overhead, the apples-to-apples counterpart to the +// Rust `dispatch_bench`. Measures one gemv two ways: +// - per-commit: one command buffer per op, commit + wait (matches the Rust +// Device.dispatch model exactly). +// - batched: many ops encoded into ONE command buffer, one commit (what the +// Swift `Ops.gemv(on: cmd)` API enables, and where the real headroom is). +// The per-commit number isolates the Metal commit()+wait floor; if it lands +// near the Rust number, the host language is not the variable. + +import Foundation +import Metal +import Testing + +@testable import FFAI + +@Suite("Dispatch overhead bench") +struct DispatchOverheadBench { + @Test("gemv per-dispatch overhead: per-commit vs batched") + func gemvDispatchOverhead() { + let m = 2048 + let w = Tensor.empty(shape: [m, m], dtype: .f32) + let x = Tensor.empty(shape: [m], dtype: .f32) + + // warmup (PSO build + first encode) + for _ in 0..<20 { + let cmd = Device.shared.makeCommandBuffer() + _ = Ops.gemv(weight: w, input: x, on: cmd) + cmd.commit() + cmd.waitUntilCompleted() + } + + let iters = 3000 + + // per-commit: one command buffer per dispatch (matches Rust) + var t0 = Date() + for _ in 0.. Metal gemv (2048x2048):") + print(String(format: " per-commit (1 cmd buffer/op): %.1f us/dispatch", perCommitUs)) + print(String(format: " batched (%d ops / 1 cmd buffer): %.1f us/op", batch, batchedUs)) + print(" (Rust per-commit, resident weights, was 178 us/dispatch)") + } +} diff --git a/Tests/FFAITests/DeviceTests.swift b/Tests/FFAITests/DeviceTests.swift index 381ab8ef..f4f9eb08 100644 --- a/Tests/FFAITests/DeviceTests.swift +++ b/Tests/FFAITests/DeviceTests.swift @@ -39,4 +39,107 @@ struct DeviceTests { cb.waitUntilCompleted() #expect(cb.status == .completed) } + + // ─── Scratch slab allocator ────────────────────────────────────── + // + // Use an isolated `Device` (same MTLDevice + queue as `.shared`) so + // these tests never mutate `Device.shared`'s scratch state, which + // other parallel suites allocate against. + + private func isolatedDevice() -> Device { + Device(mtlDevice: Device.shared.mtlDevice, commandQueue: Device.shared.commandQueue) + } + + @Test("allocScratch returns 16-byte-aligned bumping offsets") + func allocScratchAlignsAndBumps() { + let d = isolatedDevice() + let (b0, o0) = d.allocScratch(bytes: 100) + let (b1, o1) = d.allocScratch(bytes: 32) + // Same backing slab for both slices. + #expect(b0 === b1) + #expect(o0 == 0) + // First slice was 100 bytes → next offset rounds up to 112 (16-aligned). + #expect(o1 == 112) + #expect(o1 % 16 == 0) + } + + @Test("allocScratch updates diagnostic counters") + func allocScratchCounters() { + let d = isolatedDevice() + #expect(d.scratchAllocCount == 0) + #expect(d.scratchAllocBytes == 0) + _ = d.allocScratch(bytes: 64) + _ = d.allocScratch(bytes: 128) + #expect(d.scratchAllocCount == 2) + #expect(d.scratchAllocBytes == 192) + } + + @Test("resetScratch rewinds the slab offset to 0") + func resetScratchRewinds() { + let d = isolatedDevice() + let (_, first) = d.allocScratch(bytes: 256) + #expect(first == 0) + _ = d.allocScratch(bytes: 256) + d.resetScratch() + // After reset the next allocation starts back at offset 0. + let (_, afterReset) = d.allocScratch(bytes: 16) + #expect(afterReset == 0) + } + + @Test("withScratch activates scratch mode and resets on exit") + func withScratchScope() { + let d = isolatedDevice() + #expect(d.scratchModeActive == false) + let observed = d.withScratch { () -> Bool in + _ = d.allocScratch(bytes: 64) + return d.scratchModeActive + } + #expect(observed == true) + // Scope exit restored the flag and rewound the slab. + #expect(d.scratchModeActive == false) + let (_, offset) = d.allocScratch(bytes: 16) + #expect(offset == 0) + } + + @Test("nested withScratch does not reset the outer scope's slab") + func withScratchNested() { + let d = isolatedDevice() + d.withScratch { + _ = d.allocScratch(bytes: 64) // outer offset now 64 + d.withScratch { + // Inner scope sees scratch already active; it must NOT + // reset on exit (the outer scope still owns the slab). + #expect(d.scratchModeActive == true) + _ = d.allocScratch(bytes: 32) + } + // Outer slab still has the inner allocation accounted for — + // the next slice lands after both (64 + 32 → 16-aligned 96). + #expect(d.scratchModeActive == true) + let (_, offset) = d.allocScratch(bytes: 16) + #expect(offset == 96) + } + #expect(d.scratchModeActive == false) + } + + @Test("ensureScratchSlab grows the slab when no slices are live") + func ensureScratchSlabGrows() { + let d = isolatedDevice() + d.ensureScratchSlab(8 * 1024 * 1024) + let (buf, _) = d.allocScratch(bytes: 16) + #expect(buf.length >= 8 * 1024 * 1024) + } + + @Test("scalarBuffer caches one buffer per value") + func scalarBufferCaches() { + let d = isolatedDevice() + let a = d.scalarBuffer(1.5) + let b = d.scalarBuffer(1.5) + let c = d.scalarBuffer(2.5) + // Same value → same cached buffer; different value → distinct. + #expect(a === b) + #expect(a !== c) + // Stored value is the 4-byte little-endian Float. + let stored = a.contents().bindMemory(to: Float.self, capacity: 1).pointee + #expect(stored == 1.5) + } } diff --git a/Tests/FFAITests/KVCache/AURACodebookTests.swift b/Tests/FFAITests/KVCache/AURACodebookTests.swift index 4b82c1b9..e3b45728 100644 --- a/Tests/FFAITests/KVCache/AURACodebookTests.swift +++ b/Tests/FFAITests/KVCache/AURACodebookTests.swift @@ -185,6 +185,53 @@ struct AURASchemeTests { #expect(AURAScheme.aura4v2.valueBits == 2) #expect(AURAScheme.aura4v2.name == "aura4v2") } + + // MARK: - autoAsymmetric policy + + @Test("aura8v4 + aura8v2 canonical presets") + func k8AsymmetricPresets() { + #expect(AURAScheme.aura8v4.keyBits == 8 && AURAScheme.aura8v4.valueBits == 4) + #expect(AURAScheme.aura8v2.keyBits == 8 && AURAScheme.aura8v2.valueBits == 2) + #expect(AURAScheme.parse("aura8v4") == AURAScheme.aura8v4) + #expect(AURAScheme.parse("aura8v2") == AURAScheme.aura8v2) + } + + @Test("autoAsymmetric leaves low-GQA models untouched (gqa < 6)") + func autoAsymmetricLowGQA() { + let s = AURAScheme.autoAsymmetric( + requested: AURAScheme(keyBits: 4, valueBits: 4), gqaFactor: 2) + #expect(s.keyBits == 4 && s.valueBits == 4) + } + + @Test("autoAsymmetric leaves boundary gqa=5 untouched (threshold is ≥ 6)") + func autoAsymmetricBoundary() { + let s = AURAScheme.autoAsymmetric( + requested: AURAScheme(keyBits: 4, valueBits: 4), gqaFactor: 5) + #expect(s.keyBits == 4) + } + + @Test("autoAsymmetric upgrades K to 8 when gqa ≥ 6 (Qwen3.6-A3B gqa=8)") + func autoAsymmetricHighGQA() { + let s = AURAScheme.autoAsymmetric( + requested: AURAScheme(keyBits: 4, valueBits: 4), gqaFactor: 8) + #expect(s.keyBits == 8, "got keyBits=\(s.keyBits), expected 8") + #expect(s.valueBits == 4, "valueBits should be untouched") + } + + @Test("autoAsymmetric preserves V at user's chosen bit width") + func autoAsymmetricPreservesV() { + // aura4v2 + high GQA → aura8v2 (V kept at 2 bits). + let s = AURAScheme.autoAsymmetric( + requested: AURAScheme(keyBits: 4, valueBits: 2), gqaFactor: 8) + #expect(s.keyBits == 8 && s.valueBits == 2) + } + + @Test("autoAsymmetric is a no-op when K is already 8-bit") + func autoAsymmetricAlreadyProtected() { + let s = AURAScheme.autoAsymmetric( + requested: AURAScheme.aura8v4, gqaFactor: 8) + #expect(s == AURAScheme.aura8v4) + } } @Suite("LoadOptions — AURA") diff --git a/Tests/FFAITests/KVCache/AURACodecRoundTripTests.swift b/Tests/FFAITests/KVCache/AURACodecRoundTripTests.swift index 676ddf8b..017bbb38 100644 --- a/Tests/FFAITests/KVCache/AURACodecRoundTripTests.swift +++ b/Tests/FFAITests/KVCache/AURACodecRoundTripTests.swift @@ -196,6 +196,123 @@ struct AURACodecRoundTripTests { return sumErr / Float(headDim) } + /// Round-trip the codec on a slice with a known L2 norm and check + /// the reconstructed vector's L2 matches. This pins the **matched- + /// norm L2 correction** stage in `aura_encode_*` + `aura_dequant_*`: + /// encode stores `corrected = ||x|| / ||centroid_recon||`, dequant + /// multiplies each centroid value by `corrected`, restoring the + /// row L2 norm of the dequant to match the input. Mirrors canonical + /// TQ+'s matched-norm step (in the reference C++ implementation, ggml- + /// turbo-quant.c` line 510). + /// + /// A future refactor that drops the norm-correction stage (or + /// stores the un-corrected `||x||` directly) would silently degrade + /// quality everywhere; this test catches that by asserting the + /// recovered L2 differs from the input L2 by less than fp16 noise. + private func roundTripL2NormPreservation(scale: Float, bits: Int) -> ( + inputL2: Float, recoveredL2: Float + ) { + let device = Device.shared + let headDim = 128 + let packedWidth = AURACodebook.packedWidth(dim: headDim, bits: bits) + + // Build a non-uniform input with a known L2 = `scale`. We pick + // a deterministic pattern (sine wave + DC offset) so the test + // catches both norm-correction bugs and zero-DC pathologies. + var input = [Float](repeating: 0, count: headDim) + var rawSumSq: Float = 0 + for i in 0 ..< headDim { + input[i] = sin(Float(i) * 0.173) * 0.5 + 0.1 + rawSumSq += input[i] * input[i] + } + let rawL2 = sqrtf(rawSumSq) + let normaliseScale = scale / rawL2 + for i in 0 ..< headDim { + input[i] *= normaliseScale + } + + let inputT = Tensor.empty(shape: [1, headDim], dtype: .f32, device: device) + inputT.copyIn(from: input) + let rotationData = AURARotation.identityMatrix(dim: headDim) + let rotation = Tensor.empty(shape: [headDim, headDim], dtype: .f32, device: device) + rotation.copyIn(from: rotationData) + let centroids = AURACodebook.centroids(dim: headDim, bits: bits) + let boundaries = AURACodebook.boundaries(dim: headDim, bits: bits) + let codebookT = Tensor.empty(shape: [centroids.count], dtype: .f32, device: device) + codebookT.copyIn(from: centroids) + let boundariesT = Tensor.empty(shape: [boundaries.count], dtype: .f32, device: device) + boundariesT.copyIn(from: boundaries) + let packedT = Tensor.empty(shape: [1, packedWidth], dtype: .u32, device: device) + packedT.zero() + let normsT = Tensor.empty(shape: [1], dtype: .f32, device: device) + normsT.zero() + + let cmd1 = device.makeCommandBuffer() + Ops.auraEncode( + input: inputT, rotation: rotation, + boundaries: boundariesT, codebook: codebookT, + packedOut: packedT, normsOut: normsT, + rows: 1, dim: headDim, packedWidth: packedWidth, bits: bits, + on: cmd1) + cmd1.commit() + cmd1.waitUntilCompleted() + + let outT = Tensor.empty(shape: [1, 1, headDim], dtype: .f32, device: device) + outT.zero() + let cmd2 = device.makeCommandBuffer() + Ops.auraDequantRotated( + packed: packedT, norms: normsT, codebook: codebookT, + into: outT, + nKVHeads: 1, dim: headDim, packedWidth: packedWidth, + tokens: 1, bits: bits, on: cmd2) + cmd2.commit() + cmd2.waitUntilCompleted() + + let recon = outT.toArray(as: Float.self) + var inSq: Float = 0 + var outSq: Float = 0 + for i in 0 ..< headDim { + inSq += input[i] * input[i] + outSq += recon[i] * recon[i] + } + return (inputL2: sqrtf(inSq), recoveredL2: sqrtf(outSq)) + } + + @Test("aura4 round-trip preserves L2 norm (matched-norm correction)") + func aura4MatchedNormL2() { + autoreleasepool { + // Sweep three L2 magnitudes — small / unit / large — so the + // norm-correction stage gets exercised across the dynamic + // range a real K/V row sees post-RMSNorm. + let scales: [Float] = [0.25, 1.0, 4.0] + for scale in scales { + let r = roundTripL2NormPreservation(scale: scale, bits: 4) + let relErr = abs(r.inputL2 - r.recoveredL2) / r.inputL2 + let msg = + "aura4 L2 drift at scale=\(scale): " + + "input=\(r.inputL2) recovered=\(r.recoveredL2) " + + "rel_err=\(relErr) — matched-norm stage likely broken" + #expect(relErr < 1e-3, "\(msg)") + } + } + } + + @Test("aura8 round-trip preserves L2 norm to fp16 noise (matched-norm correction)") + func aura8MatchedNormL2() { + autoreleasepool { + let scales: [Float] = [0.25, 1.0, 4.0] + for scale in scales { + let r = roundTripL2NormPreservation(scale: scale, bits: 8) + let relErr = abs(r.inputL2 - r.recoveredL2) / r.inputL2 + let msg = + "aura8 L2 drift at scale=\(scale): " + + "input=\(r.inputL2) recovered=\(r.recoveredL2) " + + "rel_err=\(relErr) — matched-norm stage likely broken" + #expect(relErr < 1e-3, "\(msg)") + } + } + } + @Test("aura4 round-trip on a unit-norm slice") func aura4Roundtrip() { autoreleasepool { diff --git a/Tests/FFAITests/Loader/GGUFDequantTests.swift b/Tests/FFAITests/Loader/GGUFDequantTests.swift new file mode 100644 index 00000000..a9fc226d --- /dev/null +++ b/Tests/FFAITests/Loader/GGUFDequantTests.swift @@ -0,0 +1,69 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Unit tests for GGUFDequant — block-format constants + a deterministic +// Q8_0 round-trip (build a known block → dequant → verify d * qs). The +// Q2_K / IQ2_XXS bit-layout dequant is exercised exhaustively against a +// canonical oracle in metaltile's kernel correctness tests; here we +// verify the FFAI-side block constants and the Q8_0 CPU-split → GPU +// pipeline end to end. + +import Foundation +import Metal +import Testing + +@testable import FFAI + +@Suite("GGUFDequant") +struct GGUFDequantTests { + + @Test("Block-format constants match the GGUF spec") + func blockConstants() { + // Q8_0: 2-byte fp16 scale + 32 × int8 = 34 B / 32 values. + #expect(GGUFDequant.q8_0BlockBytes == 34) + #expect(GGUFDequant.q8_0BlockValues == 32) + // Q2_K: scales[16] + qs[64] + fp16 d + fp16 dmin = 84 B / 256. + #expect(GGUFDequant.q2_KBlockBytes == 84) + #expect(GGUFDequant.q2_KBlockValues == 256) + // IQ2_XXS: 66 B / 256. + #expect(GGUFDequant.iq2_xxsBlockBytes == 66) + #expect(GGUFDequant.iq2_xxsBlockValues == 256) + } + + @Test("Q8_0 round-trip: out[i] == d * qs[i]") + func q8_0RoundTrip() { + let device = Device.shared + // One block: scale d = 0.5 (exact in fp16), quants -16…+15. + let d: Float16 = 0.5 + let qs: [Int8] = (0..<32).map { Int8($0 - 16) } + + var block = Data() + withUnsafeBytes(of: d.bitPattern.littleEndian) { block.append(contentsOf: $0) } + for q in qs { block.append(UInt8(bitPattern: q)) } + #expect(block.count == GGUFDequant.q8_0BlockBytes) + + let cmd = device.makeCommandBuffer() + let out = GGUFDequant.dequantQ8_0( + rawBlocks: block, nValues: 32, outDtype: .f32, on: cmd, device: device) + cmd.commit() + cmd.waitUntilCompleted() + + let host = out.toFloatArray() + #expect(host.count == 32) + for i in 0..<32 { + let expected = Float(d) * Float(qs[i]) + #expect(abs(host[i] - expected) < 1e-3, "out[\(i)]=\(host[i]) expected \(expected)") + } + } +} diff --git a/Tests/FFAITests/Loader/GGUFReaderTests.swift b/Tests/FFAITests/Loader/GGUFReaderTests.swift new file mode 100644 index 00000000..873300b3 --- /dev/null +++ b/Tests/FFAITests/Loader/GGUFReaderTests.swift @@ -0,0 +1,207 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// GGUF v3 reader unit tests — pure-Swift parser + dequant pipeline. +// Tests cover: header parsing, KV metadata round-trip across all 13 +// scalar types + 12 array types, tensor-info table decoding, the on- +// disk → GPU-resident dequant pipeline for Q8_0 / Q2_K / IQ2_XXS. + +import Foundation +import Testing + +@testable import FFAI + +@Suite("GGUF v3 reader") +struct GGUFReaderTests { + + // ─── Header parsing ────────────────────────────────────────────── + + @Test("Rejects non-GGUF magic") + func rejectsNonGGUFMagic() throws { + var data = Data() + data.append(contentsOf: [0x46, 0x46, 0x55, 0x4c]) // "FFUL" + data.append(contentsOf: UInt32(3).leBytes) + #expect(throws: GGUFError.self) { + _ = try GGUFReader(url: URL(fileURLWithPath: "/tmp/gguf-test"), data: data) + } + } + + @Test("Rejects unsupported version (v2)") + func rejectsV2() throws { + var data = Data() + data.append(contentsOf: GGUFConstants.magic) + data.append(contentsOf: UInt32(2).leBytes) + data.append(contentsOf: UInt64(0).leBytes) // tensor_count + data.append(contentsOf: UInt64(0).leBytes) // metadata_kv_count + #expect(throws: GGUFError.self) { + _ = try GGUFReader(url: URL(fileURLWithPath: "/tmp/gguf-test"), data: data) + } + } + + @Test("Empty v3 file parses cleanly") + func emptyV3() throws { + var data = Data() + data.append(contentsOf: GGUFConstants.magic) + data.append(contentsOf: UInt32(3).leBytes) + data.append(contentsOf: UInt64(0).leBytes) + data.append(contentsOf: UInt64(0).leBytes) + let reader = try GGUFReader(url: URL(fileURLWithPath: "/tmp/empty.gguf"), data: data) + #expect(reader.version == 3) + #expect(reader.tensorInfos.isEmpty) + #expect(reader.metadata.isEmpty) + } + + // ─── Metadata round-trip ───────────────────────────────────────── + + @Test("Round-trips one of each scalar metadata type") + func metadataScalarRoundtrip() throws { + var data = Data() + data.append(contentsOf: GGUFConstants.magic) + data.append(contentsOf: UInt32(3).leBytes) + data.append(contentsOf: UInt64(0).leBytes) // tensor_count + data.append(contentsOf: UInt64(5).leBytes) // metadata_kv_count + + data.appendKVString("general.name", "Test Model") + data.appendKVU32("counter", 42) + data.appendKVF32("epsilon", 1e-6) + data.appendKVBool("opt", true) + data.appendKVI64("offset", -123456789) + + let reader = try GGUFReader(url: URL(fileURLWithPath: "/tmp/test.gguf"), data: data) + #expect(reader.metadataString("general.name") == "Test Model") + #expect(reader.metadataUInt32("counter") == 42) + #expect(reader.metadataFloat("epsilon") == 1e-6) + #expect(reader.metadataBool("opt") == true) + if case .int64(let v) = reader.metadata["offset"] { + #expect(v == -123456789) + } else { + Issue.record("offset metadata not decoded as int64") + } + } + + @Test("Round-trips a string-array (tokenizer.ggml.tokens shape)") + func metadataStringArray() throws { + var data = Data() + data.append(contentsOf: GGUFConstants.magic) + data.append(contentsOf: UInt32(3).leBytes) + data.append(contentsOf: UInt64(0).leBytes) + data.append(contentsOf: UInt64(1).leBytes) + data.appendKVStringArray("tokenizer.ggml.tokens", ["", "", "hello", "world"]) + + let reader = try GGUFReader(url: URL(fileURLWithPath: "/tmp/test.gguf"), data: data) + let tokens = reader.metadataStringArray("tokenizer.ggml.tokens") + #expect(tokens == ["", "", "hello", "world"]) + } + + // ─── Tensor info ───────────────────────────────────────────────── + + @Test("Decodes a tensor info entry and aligns the data section") + func tensorInfoAlignment() throws { + var data = Data() + data.append(contentsOf: GGUFConstants.magic) + data.append(contentsOf: UInt32(3).leBytes) + data.append(contentsOf: UInt64(1).leBytes) // 1 tensor + data.append(contentsOf: UInt64(0).leBytes) // 0 metadata + // tensor: name="w", n_dims=2, dims=[4, 8], type=Q8_0(=8), offset=0 + data.appendString("w") + data.append(contentsOf: UInt32(2).leBytes) + data.append(contentsOf: UInt64(4).leBytes) + data.append(contentsOf: UInt64(8).leBytes) + data.append(contentsOf: UInt32(GGUFTensorType.q8_0.rawValue).leBytes) + data.append(contentsOf: UInt64(0).leBytes) + // No metadata override of alignment → default 32. The + // tensor-info table ends mid-byte; tensorDataOffset rounds up. + let reader = try GGUFReader(url: URL(fileURLWithPath: "/tmp/test.gguf"), data: data) + #expect(reader.tensorInfos.count == 1) + let info = reader.tensorInfos[0] + #expect(info.name == "w") + #expect(info.dimensions == [4, 8]) + #expect(info.type == .q8_0) + #expect(info.numElements == 32) + // Q8_0 = 34 bytes per 32-value block → 1 block = 34 bytes. + #expect(info.byteLength == 34) + // The tensorDataOffset must be aligned to 32. + #expect(reader.tensorDataOffset % 32 == 0) + } + + // ─── IQ2_XXS lookup table integrity ────────────────────────────── + + @Test("iq2xxs_grid + ksigns tables have the expected sizes") + func iq2xxsTableSizes() { + #expect(GGUFIQ2XXSTables.grid.count == 2048) + #expect(GGUFIQ2XXSTables.ksigns.count == 128) + // Spot-check first row of the grid: little-endian unpack of + // 0x0808080808080808 → 8 bytes all = 0x08. + for i in 0..<8 { #expect(GGUFIQ2XXSTables.grid[i] == 0x08) } + // Spot-check ksigns: byte 0 = 0, byte 1 = 129, byte 127 = 255. + #expect(GGUFIQ2XXSTables.ksigns[0] == 0) + #expect(GGUFIQ2XXSTables.ksigns[1] == 129) + #expect(GGUFIQ2XXSTables.ksigns[127] == 255) + } +} + +// ─── Helpers ───────────────────────────────────────────────────────── + +extension Data { + fileprivate mutating func appendString(_ s: String) { + let bytes = Array(s.utf8) + append(contentsOf: UInt64(bytes.count).leBytes) + append(contentsOf: bytes) + } + + fileprivate mutating func appendKVString(_ key: String, _ value: String) { + appendString(key) + append(contentsOf: UInt32(GGUFValueType.string.rawValue).leBytes) + appendString(value) + } + + fileprivate mutating func appendKVU32(_ key: String, _ value: UInt32) { + appendString(key) + append(contentsOf: UInt32(GGUFValueType.uint32.rawValue).leBytes) + append(contentsOf: value.leBytes) + } + + fileprivate mutating func appendKVF32(_ key: String, _ value: Float) { + appendString(key) + append(contentsOf: UInt32(GGUFValueType.float32.rawValue).leBytes) + append(contentsOf: value.bitPattern.leBytes) + } + + fileprivate mutating func appendKVBool(_ key: String, _ value: Bool) { + appendString(key) + append(contentsOf: UInt32(GGUFValueType.bool.rawValue).leBytes) + append(value ? 1 : 0) + } + + fileprivate mutating func appendKVI64(_ key: String, _ value: Int64) { + appendString(key) + append(contentsOf: UInt32(GGUFValueType.int64.rawValue).leBytes) + append(contentsOf: UInt64(bitPattern: value).leBytes) + } + + fileprivate mutating func appendKVStringArray(_ key: String, _ values: [String]) { + appendString(key) + append(contentsOf: UInt32(GGUFValueType.array.rawValue).leBytes) + append(contentsOf: UInt32(GGUFValueType.string.rawValue).leBytes) + append(contentsOf: UInt64(values.count).leBytes) + for v in values { appendString(v) } + } +} + +extension FixedWidthInteger { + fileprivate var leBytes: [UInt8] { + var v = self.littleEndian + return withUnsafeBytes(of: &v) { Array($0) } + } +} diff --git a/Tests/FFAITests/Ops/OpsSpecialPathTests.swift b/Tests/FFAITests/Ops/OpsSpecialPathTests.swift index 3e29193c..f852b2f6 100644 --- a/Tests/FFAITests/Ops/OpsSpecialPathTests.swift +++ b/Tests/FFAITests/Ops/OpsSpecialPathTests.swift @@ -471,6 +471,163 @@ struct OpsSpecialPathTests { } } + @Test("moeDownWeightedSum6 f32 — fused six GEMVs match CPU reference") + func moeDownWeightedSum6F32() { + autoreleasepool { + runMoeDownWeightedSum6Reference(dtype: .f32) + } + } + + @Test("moeDownWeightedSum6 f16 — fused six GEMVs match CPU reference") + func moeDownWeightedSum6F16() { + autoreleasepool { + runMoeDownWeightedSum6Reference(dtype: .f16) + } + } + + @Test("moeDownWeightedSum6 f16 — matches unfused GPU chain") + func moeDownWeightedSum6F16MatchesUnfusedGpuChain() { + autoreleasepool { + runMoeDownWeightedSum6AgainstUnfused(dtype: .f16, m: 8, k: 2048) + } + } + + private func runMoeDownWeightedSum6Reference(dtype: DType) { + let m = 3 + let k = 5 + let weightsHost: [Float] = [0.5, -0.75, 0.125, 1.5, -0.25, 0.875] + let baseHost: [Float] = [1.0, -2.0, 0.5] + + var downRefs: [[Float]] = [] + var innerRefs: [[Float]] = [] + var downs: [Tensor] = [] + var inners: [Tensor] = [] + + for slot in 0 ..< 6 { + let downHost = (0 ..< (m * k)).map { idx -> Float in + let row = idx / k + let col = idx % k + return Float((slot + 1) * (row + 1) - (col + 1)) * 0.125 + } + let innerHost = (0 ..< k).map { col -> Float in + Float((slot + 2) * (col + 1)) * 0.0625 - Float(slot) * 0.05 + } + downRefs.append(dtype == .f16 ? downHost.map { Float(Float16($0)) } : downHost) + innerRefs.append(dtype == .f16 ? innerHost.map { Float(Float16($0)) } : innerHost) + + let down = Tensor.empty(shape: [m, k], dtype: dtype) + let inner = Tensor.empty(shape: [k], dtype: dtype) + if dtype == .f16 { + down.copyIn(from: downHost.map { Float16($0) }) + inner.copyIn(from: innerHost.map { Float16($0) }) + } else { + down.copyIn(from: downHost) + inner.copyIn(from: innerHost) + } + downs.append(down) + inners.append(inner) + } + + let weights = Tensor.empty(shape: [6], dtype: .f32) + weights.copyIn(from: weightsHost) + + let accum = Tensor.empty(shape: [m], dtype: dtype) + if dtype == .f16 { + accum.copyIn(from: baseHost.map { Float16($0) }) + } else { + accum.copyIn(from: baseHost) + } + + var expected = dtype == .f16 ? baseHost.map { Float(Float16($0)) } : baseHost + for row in 0 ..< m { + for slot in 0 ..< 6 { + var dot = Float(0) + for col in 0 ..< k { + dot += downRefs[slot][row * k + col] * innerRefs[slot][col] + } + expected[row] += weightsHost[slot] * dot + } + } + + runAndWait { cb in + Ops.moeDownWeightedSum6(downs: downs, inners: inners, weights: weights, accum: accum, on: cb) + } + + let got: [Float] = dtype == .f16 + ? accum.toArray(as: Float16.self).map { Float($0) } + : accum.toArray(as: Float.self) + let tolerance: Float = dtype == .f16 ? 2e-2 : 1e-4 + for row in 0 ..< m { + #expect(abs(got[row] - expected[row]) < tolerance, "row \(row): got \(got[row]), expected \(expected[row])") + } + } + + private func runMoeDownWeightedSum6AgainstUnfused(dtype: DType, m: Int, k: Int) { + let weightsHost: [Float] = [0.5, -0.75, 0.125, 1.5, -0.25, 0.875] + let baseHost = (0 ..< m).map { Float($0 % 7) * 0.125 - 0.25 } + var downs: [Tensor] = [] + var inners: [Tensor] = [] + for slot in 0 ..< 6 { + let downHost = (0 ..< (m * k)).map { idx -> Float in + let row = idx / k + let col = idx % k + return Float(((slot + 3) * (row + 5) + (col % 29)) % 31 - 15) * 0.2 + } + let innerHost = (0 ..< k).map { col -> Float in + Float(((slot + 1) * (col % 37)) % 23 - 11) * 0.2 + } + let down = Tensor.empty(shape: [m, k], dtype: dtype) + let inner = Tensor.empty(shape: [k], dtype: dtype) + if dtype == .f16 { + down.copyIn(from: downHost.map { Float16($0) }) + inner.copyIn(from: innerHost.map { Float16($0) }) + } else { + down.copyIn(from: downHost) + inner.copyIn(from: innerHost) + } + downs.append(down) + inners.append(inner) + } + + let weights = Tensor.empty(shape: [6], dtype: .f32) + weights.copyIn(from: weightsHost) + let accumFused = Tensor.empty(shape: [m], dtype: dtype) + let accumRef = Tensor.empty(shape: [m], dtype: dtype) + if dtype == .f16 { + let base = baseHost.map { Float16($0) } + accumFused.copyIn(from: base) + accumRef.copyIn(from: base) + } else { + accumFused.copyIn(from: baseHost) + accumRef.copyIn(from: baseHost) + } + + runAndWait { cb in + for slot in 0 ..< 6 { + let expertOut = Ops.gemv(weight: downs[slot], input: inners[slot], on: cb) + let wT = Tensor.filled(weightsHost[slot], shape: [m], dtype: dtype) + let scaled = Ops.mul(expertOut, wT, on: cb) + _ = Ops.add(accumRef, scaled, on: cb, into: accumRef) + } + Ops.moeDownWeightedSum6( + downs: downs, inners: inners, weights: weights, accum: accumFused, on: cb) + } + + let got: [Float] + let ref: [Float] + if dtype == .f16 { + got = accumFused.toArray(as: Float16.self).map { Float($0) } + ref = accumRef.toArray(as: Float16.self).map { Float($0) } + } else { + got = accumFused.toArray(as: Float.self) + ref = accumRef.toArray(as: Float.self) + } + let tolerance: Float = dtype == .f16 ? 1e-1 : 1e-4 + for row in 0 ..< m { + #expect(abs(got[row] - ref[row]) < tolerance, "row \(row): fused \(got[row]), ref \(ref[row])") + } + } + // MARK: - sdpaDecode2Pass @Test("sdpaDecode2Pass f32 — blocks=32 matches single-pass sdpaDecode") diff --git a/Tests/FFAITests/Telemetry/KLDivergenceTests.swift b/Tests/FFAITests/Telemetry/KLDivergenceTests.swift new file mode 100644 index 00000000..6971dcbc --- /dev/null +++ b/Tests/FFAITests/Telemetry/KLDivergenceTests.swift @@ -0,0 +1,184 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// KLDivergence math — unit tests on synthetic logit pairs to pin the +// numerical invariants of the regression gate for TQ+/AURA quality +// ports. Mirrors the canonical TQ+ harness output format. + +import Foundation +import Testing + +@testable import FFAI + +@Suite("KLDivergence — per-position + aggregate metrics") +struct KLDivergenceTests { + + // MARK: - positionMetrics — invariants + + @Test("identical logits → kld == 0 and matching argmax") + func identicalLogitsZeroKld() { + let logits: [Float] = [1.0, 2.0, 0.5, -1.0, 3.0] + let m = KLDivergence.positionMetrics( + baselineLogits: logits, codecLogits: logits, nextTokenId: 2) + #expect(m.kld < 1e-12, "got kld=\(m.kld), expected 0") + #expect(m.baselineTopIdx == 4) + #expect(m.codecTopIdx == 4) + // Same-prob check on the target token (index 2). + #expect(abs(m.baselineNextProb - m.codecNextProb) < 1e-12) + } + + @Test("shifted-constant logits → kld == 0 (softmax shift-invariance)") + func shiftInvariance() { + let baseline: [Float] = [0.0, 1.0, 2.0, 3.0] + let shifted: [Float] = [10.0, 11.0, 12.0, 13.0] + let m = KLDivergence.positionMetrics( + baselineLogits: baseline, codecLogits: shifted, nextTokenId: 1) + #expect(m.kld < 1e-10, "got kld=\(m.kld), expected 0") + #expect(m.baselineTopIdx == m.codecTopIdx) + } + + @Test("uniform baseline + peaked codec → kld matches closed-form value") + func uniformVsPeakedClosedForm() { + // baseline: uniform over 4 tokens → P_base(i) = 1/4 for all i. + let baseline: [Float] = [0, 0, 0, 0] + // codec: heavily peaks at index 0. logits [3,0,0,0] → + // softmax = (e^3, 1, 1, 1) / (e^3 + 3). + let codec: [Float] = [3, 0, 0, 0] + let m = KLDivergence.positionMetrics( + baselineLogits: baseline, codecLogits: codec, nextTokenId: 0) + // KL(U || codec) = -H(U) - E_U[log P_codec] + // = log(4) + (-1/4) * sum_i log P_codec(i) + // Closed-form by hand: + let s = exp(3.0) + 3.0 + let logQ0 = 3.0 - log(s) + let logQother = 0.0 - log(s) + let expectedKld = + -log(4.0) - 0.25 * (logQ0 + 3 * logQother) + // (KL = E_P log(P/Q) = sum_i P_i (log P_i - log Q_i)) + // P_i = 1/4, log P_i = -log 4 for all i. + // KL = -log 4 - 1/4 * sum_i log Q_i = expected above. + #expect( + abs(m.kld - expectedKld) < 1e-9, + "got \(m.kld) expected \(expectedKld)") + #expect(m.kld > 0) // distributions differ ⇒ kld positive + } + + @Test("argmax indices reflect the actual logit maximums") + func argmaxDetection() { + let baseline: [Float] = [0, 5, 0, 0, 0] // argmax = 1 + let codec: [Float] = [0, 0, 0, 4, 0] // argmax = 3 + let m = KLDivergence.positionMetrics( + baselineLogits: baseline, codecLogits: codec, nextTokenId: 0) + #expect(m.baselineTopIdx == 1) + #expect(m.codecTopIdx == 3) + } + + @Test("baselineNextProb + codecNextProb pinned at the supplied next-token-id slot") + func nextProbSlot() { + let baseline: [Float] = [10, 0, 0, 0] + let codec: [Float] = [0, 0, 0, 10] + let m = KLDivergence.positionMetrics( + baselineLogits: baseline, codecLogits: codec, nextTokenId: 0) + // Baseline strongly prefers index 0 ≈ probability close to 1. + #expect(m.baselineNextProb > 0.99) + // Codec strongly prefers index 3, so its probability for + // index 0 is near 0. + #expect(m.codecNextProb < 0.001) + } + + // MARK: - aggregate — invariants + + @Test("aggregate matches llama-perplexity --kl-divergence summary fields") + func aggregateFields() { + // Build 100 synthetic positions with controlled KLDs: + // 99 positions at kld=0.01, 1 position at kld=10. Should give + // mean ≈ 0.11, median = 0.01, max = 10, sameTop = 1.0. + var positions: [KLDivergence.PositionMetrics] = [] + // 99 small-drift positions: matching argmax, low kld. + for _ in 0 ..< 99 { + positions.append( + KLDivergence.PositionMetrics( + kld: 0.01, + baselineTopIdx: 0, codecTopIdx: 0, + baselineNextProb: 0.5, codecNextProb: 0.49)) + } + // 1 catastrophic position. + positions.append( + KLDivergence.PositionMetrics( + kld: 10.0, + baselineTopIdx: 0, codecTopIdx: 0, + baselineNextProb: 0.5, codecNextProb: 0.01)) + let agg = KLDivergence.aggregate(positions) + #expect(agg.nPositions == 100) + #expect(abs(agg.meanKld - 0.1099) < 1e-9) + #expect(agg.medianKld == 0.01) + #expect(agg.maxKld == 10.0) + // Linear-interp quantile on a 100-element list: + // 99% → pos = 99 * 0.99 = 98.01 → + // sorted[98] + 0.01 * (sorted[99] - sorted[98]) + // = 0.01 + 0.01 * (10 - 0.01) = 0.1099 + // 99.9% → pos = 99 * 0.999 = 98.901 → + // sorted[98] + 0.901 * (sorted[99] - sorted[98]) + // = 0.01 + 0.901 * 9.99 ≈ 9.0109 + #expect( + abs(agg.kld99 - 0.1099) < 1e-9, + "got kld99=\(agg.kld99)") + #expect( + abs(agg.kld999 - 9.0109) < 1e-3, + "got kld999=\(agg.kld999)") + #expect(agg.sameTopFraction == 1.0) + // meanDp = (99 * 0.01 + 1 * 0.49) / 100 = 0.0148 + #expect(abs(agg.meanDp - 0.0148) < 1e-9) + #expect(agg.maxDp == 0.49) + } + + @Test("aggregate same-top fraction tracks argmax mismatches") + func aggregateSameTopFraction() { + var positions: [KLDivergence.PositionMetrics] = [] + // 7 of 10 positions: argmax matches. + for _ in 0 ..< 7 { + positions.append( + KLDivergence.PositionMetrics( + kld: 0.001, + baselineTopIdx: 1, codecTopIdx: 1, + baselineNextProb: 0.5, codecNextProb: 0.5)) + } + // 3 of 10: codec picks something else. + for _ in 0 ..< 3 { + positions.append( + KLDivergence.PositionMetrics( + kld: 1.0, + baselineTopIdx: 1, codecTopIdx: 5, + baselineNextProb: 0.5, codecNextProb: 0.1)) + } + let agg = KLDivergence.aggregate(positions) + #expect(abs(agg.sameTopFraction - 0.7) < 1e-12) + } + + @Test("summaryLine includes the canonical TQ+ harness fields") + func summaryLineFormat() { + let positions = (0 ..< 5).map { _ in + KLDivergence.PositionMetrics( + kld: 0.1, + baselineTopIdx: 0, codecTopIdx: 0, + baselineNextProb: 0.5, codecNextProb: 0.5) + } + let agg = KLDivergence.aggregate(positions) + let line = KLDivergence.summaryLine(label: "q8_0/turbo3", metrics: agg) + #expect(line.contains("q8_0/turbo3")) + #expect(line.contains("mean_kld=")) + #expect(line.contains("same_top=")) + #expect(line.contains("n=5")) + } +} diff --git a/Tests/FFAITests/TensorTests.swift b/Tests/FFAITests/TensorTests.swift index 939de367..be0ee16d 100644 --- a/Tests/FFAITests/TensorTests.swift +++ b/Tests/FFAITests/TensorTests.swift @@ -29,6 +29,50 @@ struct TensorTests { #expect(t.buffer.length >= 128) } + @Test("empty routes through the scratch slab when scratchModeActive") + func emptyRoutesThroughScratch() { + // Isolated device so we don't perturb Device.shared's scratch + // state, which other parallel suites allocate against. + let d = Device( + mtlDevice: Device.shared.mtlDevice, + commandQueue: Device.shared.commandQueue) + + // Outside a scratch scope: fresh per-tensor buffer, offset 0, + // and no scratch accounting. + let plain = Tensor.empty(shape: [16], dtype: .f32, device: d) + #expect(plain.offset == 0) + #expect(d.scratchAllocCount == 0) + + // Inside a scratch scope: two tensors share one slab buffer at + // bumping offsets. + var a: Tensor! + var b: Tensor! + d.withScratch { + a = Tensor.empty(shape: [16], dtype: .f32, device: d) // 64 bytes + b = Tensor.empty(shape: [4], dtype: .f32, device: d) // 16 bytes + } + #expect(a.buffer === b.buffer) + #expect(a.offset == 0) + #expect(b.offset == 64) // 64 already 16-aligned + #expect(d.scratchAllocCount == 2) + // The plain buffer above is NOT the scratch slab. + #expect(plain.buffer !== a.buffer) + } + + @Test("Tensor.scratch wraps a slab slice directly") + func scratchWrapsSlabSlice() { + let d = Device( + mtlDevice: Device.shared.mtlDevice, + commandQueue: Device.shared.commandQueue) + let s0 = Tensor.scratch(shape: [8], dtype: .f32, device: d) + let s1 = Tensor.scratch(shape: [8], dtype: .f32, device: d) + #expect(s0.buffer === s1.buffer) + #expect(s0.offset == 0) + #expect(s1.offset == 32) // 8 × 4 bytes, already 16-aligned + #expect(s0.shape == [8]) + #expect(s0.dtype == .f32) + } + @Test("reshape preserves element count and storage") func reshape() { let t = Tensor.empty(shape: [2, 6], dtype: .f32) diff --git a/Tests/MetalTileSwiftTests/PSOCacheTests.swift b/Tests/MetalTileSwiftTests/PSOCacheTests.swift index e98ef3d1..1f067c6f 100644 --- a/Tests/MetalTileSwiftTests/PSOCacheTests.swift +++ b/Tests/MetalTileSwiftTests/PSOCacheTests.swift @@ -24,6 +24,10 @@ // lookup in `PSOCache.lookup`). import Foundation +// MTLComputePipelineState (and most Metal protocols) are not declared +// Sendable in the Metal headers. `@preconcurrency import Metal` lets +// the cross-task `withTaskGroup(of:)` below compile on Swift 6 strict +// concurrency without a per-call `@unchecked Sendable` wrapper. @preconcurrency import Metal import Testing diff --git a/Tests/ModelIntegrationTests/Loader/GGUFLoaderTests.swift b/Tests/ModelIntegrationTests/Loader/GGUFLoaderTests.swift new file mode 100644 index 00000000..c4bddb12 --- /dev/null +++ b/Tests/ModelIntegrationTests/Loader/GGUFLoaderTests.swift @@ -0,0 +1,124 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// GGUF loader integration — open a real GGUF, read its metadata, decode +// a representative tensor of each quant type, and build a tokenizer from +// the embedded vocab. Focused on the loader (`GGUFTensorBundle`), not on +// any one model family. +// +// Skipped by default. Set `$FFAI_GGUF_PATH` to any GGUF checkpoint dir +// (a small one is ideal — these tests only exercise the parser + dequant +// pipeline). Falls back to the DSv4-Flash path (`$FFAI_DSV4_GGUF_PATH` / +// `~/models/deepseek-v4-flash`) when set, so it doubles as DSv4 coverage +// where that large file is staged. + +import Foundation +import Testing +import Tokenizers + +@testable import FFAI + +@Suite("GGUF loader integration", .serialized) +struct GGUFLoaderTests { + + private var modelPath: String? { + let env = ProcessInfo.processInfo.environment + let candidate = + env["FFAI_GGUF_PATH"] + ?? env["FFAI_DSV4_GGUF_PATH"] + ?? NSString("~/models/deepseek-v4-flash").expandingTildeInPath + guard FileManager.default.fileExists(atPath: candidate) else { return nil } + return candidate + } + + private func open() throws -> GGUFTensorBundle? { + guard let dir = modelPath else { + print("GGUFLoaderTests: skipping (no GGUF at FFAI_GGUF_PATH / FFAI_DSV4_GGUF_PATH)") + return nil + } + return try GGUFTensorBundle(directory: URL(fileURLWithPath: dir)) + } + + @Test("Open a GGUF: header + architecture + non-trivial tensor table") + func opensCheckpoint() throws { + guard let bundle = try open() else { return } + #expect(bundle.architecture != nil, "GGUF must carry general.architecture") + #expect(bundle.reader.tensorInfos.count > 0, "tensor info table is empty") + } + + @Test("Dequant a Q8_0 tensor → correct shape, finite, bounded") + func dequantQ8_0() throws { + guard let bundle = try open() else { return } + guard let info = bundle.reader.tensorInfos.first(where: { $0.type == .q8_0 }) else { + print("GGUFLoaderTests: no Q8_0 tensors — skipping") + return + } + try assertDequantSane(bundle, info) + } + + @Test("Dequant a Q2_K tensor → correct shape, finite, bounded") + func dequantQ2_K() throws { + guard let bundle = try open() else { return } + guard let info = bundle.reader.tensorInfos.first(where: { $0.type == .q2_K }) else { + print("GGUFLoaderTests: no Q2_K tensors — skipping") + return + } + try assertDequantSane(bundle, info) + } + + @Test("Dequant an IQ2_XXS tensor → correct shape, finite, bounded, non-zero") + func dequantIQ2_XXS() throws { + guard let bundle = try open() else { return } + guard let info = bundle.reader.tensorInfos.first(where: { $0.type == .iq2_xxs }) else { + print("GGUFLoaderTests: no IQ2_XXS tensors — skipping") + return + } + try assertDequantSane(bundle, info, requireNonZero: true) + } + + @Test("Build a tokenizer from the GGUF metadata block") + func buildTokenizer() throws { + guard let bundle = try open() else { return } + let tokenizer: any Tokenizer + do { + tokenizer = try GGUFTokenizerAdapter.build(reader: bundle.reader) + } catch GGUFTokenizerAdapter.Error.unsupportedKind(let kind) { + print("GGUFLoaderTests: tokenizer kind '\(kind)' not in the supported BPE set yet — skipping") + return + } + let ids = tokenizer.encode(text: "The history of the printing press began when") + #expect(!ids.isEmpty, "encode returned an empty token list") + #expect(!tokenizer.decode(tokens: ids).isEmpty, "decode returned an empty string") + } + + /// Dequant a tensor to f32 and assert shape match + finite, bounded + /// values over a sample (exact numerical checks live in the metaltile + /// kernel correctness tests; this is the loader-pipeline sanity). + private func assertDequantSane( + _ bundle: GGUFTensorBundle, _ info: GGUFTensorInfo, requireNonZero: Bool = false + ) throws { + let t = try bundle.tensor(named: info.name, outDtype: .f32) + #expect(t.shape.map { Int($0) } == info.dimensions.map { Int($0) }) + let sample = t.toArray(as: Float.self).prefix(1024) + var anyNonZero = false + for v in sample { + #expect(v.isFinite, "\(info.type) dequant produced non-finite value") + #expect(abs(v) < 1e3, "\(info.type) dequant magnitude unreasonable (\(v))") + if v != 0 { anyNonZero = true } + } + if requireNonZero { + #expect(anyNonZero, "\(info.type) dequant produced all-zero sample") + } + } +} diff --git a/Tests/ModelIntegrationTests/Telemetry/AuraDecodeBenchIntegrationTests.swift b/Tests/ModelIntegrationTests/Telemetry/AuraDecodeBenchIntegrationTests.swift new file mode 100644 index 00000000..701eaeff --- /dev/null +++ b/Tests/ModelIntegrationTests/Telemetry/AuraDecodeBenchIntegrationTests.swift @@ -0,0 +1,330 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// AURA decode-tps + cache-memory bench. Quantifies the win unlocked by +// wiring the compressed flash decode path (`AURADecodePath.compressed`, +// `Ops.auraFlashSdpa`) in `Qwen3Layer.forward` and gives a numeric +// baseline for future TQ+ ports (P1c per-group fp8 scale, etc.) to +// measure against. +// +// The `.compressed` path scores Q directly against packed K codes and +// dequants V per-tile on chip — no `[nKVHeads, maxSeq, headDim]` mirror +// buffer is materialised. Two measurable wins: +// +// 1. Decode tps at varied KV lengths — pays back the kernel-launch +// cost of `aura_flash_sdpa` over the dequant-mirror's +// `auraDequantRotated → sdpaDecode` two-encoder pair, dominant at +// long context. +// +// 2. Cache memory — the per-layer shared working buffer disappears. +// For aura4v4 at headDim=128, nKVHeads=8, maxSeq=1024 that's +// `8 * 1024 * 128 * 2 = 2 MiB` × n_layers saved. +// +// Quality parity is covered by `AuraKLDIntegrationTests` — these +// benches assume the wiring is correct and just measure perf. + +import Foundation +import TestHelpers +import Testing + +@testable import FFAI + +/// Model path **must** be supplied via the `FFAI_AURA_BENCH_MODEL_PATH` +/// env var. There is no machine-specific default — the prior hardcoded +/// `/Users/tom/models/...` was a footgun for anyone else running the +/// suite. When the env var is unset OR the path doesn't exist, every +/// test in the suite prints a `[skipped]` line and returns 0 so CI +/// passes cleanly on contributors who don't have the model staged. +/// +/// Recommended models for `blockSize` validation (per @ekryski's PR #15 +/// review): Qwen3-1.7B-4bit or Qwen3.5-2B-4bit / Qwen3-4B-4bit so the +/// measurement isn't anchored to a single small-model variance regime. +private let qwen3LocalPath: String? = + ProcessInfo.processInfo.environment["FFAI_AURA_BENCH_MODEL_PATH"] + +/// KV sweep set, overridable via `FFAI_AURA_BENCH_KV_LENGTHS` +/// (comma-separated). Defaults to {256, 1024, 4096} so `blockSizeSweep` +/// crosses the threshold where bs=64 vs bs=128 stops mattering and the +/// long-context regime starts to dominate. Longer points (e.g. 16384) +/// can be opted into via the env var when a larger model is loaded. +private let benchKVLengths: [Int] = { + if let s = ProcessInfo.processInfo.environment["FFAI_AURA_BENCH_KV_LENGTHS"] { + let parsed = s.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if !parsed.isEmpty { return parsed } + } + return [256, 1024, 4096] +}() + +/// Returns the resolved model path if both the env var is set and the +/// path exists on disk; otherwise prints a skip line and returns nil. +/// Wraps every test entry-point so a missing model is a quiet skip, +/// never a failure. +private func benchModelPath(_ testName: String) -> String? { + guard let p = qwen3LocalPath else { + print("\(testName) skipped: FFAI_AURA_BENCH_MODEL_PATH env var not set") + return nil + } + guard FileManager.default.fileExists(atPath: p) else { + print("\(testName) skipped: \(p) not found") + return nil + } + return p +} + +@Suite("AURA decode bench — compressed vs dequant-mirror", .serialized) +struct AuraDecodeBenchIntegrationTests { + + /// Same diverse prompt as the KLD harness — lets us cross-reference + /// quality + perf numbers from the same workload. + private static let samplePrompt = + "The history of the printing press began when European craftsmen " + + "combined movable metal type with oil-based ink and a wooden screw " + + "press. The first printed book was the Gutenberg Bible in 1455. " + + "Compute the next item in this sequence: 2, 4, 8, 16, " + + /// Decode-tps bench at a fixed KV length. `decodePath` selects + /// compressed flash vs dequant-mirror. `modelPath` is the on-disk + /// model directory (passed from each test entry-point so a missing + /// `FFAI_AURA_BENCH_MODEL_PATH` skips quietly instead of crashing + /// here). Returns the median tps over `nRuns` warmed runs of + /// `nSteps` decode tokens each. + private func runDecodeTpsBench( + modelPath: String, + decodePath: AURADecodePath, kvLength: Int, nRuns: Int, nSteps: Int + ) async throws -> Double { + var optsBuilder = LoadOptions() + optsBuilder.prewarm = false + optsBuilder.kvCache = .auraQuantized(scheme: .default) + optsBuilder.auraDecodePath = decodePath + let opts = optsBuilder + + let m = try await ModelLoadLock.shared.loadSerially { + try await Model.load(modelPath, options: opts) + } + let qwen = try #require(m.qwen3, "expected Qwen3Model engine") + + // Build a token sequence long enough to seed the cache to + // `kvLength` positions before the timed decode begins. + let promptTokens = m.tokenizer.encode(text: Self.samplePrompt) + precondition( + kvLength >= promptTokens.count, + "kvLength \(kvLength) must be ≥ promptTokens.count \(promptTokens.count)") + var seedTokens = promptTokens + // Pad with token-0 to reach the requested kv length. Decode + // quality of padding doesn't matter — we only need the cache + // populated. + while seedTokens.count < kvLength { + seedTokens.append(0) + } + + // Two warmups so the first timed run isn't paying for shader + // compilation / first-touch effects. + for _ in 0 ..< 2 { + let warmCaches = qwen.makeLayerCaches(maxSeq: max(kvLength + nSteps + 32, 1024)) + for (i, tok) in seedTokens.enumerated() { + _ = qwen.forward(tokenId: tok, position: i, caches: warmCaches) + } + for j in 0 ..< 4 { + _ = qwen.forward( + tokenId: 0, position: kvLength + j, caches: warmCaches) + } + } + + // Timed runs. + var runs: [Double] = [] + for _ in 0 ..< nRuns { + let caches = qwen.makeLayerCaches(maxSeq: max(kvLength + nSteps + 32, 1024)) + for (i, tok) in seedTokens.enumerated() { + _ = qwen.forward(tokenId: tok, position: i, caches: caches) + } + let t0 = Date() + for j in 0 ..< nSteps { + _ = qwen.forward( + tokenId: 0, position: kvLength + j, caches: caches) + } + runs.append(Date().timeIntervalSince(t0)) + } + runs.sort() + let median = runs[runs.count / 2] + return Double(nSteps) / median + } + + /// Side-by-side bench at one KV length, prints both numbers + the + /// ratio. **Informational + catastrophic-regression gate only** — + /// the metaltile flash kernel is currently + /// single-simdgroup-per-query (see `aura_flash_sdpa.rs` header: + /// "token-parallelism is a perf follow-up"), so `.compressed` is + /// a known perf regression at all KV lengths vs `.dequantMirror`. + /// Gate is set wide enough to catch a kernel meltdown (ratio + /// dropping below 0.2 means something far worse than the known + /// kernel-layout gap), not to enforce parity. + private func runComparison( + modelPath: String, + kvLength: Int, nRuns: Int = 5, nSteps: Int = 32, + catastrophicFloor: Double = 0.20 + ) async throws { + let tpsMirror = try await runDecodeTpsBench( + modelPath: modelPath, + decodePath: .dequantMirror, kvLength: kvLength, + nRuns: nRuns, nSteps: nSteps) + let tpsCompressed = try await runDecodeTpsBench( + modelPath: modelPath, + decodePath: .compressed, kvLength: kvLength, + nRuns: nRuns, nSteps: nSteps) + let ratio = tpsCompressed / tpsMirror + let speedupPct = (ratio - 1.0) * 100.0 + print( + "[aura4v4 KV=\(kvLength)] " + + "dequantMirror=\(String(format: "%.2f", tpsMirror)) tps " + + "compressed=\(String(format: "%.2f", tpsCompressed)) tps " + + "ratio=\(String(format: "%.3f", ratio))× " + + "(\(String(format: "%+.1f", speedupPct))%)") + // Catastrophic-regression gate only. The known kernel-layout + // gap is roughly -18% at KV=64 → -58% at KV=1024 on M5 Max + // (single-simdgroup-per-query vs sdpaDecode's parallel + // shape). A ratio below 0.2 means something far worse than + // that — probably a bug worth catching. + let ratioStr = String(format: "%.3f", ratio) + let msg = + "compressed/dequantMirror ratio \(ratioStr) below " + + "catastrophic floor \(catastrophicFloor) at KV=\(kvLength)" + #expect(ratio >= catastrophicFloor, "\(msg)") + } + + @Test("decode tps — KV=64 (short context)") + func decodeTpsKV64() async throws { + guard let mp = benchModelPath("decodeTpsKV64") else { return } + try await runComparison(modelPath: mp, kvLength: 64) + } + + @Test("decode tps — KV=256 (medium context)") + func decodeTpsKV256() async throws { + guard let mp = benchModelPath("decodeTpsKV256") else { return } + try await runComparison(modelPath: mp, kvLength: 256) + } + + @Test("decode tps — KV=1024 (long context, mirror buffer largest)") + func decodeTpsKV1024() async throws { + guard let mp = benchModelPath("decodeTpsKV1024") else { return } + // At KV=1024 the dequant-mirror writes a 2 MiB f16 buffer per + // layer per token. Compressed flash reads packed K codes + // directly. This is where the win should be visible. + try await runComparison(modelPath: mp, kvLength: 1024) + } + + /// 2-pass `blockSize` sweep. Varies + /// `AuraFlashScratchCache.blockSizeOverride` across {32, 64, 128, + /// 256} at KV=256 / 1024 / 4096 to see which tile size best + /// saturates the M5 Max for the 2-pass FA-2 dispatch. The default + /// is 64 (matches `Ops.sdpaDecode2Pass`); this prints a per-cell + /// tps table so we can tune without re-running the full bench. + /// + /// At KV=64 only `bs=32` would give >1 block, so we skip it — the + /// sweep matters most where token-parallelism actually has work to + /// distribute. + @Test("decode tps — blockSize sweep at KV=256 / 1024 / 4096 (2-pass only)") + func blockSizeSweep() async throws { + guard let mp = benchModelPath("blockSizeSweep") else { return } + let blockSizes = [32, 64, 128, 256] + let kvLengths = benchKVLengths + var results: [(kv: Int, bs: Int, tps: Double)] = [] + // swift-testing captures stdout per-test-method and only flushes + // it on test return, so a long-running sweep produces zero + // visible output for tens of minutes. Mirror every cell line to + // a side-channel file (env-overridable) so progress is tail-able + // in real time: `tail -f $FFAI_AURA_BENCH_LOG`. + let logPath = + ProcessInfo.processInfo.environment["FFAI_AURA_BENCH_LOG"] + ?? "/tmp/ffai-aura-bench.log" + let logURL = URL(fileURLWithPath: logPath) + func emit(_ line: String) { + print(line) + if let data = (line + "\n").data(using: .utf8) { + if FileManager.default.fileExists(atPath: logPath), + let handle = try? FileHandle(forWritingTo: logURL) + { + defer { try? handle.close() } + _ = try? handle.seekToEnd() + try? handle.write(contentsOf: data) + } else { + try? data.write(to: logURL, options: .atomic) + } + } + } + emit("\n=== blockSize sweep START — model=\(mp), KV=\(kvLengths), bs=\(blockSizes) ===") + for kv in kvLengths { + for bs in blockSizes { + AuraFlashScratchCache.blockSizeOverride = bs + let cellStart = Date() + let tps = try await runDecodeTpsBench( + modelPath: mp, + decodePath: .compressed, kvLength: kv, + nRuns: 3, nSteps: 24) + let cellSecs = Date().timeIntervalSince(cellStart) + results.append((kv, bs, tps)) + emit( + "[blockSize sweep] KV=\(kv) bs=\(bs) " + + "compressed=\(String(format: "%.2f", tps)) tps " + + "(cell \(String(format: "%.1f", cellSecs))s)") + } + } + AuraFlashScratchCache.blockSizeOverride = nil + emit("\n=== blockSize sweep summary (model=\(mp)) ===") + emit("KV \\ bs 32 64 128 256") + for kv in kvLengths { + let row = + results.filter { $0.kv == kv } + .map { String(format: "%7.2f", $0.tps) } + .joined(separator: " ") + emit("KV=\(String(format: "%-5d", kv)) \(row)") + } + } + + /// Cache-memory bench: the dequant-mirror path allocates a + /// `[nKVHeads, maxSeq, headDim]` shared working buffer per layer. + /// The compressed path doesn't touch that buffer at all — assert + /// the savings by inspecting `bytesAllocated` / `bytesInUse` on + /// the AURA cache itself. + @Test("cache memory — packed-only footprint vs mirror at maxSeq=4096") + func cacheMemoryFootprint() async throws { + guard let mp = benchModelPath("cacheMemoryFootprint") else { return } + var optsBuilder = LoadOptions() + optsBuilder.prewarm = false + optsBuilder.kvCache = .auraQuantized(scheme: .default) + optsBuilder.auraDecodePath = .compressed + let opts = optsBuilder + let m = try await ModelLoadLock.shared.loadSerially { + try await Model.load(mp, options: opts) + } + let qwen = try #require(m.qwen3, "expected Qwen3Model engine") + let caches = qwen.makeLayerCaches(maxSeq: 4096) + guard let auraFirst = caches.first as? AURAQuantizedKVCache else { + Issue.record("expected at least one AURAQuantizedKVCache in layer caches") + return + } + // `bytesAllocated` reports the packed buffers + norms (the + // permanent storage); the per-layer working mirror is shared + // across layers + only lives when `.dequantMirror` is engaged + // (allocated lazily inside `prepareForAttention`). + let packedBytesAllocated = auraFirst.bytesAllocated + let mirrorBytesIfAlloc = + auraFirst.nKVHeads * auraFirst.maxSeq * auraFirst.headDim * 2 // bf16 + let savingsRatio = Double(mirrorBytesIfAlloc) / Double(packedBytesAllocated) + print( + "[aura4v4 cache layout @ maxSeq=4096] " + + "packed+norms=\(packedBytesAllocated / 1024) KiB " + + "mirror-if-alloc=\(mirrorBytesIfAlloc / 1024) KiB " + + "compression=\(String(format: "%.2f", savingsRatio))×") + } +} diff --git a/Tests/ModelIntegrationTests/Telemetry/AuraKLDIntegrationTests.swift b/Tests/ModelIntegrationTests/Telemetry/AuraKLDIntegrationTests.swift new file mode 100644 index 00000000..0032b5f5 --- /dev/null +++ b/Tests/ModelIntegrationTests/Telemetry/AuraKLDIntegrationTests.swift @@ -0,0 +1,310 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// AURA KL-divergence regression gate. Loads a small local checkpoint +// twice — once with the fp16 baseline KV cache and once with an AURA +// codec — emits per-position logits over a fixed prompt, and reports +// the aggregate KLD vs the baseline + same-top-token rate. Mirrors the +// canonical TQ+ harness output from +// `bench-tq+/harness/kld_vs_baseline.py` (in +// the reference TQ+ harness). +// +// This test is the regression gate for every subsequent TQ+ port — +// matched-norm L2 correction, InnerQ equalization, per-group FP8 scale. +// Each port should improve or hold the recorded `same_top_fraction` +// and lower the `mean_kld` vs the baseline AURA scheme. +// +// Currently gated on the local Qwen3-0.6B-4bit checkpoint — the +// smallest AURA-compatible model that runs end-to-end on a dev box. + +import Foundation +import TestHelpers +import Testing + +@testable import FFAI + +private let qwen3LocalPath = "/Users/tom/models/Qwen3-0.6B-4bit" + +@Suite("AURA KL-divergence regression gate", .serialized) +struct AuraKLDIntegrationTests { + + /// Fixed sample prompt. Chosen for diversity (en-prose + rare + /// tokens + a code-like fragment) so the codec's tail behaviour + /// gets exercised, not just the head of the distribution. + private static let samplePrompt = + "The history of the printing press began when European craftsmen " + + "combined movable metal type with oil-based ink and a wooden screw " + + "press. The first printed book was the Gutenberg Bible in 1455. " + + "Compute the next item in this sequence: 2, 4, 8, 16, " + + @Test("model loads + single-step forward smoke") + func loadSmoke() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("loadSmoke skipped: \(qwen3LocalPath) not found") + return + } + print("loadSmoke: loading...") + var optsBuilder = LoadOptions() + optsBuilder.prewarm = false + let opts = optsBuilder + let m = try await ModelLoadLock.shared.loadSerially { + try await Model.load(qwen3LocalPath, options: opts) + } + print("loadSmoke: loaded. engine type=\(type(of: m.engine))") + let caches = m.engine.makeLayerCaches(maxSeq: 256) + print("loadSmoke: caches=\(caches.count)") + let logits = m.engine.forward(tokenId: 0, position: 0, caches: caches) + print("loadSmoke: vocab=\(logits.elementCount)") + let firstFew = Array(logits.toFloatArray().prefix(5)) + print("loadSmoke: first-5-logits=\(firstFew)") + } + + @Test("baseline-only smoke — emit logits + measure KLD vs self == 0") + func baselineSmoke() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("baselineSmoke skipped: \(qwen3LocalPath) not found") + return + } + let trace = try await emitTrace( + kvCache: .raw, scheme: nil, decodePath: .dequantMirror) + // Self-KLD should be ~0 across positions (same logits both sides). + let metrics = LogitsEmitter.compare( + baseline: trace.logits, codec: trace.logits, tokenIds: trace.tokenIds) + print(KLDivergence.summaryLine(label: "self vs self", metrics: metrics)) + #expect( + metrics.meanKld < 1e-6, + "self-KLD should be ≈0, got \(String(format: "%.6e", metrics.meanKld))") + #expect(metrics.sameTopFraction == 1.0) + } + + @Test("AURA aura4v4 vs fp16 baseline — KLD + same-top regression gate") + func aura4v4VsBaseline() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("auraKLD skipped: \(qwen3LocalPath) not found") + return + } + let metrics = try await runKLDComparison(scheme: .default) + print( + KLDivergence.summaryLine(label: "aura4v4 (default)", metrics: metrics)) + // Baseline gate, pinned at the current state of AURA on + // Qwen3-0.6B-4bit so a regression on subsequent TQ+ ports + // (matched-norm L2, InnerQ, per-group fp8 scale) gets caught + // by CI. The measured floor today is mean_kld≈1.24 + same_top + // ≈47.5%. P1 ports should drive these down/up; tighten the + // thresholds as each port lands. + let sameTopStr = String(format: "%.4f", metrics.sameTopFraction) + let meanKldStr = String(format: "%.4f", metrics.meanKld) + let sameTopMsg = + "same_top \(sameTopStr) below current-state floor 0.40 — " + + "AURA quality regressed below the 2026-05-26 baseline of 0.475" + let meanKldMsg = + "mean_kld \(meanKldStr) above current-state ceiling 1.5 — " + + "AURA quality regressed above the 2026-05-26 baseline of 1.24" + #expect(metrics.sameTopFraction > 0.40, "\(sameTopMsg)") + #expect(metrics.meanKld < 1.5, "\(meanKldMsg)") + } + + @Test("AURA aura4v4 COMPRESSED flash decode — matches dequant-mirror quality") + func aura4v4Compressed() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("aura4v4 compressed skipped: \(qwen3LocalPath) not found") + return + } + let metrics = try await runKLDComparison( + scheme: .default, decodePath: .compressed) + print( + KLDivergence.summaryLine( + label: "aura4v4 compressed (flash)", metrics: metrics)) + // Compressed flash should track dequant-mirror quality closely + // (same codec, just different decode dispatch). Gate is set + // wide enough to absorb the bf16-Π precision cost from + // metaltile #226 (rotation + boundaries moved to Tensor + // storage — halves the encoder's dominant read but rounds the + // Π matrix and Lloyd-Max thresholds at ~1e-3, which shifts a + // small fraction of borderline 4-bit bin assignments at encode + // time). Mirror baseline at f16 ≈ 1.41; compressed at bf16 Π ≈ + // 1.76 in current runs. Floor + ceiling sized to catch a + // catastrophic flash-kernel divergence (e.g. dispatch-grid bug + // → same_top=0), not to enforce parity with the f32-Π era. + let sameTopStr = String(format: "%.4f", metrics.sameTopFraction) + let meanKldStr = String(format: "%.4f", metrics.meanKld) + let sameTopMsg: Comment = + "compressed flash same_top \(sameTopStr) below floor 0.30 (bf16-Π era)" + let meanKldMsg: Comment = + "compressed flash mean_kld \(meanKldStr) above ceiling 2.0 (bf16-Π era)" + #expect(metrics.sameTopFraction > 0.30, sameTopMsg) + #expect(metrics.meanKld < 2.0, meanKldMsg) + } + + @Test("AURA aura4v2 COMPRESSED flash decode — production recipe via flash") + func aura4v2Compressed() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("aura4v2 compressed skipped: \(qwen3LocalPath) not found") + return + } + let metrics = try await runKLDComparison( + scheme: AURAScheme(keyBits: 4, valueBits: 2), decodePath: .compressed) + print( + KLDivergence.summaryLine( + label: "aura4v2 compressed (flash)", metrics: metrics)) + // Flash kernel shows a small residual gap vs dequant-mirror at + // 2-bit V (compressed same_top ≈ 0.31 vs dequant-mirror's 0.43; + // mean_kld 2.05 vs 1.93). aura4v4 matches dequant-mirror + // exactly so the gap is in the 2-bit-V code path of + // `aura_flash_sdpa.rs`, not the wiring. P1c per-group fp8 + // scale is the canonical fix. + let sameTopStr = String(format: "%.4f", metrics.sameTopFraction) + let meanKldStr = String(format: "%.4f", metrics.meanKld) + #expect( + metrics.sameTopFraction > 0.25, + "compressed flash same_top \(sameTopStr) below 0.25 — regression beyond known 2-bit-V gap") + #expect( + metrics.meanKld < 3.0, + "compressed flash mean_kld \(meanKldStr) above aggressive-V ceiling 3.0") + } + + @Test("AURA aura8v4 — TQ+ production recipe (high-bit K, aggressive V)") + func aura8v4() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("aura8v4 skipped: \(qwen3LocalPath) not found") + return + } + let scheme = AURAScheme(keyBits: 8, valueBits: 4) + let metrics = try await runKLDComparison(scheme: scheme) + print(KLDivergence.summaryLine(label: "aura8v4 (TQ+ recipe)", metrics: metrics)) + } + + @Test("AURA aura3v3 — mid-bit data point in the curve") + func aura3v3() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("aura3v3 skipped: \(qwen3LocalPath) not found") + return + } + let scheme = AURAScheme(keyBits: 3, valueBits: 3) + let metrics = try await runKLDComparison(scheme: scheme) + print(KLDivergence.summaryLine(label: "aura3v3 (3-bit sym)", metrics: metrics)) + } + + @Test("AURA aura2v2 — low-bit data point in the curve") + func aura2v2() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("aura2v2 skipped: \(qwen3LocalPath) not found") + return + } + let scheme = AURAScheme(keyBits: 2, valueBits: 2) + let metrics = try await runKLDComparison(scheme: scheme) + print(KLDivergence.summaryLine(label: "aura2v2 (2-bit sym)", metrics: metrics)) + } + + @Test("AURA aura8v8 — high-bit sanity check (should be near-baseline)") + func aura8v8SanityCheck() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("auraKLD aura8v8 skipped: \(qwen3LocalPath) not found") + return + } + let scheme = AURAScheme(keyBits: 8, valueBits: 8) + let metrics = try await runKLDComparison(scheme: scheme) + print( + KLDivergence.summaryLine( + label: "aura8v8 (high-bit sanity)", metrics: metrics)) + // 8-bit Lloyd-Max codebook should be very near baseline. If + // this is far from baseline the issue is the pipeline + // (rotation, encode→decode round-trip, dispatch), not the + // codebook — guides where to look for the quality gap. + } + + @Test("AURA aura4v2 (asymmetric 4-bit K / 2-bit V) vs fp16 baseline") + func aura4v2VsBaseline() async throws { + guard FileManager.default.fileExists(atPath: qwen3LocalPath) else { + print("auraKLD aura4v2 skipped: \(qwen3LocalPath) not found") + return + } + let scheme = AURAScheme(keyBits: 4, valueBits: 2) + let metrics = try await runKLDComparison(scheme: scheme) + print( + KLDivergence.summaryLine( + label: "aura4v2 (production)", metrics: metrics)) + // aura4v2 is more aggressive — looser floors than aura4v4. + // Specifically the 2-bit V codebook chops a lot of fidelity; + // we mostly want to know it doesn't go catastrophic. + let sameTopStr = String(format: "%.4f", metrics.sameTopFraction) + let meanKldStr = String(format: "%.4f", metrics.meanKld) + #expect( + metrics.sameTopFraction > 0.40, + "same_top \(sameTopStr) below aggressive-V floor 0.40") + #expect( + metrics.meanKld < 3.0, + "mean_kld \(meanKldStr) above aggressive-V ceiling 3.0") + } + + // MARK: - Helpers + + /// Load the same checkpoint twice (raw fp16 baseline + AURA codec + /// with the given scheme), emit per-position logits over the + /// sample prompt for both, and aggregate the KLD. + private func runKLDComparison( + scheme: AURAScheme, decodePath: AURADecodePath = .dequantMirror + ) async throws -> KLDivergence.AggregateMetrics { + let baselineTrace = try await emitTrace( + kvCache: .raw, scheme: nil, decodePath: .dequantMirror) + let codecTrace = try await emitTrace( + kvCache: .auraQuantized(scheme: scheme), scheme: scheme, + decodePath: decodePath) + let tokenIds = baselineTrace.tokenIds + return LogitsEmitter.compare( + baseline: baselineTrace.logits, + codec: codecTrace.logits, + tokenIds: tokenIds) + } + + private struct Trace { + let tokenIds: [Int] + let logits: [[Float]] + } + + /// Load + tokenize + emit logits with one specific KV-cache setting. + /// Kept as its own helper so the model goes out of scope (along + /// with its big weight buffers) between the baseline + codec + /// runs — a 0.6B 4-bit model is small but two side-by-side + /// instances would still cost ~600 MB. + private func emitTrace( + kvCache: KVCacheKind, scheme: AURAScheme?, + decodePath: AURADecodePath + ) async throws -> Trace { + var optsBuilder = LoadOptions() + optsBuilder.prewarm = false + optsBuilder.kvCache = kvCache + optsBuilder.auraDecodePath = decodePath + let opts = optsBuilder + let m = try await ModelLoadLock.shared.loadSerially { + try await Model.load(qwen3LocalPath, options: opts) + } + let tokens = m.tokenizer.encode(text: Self.samplePrompt) + guard let engine = m.qwen3 else { + throw NSError( + domain: "AuraKLDIntegrationTests", code: -1, + userInfo: [ + NSLocalizedDescriptionKey: + "expected Qwen3Model engine for \(qwen3LocalPath)" + ]) + } + // Cap maxSeq tight against the prompt — Qwen3's default is + // huge (256K), allocating that for ~64 tokens of bench data + // wastes seconds + GBs of memory and was the cause of an + // earlier SIGSEGV on this harness. + let logits = LogitsEmitter.emit( + model: engine, tokenIds: tokens, maxSeq: max(tokens.count + 32, 256)) + return Trace(tokenIds: tokens, logits: logits) + } +} diff --git a/Tests/ModelIntegrationTests/Text/DeepSeekV4IntegrationTests.swift b/Tests/ModelIntegrationTests/Text/DeepSeekV4IntegrationTests.swift new file mode 100644 index 00000000..a6a936aa --- /dev/null +++ b/Tests/ModelIntegrationTests/Text/DeepSeekV4IntegrationTests.swift @@ -0,0 +1,123 @@ +// Copyright 2026 Tom Turney (@TheTom) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// DeepSeek-V4 model integration. Mirrors the common model-integration +// pattern (loads / shapes + configs / default parameters / coherent +// output). GGUF-loader-specific coverage lives in +// `Tests/ModelIntegrationTests/Loader/GGUFLoaderTests.swift`. +// +// Skipped by default — the DSv4-Flash checkpoint is ~86 GB, so every +// test guard-returns unless a checkpoint is staged at +// `$FFAI_DSV4_GGUF_PATH` (default `~/models/deepseek-v4-flash`). The +// GGUF path is a parallel loader (`DeepSeekV4Flash.loadModelFromGGUF`), +// not the standard `Model.load` safetensors flow, so these construct +// the model directly. + +import Foundation +import Testing + +@testable import FFAI + +@Suite("DeepSeekV4 integration", .serialized) +struct DeepSeekV4IntegrationTests { + + /// Local checkpoint dir, or `nil` to skip (model too big for CI). + private var modelPath: String? { + let env = + ProcessInfo.processInfo.environment["FFAI_DSV4_GGUF_PATH"] + ?? NSString("~/models/deepseek-v4-flash").expandingTildeInPath + guard FileManager.default.fileExists(atPath: env) else { return nil } + return env + } + + /// Minimal config synthesized from GGUF hparams (the GGUF loader + /// reads the rest of the structure off the file itself). + private func ggufConfig(_ bundle: GGUFTensorBundle) -> ModelConfig { + let hidden = Int(bundle.reader.metadataUInt32("deepseek4.embedding_length") ?? 4096) + let nLayers = Int(bundle.reader.metadataUInt32("deepseek4.block_count") ?? 43) + let vocab = Int(bundle.reader.metadataUInt32("deepseek4.vocab_size") ?? 129_280) + let nHeads = Int(bundle.reader.metadataUInt32("deepseek4.attention.head_count") ?? 64) + return ModelConfig( + architecture: "DeepSeekV4ForCausalLM", modelType: "deepseek4", + raw: [ + "hidden_size": hidden, "num_hidden_layers": nLayers, + "vocab_size": vocab, "num_attention_heads": nHeads, + ]) + } + + // 1. Loader + expected shapes / config. + @Test("Loads from GGUF and exposes the expected layer geometry") + func loadsAndHasExpectedShapes() throws { + guard let dir = modelPath else { + print("DeepSeekV4IntegrationTests: skipping (no model at FFAI_DSV4_GGUF_PATH)") + return + } + let bundle = try GGUFTensorBundle(directory: URL(fileURLWithPath: dir)) + let model = try DeepSeekV4Flash.loadModelFromGGUF( + config: ggufConfig(bundle), gguf: bundle, options: LoadOptions(), device: .shared) + + #expect(model.textConfig.nLayers == 43) + // compress_ratios carries one extra entry for the MTP slot. + #expect(model.layerCompressRatios.count >= model.textConfig.nLayers) + + // Layer 0 is full-attention (compress_ratio 0); loading it dequants + // the layer tensors and exposes per-head learnable attn sinks. + let layer0 = try model.layer(0) + #expect(layer0.compressRatio == 0) + #expect(layer0.layerIndex == 0) + #expect(layer0.attnSinks.shape.map { Int($0) } == [64]) + model.releaseLayer(0) + } + + // 2. Default generation parameters. + @Test("Default generation parameters match the DSv4 recipe") + func defaultGenerationParameters() { + let p = DeepSeekV4Flash.defaultGenerationParameters + // No model-specific maxTokens override — uses the framework default. + #expect(p.maxTokens == GenerationParameters().maxTokens) + #expect(p.prefillStepSize == 4096) + #expect(p.temperature == 0.6) + #expect(p.topP == 0.95) + #expect(p.topK == 64) + } + + // 3. Coherent output — one forward must produce finite, NaN-free logits + // and a real argmax (the model-too-big-to-CI proxy for "generates + // sane text"; full multi-token generate lands when the GGUF path + // wires into `Model.generate`). + @Test("Forward from BOS produces finite, NaN-free logits") + func forwardProducesSaneLogits() throws { + guard let dir = modelPath else { + print("DeepSeekV4IntegrationTests: skipping (no model at FFAI_DSV4_GGUF_PATH)") + return + } + let bundle = try GGUFTensorBundle(directory: URL(fileURLWithPath: dir)) + let model = try DeepSeekV4Flash.loadModelFromGGUF( + config: ggufConfig(bundle), gguf: bundle, options: LoadOptions(), device: .shared) + let state = model.makeDecodeState() + let bos = Int(bundle.reader.metadataUInt32("tokenizer.ggml.bos_token_id") ?? 0) + + let logits = try model.forwardAllLayers(inputTokenId: bos, state: state) + let host = logits.toFloatArray() + + let nNaN = host.reduce(0) { $0 + ($1.isNaN ? 1 : 0) } + #expect(nNaN == 0, "logits has \(nNaN) NaN values") + + var maxIdx = 0 + var maxVal: Float = -.infinity + for (i, v) in host.enumerated() where v > maxVal { maxVal = v; maxIdx = i } + #expect(maxVal.isFinite, "argmax logit not finite: \(maxVal)") + #expect(maxIdx >= 0 && maxIdx < host.count) + } +} diff --git a/benchmarks/.m5-max-2026-05-27.state.json b/benchmarks/.m5-max-2026-05-27.state.json new file mode 100644 index 00000000..aea0aa75 --- /dev/null +++ b/benchmarks/.m5-max-2026-05-27.state.json @@ -0,0 +1,196 @@ +{ + "chip" : "m5-max", + "createdAt" : "2026-05-27T16:44:48Z", + "osVersion" : "26.4.1", + "rows" : [ + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.10223232635842, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 129597440, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " 1455, 1456, 1457, 1458, 1459, 1460, 1461, 1462, 1463, 1464, 146", + "peakGPUBytes" : 31415762944, + "prefillTokensPerSecond" : 213.90086106833246, + "promptTokens" : 48, + "quantization" : "4bit", + "steadyTokensPerSecond" : 92.12155979021612, + "timeToFirstTokenMs" : 224.4030237197876, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.62556231369072, + "generatedTokens" : 32, + "kvCacheUsedBytes" : 128471040, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The first book to be printed using movable type was the 42-line Bible, also known as the Gutenberg Bible, which was printed by Johannes Gutenberg in Mainz", + "peakGPUBytes" : 31410749440, + "prefillTokensPerSecond" : 197.18878258621095, + "promptTokens" : 25, + "steadyTokensPerSecond" : 92.70339484948143, + "timeToFirstTokenMs" : 126.78205966949463, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.15779663471066, + "generatedTokens" : 128, + "kvCacheUsedBytes" : 134676480, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " Based on the text, what is the primary purpose of the passage? Here's a thinking process: 1. **Analyze User Input:** - **Input Text:** A short p", + "peakGPUBytes" : 31452856320, + "prefillTokensPerSecond" : 257.1910967257828, + "promptTokens" : 232, + "steadyTokensPerSecond" : 92.16453263816872, + "timeToFirstTokenMs" : 902.0529985427856, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 91.41499001004274, + "generatedTokens" : 32, + "kvCacheUsedBytes" : 128471040, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The first book to be printed using movable type was the 42-line Bible, also known as the Gutenberg Bible, which was printed by Johannes Gutenberg in Mainz", + "peakGPUBytes" : 31410749440, + "prefillTokensPerSecond" : 196.32945758205545, + "promptTokens" : 25, + "steadyTokensPerSecond" : 91.60482769746231, + "timeToFirstTokenMs" : 127.33697891235352, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.48501916467842, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 130539520, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The", + "peakGPUBytes" : 31424806912, + "prefillTokensPerSecond" : 280.01278103668017, + "promptTokens" : 94, + "steadyTokensPerSecond" : 92.54766762874516, + "timeToFirstTokenMs" : 335.6989622116089, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.08659250169724, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 130539520, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The", + "peakGPUBytes" : 31424675840, + "prefillTokensPerSecond" : 289.24144219951825, + "promptTokens" : 94, + "steadyTokensPerSecond" : 92.05904820356909, + "timeToFirstTokenMs" : 324.9880075454712, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.11866776389165, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 130539520, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The", + "peakGPUBytes" : 31424675840, + "prefillTokensPerSecond" : 291.62303651754684, + "promptTokens" : 94, + "steadyTokensPerSecond" : 92.14183483114596, + "timeToFirstTokenMs" : 322.3339319229126, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 91.52729328440702, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 130539520, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The", + "peakGPUBytes" : 31424675840, + "prefillTokensPerSecond" : 299.53476089196903, + "promptTokens" : 94, + "steadyTokensPerSecond" : 91.42965282009372, + "timeToFirstTokenMs" : 313.8200044631958, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.14957910815177, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 130539520, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The", + "peakGPUBytes" : 31424675840, + "prefillTokensPerSecond" : 290.0214435067574, + "promptTokens" : 94, + "steadyTokensPerSecond" : 92.13681208566285, + "timeToFirstTokenMs" : 324.11396503448486, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 92.25730614691376, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 130539520, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The", + "peakGPUBytes" : 31424675840, + "prefillTokensPerSecond" : 295.73976163168805, + "promptTokens" : 94, + "steadyTokensPerSecond" : 92.29553653619756, + "timeToFirstTokenMs" : 317.84701347351074, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + }, + { + "baselineGPUBytes" : 31405244416, + "contextSize" : 262144, + "decodeTokensPerSecond" : 91.89618198518147, + "generatedTokens" : 64, + "kvCacheUsedBytes" : 130539520, + "method" : "simple", + "model" : "\/Users\/tom\/models\/Qwen3.6-35B-A3B-4bit", + "outputPreview" : " The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The", + "peakGPUBytes" : 31424675840, + "prefillTokensPerSecond" : 278.15511909960793, + "promptTokens" : 94, + "steadyTokensPerSecond" : 91.95684257943317, + "timeToFirstTokenMs" : 337.94093132019043, + "weightsBytes" : 19506817536, + "wiredTicketBytes" : 115448725504 + } + ], + "systemRAMBytes" : 137438953472 +} \ No newline at end of file diff --git a/benchmarks/m5-max-2026-05-27.md b/benchmarks/m5-max-2026-05-27.md new file mode 100644 index 00000000..3fa5fd48 --- /dev/null +++ b/benchmarks/m5-max-2026-05-27.md @@ -0,0 +1,19 @@ +# FFAI Bench — m5-max + +- System RAM: 128.00 GB +- OS: 26.4.1 +- Created: 2026-05-27T16:44:48Z + +| Model | Method | Quant | Ctx | Prompt | Prefill tok/s | Decode tok/s | Steady tok/s | TTFT (ms) | Gen tokens | Baseline GPU | Peak GPU | KV used | Weights | Gen PPL | Gen KLD | Sample | +|---|---|---|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|--:|---| +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | 4bit | 262144 | 48 | 213.90 | 92.10 | 92.12 | 224.40 | 64 | 29.25 GB | 29.26 GB | 123.6 MB | 18.17 GB | - | - | 1455, 1456, 1457, 1458, 1459, 1460, 1461, 1462, 1463, 1464, 146 | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 25 | 197.19 | 92.63 | 92.70 | 126.78 | 32 | 29.25 GB | 29.25 GB | 122.5 MB | 18.17 GB | - | - | The first book to be printed using movable type was the 42-line Bible, also known as the Gutenberg Bible, which was printed by Johannes Gutenberg in Mainz | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 232 | 257.19 | 92.16 | 92.16 | 902.05 | 128 | 29.25 GB | 29.29 GB | 128.4 MB | 18.17 GB | - | - | Based on the text, what is the primary purpose of the passage? Here's a thinking process: 1. **Analyze User Input:** - **Input Text:** A short p | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 25 | 196.33 | 91.41 | 91.60 | 127.34 | 32 | 29.25 GB | 29.25 GB | 122.5 MB | 18.17 GB | - | - | The first book to be printed using movable type was the 42-line Bible, also known as the Gutenberg Bible, which was printed by Johannes Gutenberg in Mainz | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 94 | 280.01 | 92.49 | 92.55 | 335.70 | 64 | 29.25 GB | 29.27 GB | 124.5 MB | 18.17 GB | - | - | The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 94 | 289.24 | 92.09 | 92.06 | 324.99 | 64 | 29.25 GB | 29.27 GB | 124.5 MB | 18.17 GB | - | - | The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 94 | 291.62 | 92.12 | 92.14 | 322.33 | 64 | 29.25 GB | 29.27 GB | 124.5 MB | 18.17 GB | - | - | The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 94 | 299.53 | 91.53 | 91.43 | 313.82 | 64 | 29.25 GB | 29.27 GB | 124.5 MB | 18.17 GB | - | - | The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 94 | 290.02 | 92.15 | 92.14 | 324.11 | 64 | 29.25 GB | 29.27 GB | 124.5 MB | 18.17 GB | - | - | The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 94 | 295.74 | 92.26 | 92.30 | 317.85 | 64 | 29.25 GB | 29.27 GB | 124.5 MB | 18.17 GB | - | - | The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The | +| /Users/tom/models/Qwen3.6-35B-A3B-4bit | simple | - | 262144 | 94 | 278.16 | 91.90 | 91.96 | 337.94 | 64 | 29.25 GB | 29.27 GB | 124.5 MB | 18.17 GB | - | - | The printing press was invented by Johannes Gutenberg, a German goldsmith, in the 15th century, and it revolutionized the way information was disseminated. The | diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md new file mode 100644 index 00000000..291b6423 --- /dev/null +++ b/docs/BACKENDS.md @@ -0,0 +1,68 @@ +# Adding a backend (ROCm, Vulkan, …) + +The engine is built so a new GPU backend needs **only two things**; everything +above the `Device` trait — ops, models, loader, runtime, the whole verified +model suite — comes for free. + +``` + ┌──────────────────────────────────────────────┐ + SHARED │ ffai-models / ffai-modeltests (model graphs) │ + (no per- │ ffai-ops (the op seam, → Kernel IR) │ + backend │ ffai-loader (GGUF / SafeTensors + dequant)│ + code) │ ffai-core (Device trait · Tensor) │ + └───────────────────────┬──────────────────────┘ + │ the ONLY boundary + ┌───────────────────────▼──────────────────────┐ + PER-BACKEND │ 1. impl Device (alloc/upload/download/ │ + (all you │ dispatch/synchronize) — ~150 lines │ + write) │ 2. metaltile codegen target (emit the │ + │ backend's kernel language) │ + └────────────────────────────────────────────────┘ +``` + +## The two pieces + +**1. A `Device` impl** (`crates/backends/ffai-/src/lib.rs`). Mirror +`ffai-cuda` / `ffai-metal`: wrap the backend's buffers + module/PSO cache, and +implement the five `ffai_core::Device` methods. `alloc`/`upload`/`download` move +bytes; `dispatch(kernel, bindings, grid)` binds the operands and launches. +`ffai-ops` only ever calls these — it never names a concrete backend. + +**2. A metaltile codegen target.** `metaltile-codegen` already emits **MSL** +(`src/msl/`) and **CUDA** (`src/cuda/`) from one `#[kernel]` DSL. +- **ROCm/HIP** is the cheapest: HIP is ~source-compatible with CUDA C, so the + `cuda` backend is the starting point — most kernels compile under `hipcc` + unchanged; differences are wavefront size (64 vs 32) and a few intrinsics. +- **Vulkan** needs an SPIR-V/GLSL compute emit (a new `src/vulkan/`). + +## Wiring the tests (one file, not N) + +The model forwards live **once** in `ffai-modeltests` as `verify_*(&dyn Device)`, +collected by `run_all(dev, plat)`. Each backend has a **single** test file: + +```rust +// crates/backends/ffai-/tests/all_models.rs +use ffai_::Device; +#[test] +fn all_models() { + let Some(dev) = Device::create().expect("") else { return }; + ffai_modeltests::run_all(dev.as_ref(), " GPU"); +} +``` + +That's the whole verification surface. The 10-model suite (GPT-2, Pythia, +GPT-Neo, OLMo-2, Gemma-2, Phi-1.5, StableLM-2, OLMoE, Mamba2, Falcon-H1 — +LayerNorm/parallel-residual/post-norm/geGLU/MoE/SSM/hybrid families) runs on +the new backend with **zero model code**, checked against the same HF oracles. + +## Checklist for a new backend + +1. `crates/backends/ffai-rocm/` — `Device` impl + `unsafe impl Send/Sync` as needed. +2. metaltile codegen target (HIP: fork `src/cuda/`; tune wavefront 64). +3. `tests/all_models.rs` (copy the template above). +4. `cargo test -p ffai-rocm --test all_models` → the full suite, vs HF. +5. Add the crate to `[workspace].members`. + +No changes to `ffai-ops`, `ffai-models`, `ffai-loader`, or any model — those are +shared. If a kernel diverges per backend, it diverges in metaltile's codegen, +not in the engine. diff --git a/docs/DSV4_PORT.md b/docs/DSV4_PORT.md new file mode 100644 index 00000000..446b5634 --- /dev/null +++ b/docs/DSV4_PORT.md @@ -0,0 +1,100 @@ +# DeepSeek-V4-Flash → shared Rust engine — implementation spec + +Status: **groundwork / roadmap.** DSv4-Flash is a research-grade architecture +with several novel subsystems; this maps the exact forward, weights, kernels, +and config so the Rust port is a build problem, not a discovery problem. + +> ⚠️ The Swift reference is itself **partial**: full-attention layers work, +> but CSA/HCA compression + the Lightning Indexer are WIP (greedy decode NaNs +> on those layers). A fully-correct end-to-end DSv4 reference does not yet +> exist on either side — port the full-attention path first and treat +> CSA/HCA as a parallel research track. + +## Config (DSv4-Flash) + +hidden 4096 · layers 43 · vocab 129280 · n_heads 64 · n_kv_heads 1 · +head_dim 512 · qk_rope_head_dim 64 (nope 448 / rope 64) · q_lora_rank 1024 · +o_lora_rank 1024 · o_groups 8 · rope_theta 10000 (compress 160000) · +sliding_window 128 · n_routed_experts 256 · experts_per_tok 6 · +n_shared_experts 1 · moe_intermediate 2048 · scoring `sqrtsoftplus` · +routed_scaling 1.5 · swiglu_limit 10.0 · rms_norm_eps 1e-6 · +mHC: hc_mult 4, sinkhorn_iters 20 · **activations f32** (f16 drifts ~0.08 +logits over 43 layers). + +## Per-layer forward (decode) + +mHC sinkhorn-split → mHC collapse (4ch→1) → attn_norm → **MLA**: +q_a → q_a_norm → q_b → per-head unit-RMS q-norm → partial-RoPE(q tail) ; +kv → kv_a_norm → partial-RoPE(kv tail) → sliding-window cache append → +SDPA d512 + per-head sink → inverse partial-RoPE → grouped O-LoRA (8×) → +mHC expand. Then mHC split → collapse → ffn_norm → **DSv4-MoE** +(sqrtsoftplus router + bias → top-6 → per-expert clamped-SwiGLU + 1 shared) +→ mHC expand. + +## metaltile kernels (exist, pass the CUDA corpus) + +`ffai_dsv4_partial_rope` (qk,out,head_dim,n_nope,half_rot,position,theta, +inverse,freq_scale,ext,corr_low,corr_high) · `ffai_dsv4_indexer_score` · +`ffai_dsv4_indexer_topk_block` · `ffai_dsv4_mhc_sinkhorn_split` · +`ffai_dsv4_mhc_collapse` · `ffai_dsv4_mhc_expand` · `ffai_dsv4_compressor_pool` · +`ffai_sdpa_decode_d512_sink` (reused MLX) · `ffai_moe_router_sqrtsoftplus` · +`ffai_dsv4_swiglu_limit` · dequant: `ffai_dsv4_mxfp4_dequant`, +`ffai_dsv4_fp8_block_dequant`. + +## GGUF weights (blk.N.*) + quant + +q_a/q_b/kv/output_a/output_b/shexp: **q8_0** · gate_exps/up_exps: **iq2_xxs** +· down_exps: **q2_K** · ffn_gate_inp/mHC/compressor/indexer: **f16** · +norms/sinks/scales/biases: **f32**. token_embd f16, output q8_0. +(See Swift `Models/Text/DeepSeekV4Text.swift` loader + `Loader/GGUF/`.) + +## Build order (each its own focused session) + +1. **GGUF v3 loader + dequant** (q8_0, q2_K, iq2_xxs, f16, f32) — gate for everything. +2. **MLA attention builder** — partial-RoPE + sink-SDPA ops wired into ffai-ops; validate the op-composition vs CPU on both platforms (kernels already pass the corpus). +3. **DSv4-MoE** — sqrtsoftplus router + clamped-SwiGLU + shared expert (extends the verified `ffai_models::moe`). +4. **mHC** — sinkhorn-split / collapse / expand (the 4-channel residual). +5. **Full-attention layers end-to-end** on CUDA, diff vs Swift full-attn output. +6. **CSA/HCA + indexer** — research track; reference is WIP. + +## Real checkpoint — reverse-engineered layout (2026-06, IQ2XXS 86 GB, CUDA box) + +GGUF dequant verified vs gguf-py on the real checkpoint **on the GB10 box** +(43 blocks, 1328 tensors): Q8_0 Δ4.0e-7, Q2_K Δ4.9e-7, IQ2_XXS Δ4.7e-7. +Composites (MLA/MoE/mHC/swiglu_limit/router) all pass on CUDA vs CPU. + +**Config (`deepseek4.*` metadata):** hidden 4096 · 64 heads · head_dim +(key/value_length) 512 · q_lora 1024 · output_lora 1024 · output_groups 8 · +rope_dim 64 (⇒ n_nope 448, half_rot 32) · eps 1e-6 · experts 256 (used 6, +shared 1, ffn 2048, weights_scale 1.5, norm true) · swiglu_clamp 10 · mHC +count 4, sinkhorn_iters 20. + +**blk.N tensor → struct map** (ggml `[in,out]` dims = row-major `[out,in]` = +struct layout, no transpose): + +| GGUF tensor | dims | → struct field | +|---|---|---| +| `attn_norm.weight` | [4096] | `MlaWeights.attn_norm` | +| `attn_q_a.weight` | [4096,1024] | `q_a` [1024,4096] | +| `attn_q_a_norm.weight` | [1024] | `q_a_norm` | +| `attn_q_b.weight` | [1024,32768] | `q_b` [32768,1024] | +| `attn_kv.weight` | [4096,512] | `kv` [512,4096] | +| `attn_kv_a_norm.weight` | [512] | `kv_a_norm` | +| `attn_sinks.weight` | [64] | `sink` (per-head) | +| `attn_output_a.weight` | [4096,8192] | `output_a` = 8 groups × [1024,4096] | +| `attn_output_b.weight` | [8192,4096] | `output_b` [4096,8192] | +| `hc_attn_fn.weight` | [16384,24] | `MhcWeights.hc_fn` [24,16384] | +| `hc_attn_base.weight` | [24] | `hc_base` | +| `hc_attn_scale.weight` | [3] | `hc_scale` (pre/post/comb — a tensor, not a const) | +| `ffn_gate_inp.weight` | [4096,256] | MoE router | +| `ffn_{gate,up}_exps.weight` | [4096,2048,256] | 256 experts (IQ2_XXS) | +| `ffn_down_exps.weight` | [2048,4096,256] | 256 experts (Q2_K) | +| `ffn_{gate,up,down}_shexp.weight` | … | shared expert | +| `ffn_gate_tid2eid.weight` | [6,129280] | hash-routing table | + +**Full-forward blocker (now named precisely):** the `attention.indexer` +(head_count 64, key_length 128, top_k 512) + `sliding_window 128` — the CSA +sparse-attention indexer, WIP in the reference itself ⇒ no correct oracle for +the full 43-layer forward. Everything *else* (dense MLA + mHC + MoE per layer) +is op-verified; a real-weight single-layer forward (GPU-vs-CPU) is turnkey from +the map above. diff --git a/docs/PERF.md b/docs/PERF.md new file mode 100644 index 00000000..fed7175e --- /dev/null +++ b/docs/PERF.md @@ -0,0 +1,86 @@ +# FFAI performance — status, numbers, and the #1 bottleneck + +## Current throughput (GPT-2-124M, incremental KV cache) + +| platform | prefill | decode | one-time | output | +|---|---|---|---|---| +| CUDA (GB10 sm_121) | 52.5 tok/s | 24.6 tok/s (41 ms/tok) | 16s upload + 42s JIT | exact HF match | +| Metal — resident bufs | 24 tok/s | **17.6 tok/s (57 ms/tok)** | 11s upload + 32s JIT | exact HF match | +| Metal — old shim | 11 tok/s | 9.4 tok/s (107 ms/tok) | (re-uploaded weights/dispatch) | exact HF match | + +## Per-dispatch micro-bench — Rust vs Swift, same Apple GPU (settles the FFI-overhead question) + +gemv (2048×2048), per-commit = one command buffer per op + wait: + +| path | per-dispatch | batched (N ops / 1 cmd buffer) | +|---|---|---| +| Rust → Metal (resident weights) | **178 µs** | (via `dispatch_chain`) | +| Rust → Metal (old host-shadow shim) | 1868 µs | — | +| Swift → Metal (native `Ops.gemv`) | **170 µs** | **22 µs/op** | + +**Rust and Swift per-dispatch are within ~4%** — both pay the same Metal +`commit()`+wait, so the host language is not the variable (there's no Swift→Rust +FFI in the inference path anyway). The earlier 10× Rust gap was purely the +host-shadow shim re-uploading weights every dispatch, now fixed. + +The real lever is **batching**: Swift's 22 µs/op (7.6× faster) comes from +encoding many ops into one command buffer (its `Ops.gemv(on: cmd)` API). Rust +gets the same via `dispatch_chain` — the Device trait currently commits per op; +batching a whole layer into one command buffer is the next win, identical in +kind to what Swift does. tiny add: 188 µs (shim) → 161 µs (resident). + +## Fixed: resident-buffer fast path (was a host-shadow copy-in/out shim) + +`ffai-metal` now caches a GPU-resident `ResidentBuffer` on each `MetalBuffer` +(via the runtime's `upload_resident`), so pure inputs (weights) upload **once** +and bind resident across every dispatch instead of being re-staged from a host +shadow each call. Outputs still flow through host bytes (preserves in-place +reads + readback). Verified correct across elementwise / reduction / multi-output +/ in-place / MLA-composite kernels and all real-model forwards. Result: gemv 10×, +decode 1.9× (107→57 ms/tok). + +### Original root cause (for the record) + +`crates/backends/ffai-metal/src/lib.rs::dispatch` (and `synchronize`, which is a +no-op "dispatch is synchronous") does, **per op**: + +1. read **every input operand's bytes from a host shadow** (`shadow(buf).data.read().clone()`), +2. hand them to `Context::dispatch_with_grid` as a `BTreeMap>` (re-uploaded), +3. run + wait, +4. copy each output back into the host shadow. + +There are **no persistent device buffers**. Every dispatch re-stages all +operands through host memory and synchronizes. A token = ~120 ops, so weights +and activations cross the bus ~120×/token, each a commit+wait. That is the +107 ms, not compute and not host round-trips in the *test* (those barely +mattered — cutting them 3× changed nothing). + +This also explains why "device-resident weights" gave 26× earlier: that win was +from **not recomputing host-side weight transposes** (`conv_t`) every step, not +from any actual device residency — the backend re-uploads regardless. + +The CUDA backend already wraps real device buffers (`CudaDevice` alloc/htod/dtoh), +which is why GB10 is ~2.6× faster and has more headroom. + +## The fix (highest-leverage perf item) + +Make `MetalDevice` hold **persistent `MTLBuffer`s** keyed by the `DeviceBuffer` +handle, and a `dispatch` that **binds device buffers by handle** instead of +re-uploading bytes — upload once on `alloc`/`upload`, never copy back unless +`download` is called. Requires the `metaltile_runtime::Context` Metal face to +accept pre-resident buffer handles (a small Context API addition) rather than a +bytes map. Then: + +- weights upload once, stay resident; activations stay resident across layers; +- `synchronize` becomes a real fence; dispatches enqueue into one command buffer + per token and sync once (kills the per-op commit+wait). + +Expected: the dispatch-bound 42× overhead largely collapses; F16 weights then +become worthwhile (halves the resident footprint + the now-dominant bandwidth). + +## Secondary perf items (after the above) + +- Cache the compiled metallib / cubin to kill the 32–42 s one-time JIT. +- Fuse the decode path (fewer dispatches) — esp. QKV proj + the SwiGLU/GELU MLP. +- Device-side KV cache (append + attend without host reorg/re-upload). +- Batched decode (batch > 1). diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 00000000..69eec23c --- /dev/null +++ b/docs/VERIFICATION.md @@ -0,0 +1,95 @@ +# FFAI shared-engine verification matrix + +One Rust codebase (`ffai-models` + `ffai-ops`) over the `ffai-core::Device` +trait, run on each backend, **argmax checked against HF transformers** for a +single-token forward (`forward_single` — token attends to itself at pos 0, +which is exactly HF's 1-token forward). The multi-token KV-cache decode loop +is the runtime's job; this validates the model graph + every op. + +## Dense transformer-LLM family — one builder, auto-config (`load_hf`) + +`load_hf` reads HF `config.json` for geometry and detects arch flags from the +tensor names (qk-norm by `q_norm.weight`, QKV bias by `q_proj.bias`). No +per-model code. + +| model | arch detail | CUDA (GB10 / sm_121) | Metal (Apple GPU) | HF argmax (tok 9707) | +|---|---|:---:|:---:|:---:| +| Qwen3-0.6B | qk-norm, tied, hd128 | ✅ 21806 | ✅ 21806 | 21806 | +| Qwen2.5-0.5B | QKV bias, tied, hd64 | ✅ 11 | ✅ 11 | 11 | +| SmolLM2-135M | Llama-arch, tied, hidden 576 | ✅ 28 | ✅ 28 | 28 | + +Three distinct architectures (qk-norm / QKV-bias / plain-Llama) and a +non-128-multiple hidden (576, handled by the strided `mt_rms_norm_wide` +fallback) — all auto-loaded by `load_hf`, identical argmax on CUDA, Metal, +and HF. Same code path covers the rest of the family as weights are staged: +**Llama 3.x, Mistral, Yi, Phi, Starcoder2, OLMo, InternLM2, Granite3, …** — +dense Llama-style transformers differing only by config + the qk-norm/bias +flags `load_hf` already detects. + +## Architecture-path coverage — distinct non-Llama mechanisms (host-orchestrated, vs HF argmax/top-k) + +Each model below was chosen to exercise a *mechanism* the dense `load_hf` path +doesn't, on the shared op layer. All single-token forwards, argmax/top-k vs HF. + +| model | mechanism exercised | CUDA | Metal | HF | +|---|---|:---:|:---:|:---:| +| GPT-2 124M | LayerNorm-LLM, learned-pos, Conv1D (transposed) weights, gelu_new, tied | ✅ 198 | ✅ 198 | 198 | +| Pythia-160m | GPT-NeoX: parallel residual, interleaved per-head QKV, partial rotary | ✅ 285 | ✅ 285 | 285 | +| Gemma-2-2b | √hidden embed-scale, RMSNorm(1+w), 4 norms/layer, geGLU, GQA hd256, softcaps | ✅ top3 | ✅ top3 | [9707,235265,110] | +| Phi-1.5 | single shared norm → parallel attn+MLP, separate q/k/v+bias, partial rotary | ✅ top3 | ✅ top3 | [11,13,546] | +| OLMo-2-1B | **post-norm** (norm on sublayer output) + qk-norm over full proj, SwiGLU | ✅ top3 | ✅ top3 | [198,8,13] | +| StableLM-2-1.6B | LayerNorm(+bias), q/k/v bias, partial rotary, SwiGLU | ✅ top3 | ✅ top3 | [341,11,280] | +| GPT-Neo-125M | learned-pos + LayerNorm, separate q/k/v (no bias), no attn scaling, tied | ✅ top3 | ✅ top3 | [28,59,91] | + +(`load_hf` already covers qk-norm / QKV-bias / plain-Llama / GQA via Qwen3·Qwen2.5·SmolLM2.) + +## Prefill primitive — multi-token causal forward (vs HF argmax/top-k) + +The model tests above are single-token (pos 0). These verify the **multi-token +causal prefill** path — each position attends over [0, i] via `sdpa_decode` +with `n_kv = i+1` against the per-head K/V cache (`kv_stride = seq_len`). + +| test | what it adds | CUDA | Metal | HF | +|---|---|:---:|:---:|:---:| +| GPT-2 prefill (8 tok) | causal masking + learned positions | ✅ | ✅ | top3 [11,21831,7586] | +| Llama prefill (7 tok) | **RoPE-at-position** + GQA + SwiGLU (SmolVLM text model) | ⏳ | ✅ | top3 [12642,4052,216] | + +### Throughput (correctness-first, not yet optimized) — GPT-2-124M, incremental KV cache + device-resident weights +| platform | prefill | decode | one-time (weight upload / kernel JIT) | output | +|---|---|---|---|---| +| CUDA (GB10) | 52.5 tok/s | **24.6 tok/s** (41 ms/tok) | 16.1s / 42.4s | exact HF match | +| Metal (Apple GPU) | 11 tok/s | **9.4 tok/s** (107 ms/tok) | 10.7s / 31.5s | exact HF match | + +Resident weights gave a 26× decode speedup over re-uploading per step. Remaining +overhead is per-layer host round-trips + KV re-upload (device-resident activations ++ device KV cache is the next win) and F32-only compute. Not production tok/s yet. + +With these, every primitive for end-to-end generation + multimodal prefill is +verified. A full VLM forward = [SigLIP tower] → [SmolVLM connector] → splice +image embeds into the text sequence → [this Llama causal prefill] — each piece +independently confirmed vs HF on the shared op layer. + +## Exotic families — dedicated builders / ops (in progress) + +| family | needs | status | +|---|---|---| +| MoE — OLMoE / Qwen2-MoE / GPT-OSS / Granite4 | router → top-k → per-expert SwiGLU (+ optional shared expert) | ✅ **full real OLMoE-1B-7B verified vs HF both platforms** (argmax 310 on Metal + CUDA) — 16-layer, 64-expert, top-8, no-renorm (`norm_topk_prob=false`), no shared expert, qk-norm over the *full* 2048 projection, MHA hd128, sharded BF16 checkpoint via the mmap sharded loader. Plus the real Qwen2-MoE block (sigmoid-gated shared expert, max\|Δ\|4.6e-7 both platforms). | +| DeepSeek-V4 (MLA + DSv4-MoE + mHC) | full novel arch | ✅ **entire compute path + GGUF loader verified** (MLA/MoE/mHC composites both platforms vs CPU; F32/F16/Q8_0/Q2_K/IQ2_XXS dequant vs gguf-py on the 81GB checkpoint). Full 43-layer forward blocked on CSA/HCA sparse-attention (WIP in the reference itself — no correct oracle). | +| SSM — Mamba2, Jamba, LFM2 | conv1d + SSD selective-scan + gated RMSNorm | ✅ **full real Mamba2-130m verified vs HF both platforms** (argmax 310 on CUDA + Metal) — 24-layer forward: in_proj → conv1d+silu → SSD scan → gated RMSNorm → out_proj, on the shared op layer. | +| Hybrid SSM+attention — **Falcon-H1**, Jamba, Zamba | per-layer Mamba2 mixer ∥ attention, µP multipliers | ✅ **full real Falcon-H1-0.5B verified vs HF both platforms** (top-3 [593,531,587] on CUDA + Metal) — each of 36 layers runs the Mamba2 mixer AND GQA attention in parallel off one shared norm, summed into the residual, then SwiGLU FFN; all µP multipliers handled exactly. Reuses `conv1d_step`+`ssm_step` and `sdpa_decode` together in one model. | +| VLM — SigLIP/CLIP towers · Pixtral, SmolVLM2, FastVLM, Idefics3, MiniCPMV | vision tower + projector + the LLM builder | ✅ **full real SigLIP-base vision tower verified vs HF both platforms** (last_hidden_state max\|Δ\| 9e-5 / sum −792.79 vs HF −792.795, Metal + CUDA) — 12-layer bidirectional ViT: patch-embed (conv-as-matmul) + position-embed + LayerNorm + full self-attention + GELU(tanh)-MLP, via the new `matmul`/`layer_norm`/`gelu` ops (all unit-checked vs CPU both platforms). The LLM half is the dense-Llama family above; a VLM = this tower → connector → that LLM. **SmolVLM (Idefics3) connector now verified vs HF both platforms** (pixel-shuffle gathering a 4×4 patch block into each token's channels + modality projection; out max\|Δ\| 5e-4 / sum 79.85 vs HF 79.852, Metal+CUDA). All three pieces of a VLM (SigLIP tower, connector, dense-Llama LLM) are independently verified; the remaining work is only the end-to-end token-splice plumbing. | +| Audio — Whisper (enc **+ dec**) · Parakeet, Voxtral, StyleTTS2, … | encoder/decoder + audio front-end + cross-attention | ✅ **full real Whisper encoder AND encoder→decoder STT verified vs HF both platforms.** Encoder (whisper-tiny): last_hidden_state max\|Δ\| 2e-5 — conv front-end (Conv1d as im2col+`matmul`) + sinusoidal pos + bidirectional transformer. **Full STT (whisper-base): argmax 50362 = HF on Metal+CUDA** — adds the **cross-attention** path (decoder Q over the encoder's 1500 K/V states, per layer) + causal self-attn + tied lm_head. Cross-attention is the mechanism every encoder-decoder + many VLMs share. | + +## How to verify a model + +```sh +# Metal (Mac) +MODEL_DIR=/path/to/hf_model TOK=9707 EXPECT= \ + cargo test -p ffai-metal --test hf_model -- --nocapture +# CUDA (GB10) +MODEL_DIR=/path/to/hf_model TOK=9707 EXPECT= \ + cargo test -p ffai-cuda --features cuda --test hf_model -- --nocapture +``` + +`EXPECT` is HF transformers' argmax for the same single input token — the +oracle. Swift FFAI is itself HF-validated, so matching HF ≡ matching Swift. diff --git a/docs/architecture.png b/docs/architecture.png new file mode 100644 index 00000000..151f1e0c Binary files /dev/null and b/docs/architecture.png differ diff --git a/docs/ffai-before-after.png b/docs/ffai-before-after.png new file mode 100644 index 00000000..fd21cca9 Binary files /dev/null and b/docs/ffai-before-after.png differ diff --git a/docs/ffai-before-after.svg b/docs/ffai-before-after.svg new file mode 100644 index 00000000..ef5393ea --- /dev/null +++ b/docs/ffai-before-after.svg @@ -0,0 +1,156 @@ + + + + + + + + + + FFAI — F*cking Fast AI + before: Swift, Apple-only · after: one product, two engines, every GPU — shared kernels + + + + + + BEFORE + Swift inference library, native Metal + + + + FFAI (Swift) + + + ~35 model families · Vision · Audio + Llama · Qwen · Gemma · Nemotron · DeepSeek-V4 · Mamba · MoE … + forward passes, written in Swift + + + Swift runtime + Device.swift · BufferPool · Ops · KVCache · Generation · Sampler + Loaders: GGUF / SafeTensors / HF + all Swift, all Apple + + + MetalTileSwift + pre-compiled kernels.metallib (AOT) · PSOCache + + + + Metal → Apple GPU + + + iPhone · iPad · Mac — and nothing else + + + + metaltile (Rust kernel toolchain) + one IR → MSL → kernels.metallib + + tile build --emit + + + The ceiling + • "Metal" + "Apple" baked into the name and the code. + • No path to NVIDIA / AMD / portable GPUs. + • Want CUDA? Rewrite the whole model surface from scratch. + + + AFTER + two engines, one product, shared kernels + + + + FFAI (Swift) — unchanged + + PRIMARY · APPLE + ~35 models · Vision · Audio · DSv4 + Swift runtime + loaders + MetalTileSwift (AOT metallib) + + Metal → iPhone · iPad · Mac + still first-class · ships today + tests pass · DeepSeek-V4 runs (same as before) + can ALSO call the shared Rust layer ↓ (ffai-ffi) + via C-ABI — proven: Swift links libffai_ffi + + + + FFAI (Rust) — new + + CROSS-PLATFORM + ffai-models / loader / runtime + backend-neutral (porting in progress) + + ffai-ops — the op seam (real) + + ffai-core: one Device trait + add/mul proven on GB10 through the trait + ffai-ffi (C-ABI) → Swift & any C caller + ffai-cli enumerates live devices + + + + + + + + Backends — one crate each, behind the Device trait + + + ffai-cuda + REAL · GB10 sm_121 + NVRTC→PTX→driver + + + ffai-metal + stub (metal-rs) + Swift is primary on Apple + + + ffai-vulkan + stub (SPIR-V) + portable GPUs + + + + ROCm … + one crate = one backend + + + + NVIDIA + Apple + AMD / Intel / Android + + + + + + + metaltile — shared kernel toolchain (one IR) + → MSL (.metallib) for Swift → CUDA C++ / PTX for Rust → SPIR-V (Vulkan, next) + 4164/4164 kernels bit-accurate on GB10 · same kernels both engines run + + + same IR + + + + The unlock + • Swift keeps shipping to iPhone — untouched, primary. + • Same engine now runs NVIDIA today, AMD/Vulkan next — add a backend, not a rewrite. + + + Proven: Swift suite passes (parity) · DeepSeek-V4 runs on Swift · CUDA + Swift both consume the shared ffai-core/ffai-ops layer · live GB10 through the Device trait. + github.com/TheTom/ffai (private) — Swift at repo root, Rust engine under rust/, metaltile shared. + diff --git a/docs/ffai-shared-models.png b/docs/ffai-shared-models.png new file mode 100644 index 00000000..8d9990bd Binary files /dev/null and b/docs/ffai-shared-models.png differ diff --git a/docs/ffai-shared-models.svg b/docs/ffai-shared-models.svg new file mode 100644 index 00000000..1ada26cf --- /dev/null +++ b/docs/ffai-shared-models.svg @@ -0,0 +1,127 @@ + + + + + + + + + FFAI — models as a shared layer (target) + write each model ONCE (Rust) · run on every backend · Swift app calls in · zero per-platform duplication + + + + Swift app (Apple) + SwiftUI · AVFoundation capture · CoreML + iPhone · iPad · Mac packaging + calls the shared engine via ffai-ffi (C-ABI) + native Apple bits stay Swift; inference goes to Rust + + + + Swift models = the oracle + the existing ~35 Swift forward passes + validate each Rust port bit-for-bit, + then retire for inference. + no throwaway work — they're the ground truth + + + + + diff vs + + + + + WRITTEN ONCE + ffai-models (Rust) — one codebase + 35 families collapse to ~6–10 block-builders + per-model config (like ggml): + transformer-LLM builder + Llama · Qwen · Gemma · Mistral · Yi · Phi · SmolLM · Starcoder · OLMo … + + MoE / MLA builder + DeepSeek-V4 · GPT-OSS · Granite4 + + SSM builder + Mamba2 · Jamba · FalconH1 · LFM2 + + VLM / Audio builders + Pixtral · SmolVLM2 · FastVLM · TTS/STT + + + + + + ffai-ops — the op seam + rms_norm · matmul · attention · rope · softmax · sampling → builds metaltile Kernel IR + add/mul real today; + heavy ops = next step + + + + + + ffai-core — one Device trait (alloc · upload · dispatch · sync) + + + + + + + + + + ffai-cuda + REAL · GB10 sm_121 + NVRTC → PTX → driver + + + ffai-metal (metal-rs) + THE KEY PIECE TO BUILD + makes Apple run the shared Rust models + + + ffai-vulkan + portable GPUs + SPIR-V (metaltile target next) + + + ffai-rocm … + AMD + one crate = one backend + + + + + + + NVIDIA + Apple GPU + AMD / Intel / Android + + + + + metaltile — shared kernels (one IR → MSL · CUDA/PTX · SPIR-V) + the kernels every backend runs · 4164/4164 bit-accurate on GB10 · already shared + + feeds the Device backends + + + + No duplication + • Each model lives in ONE file (Rust). CUDA, Metal, Vulkan, ROCm all run the same code — the backend swaps underneath. + • Adding a GPU vendor = one backend crate, not 35 model rewrites. Adding a model = one builder, every GPU gets it free. + • Swift stays native for the iPhone app + is the bit-exact oracle the ports are checked against. + Cost: port ~6–10 builders (not 35) once + build the metal-rs backend. Then models are genuinely shared. + diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 00000000..db447002 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,6 @@ +/target +**/*.rs.bk +.DS_Store +**/*.metallib +*.gguf +*.safetensors diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 00000000..097878c5 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,639 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "ffai" +version = "0.1.0" +dependencies = [ + "ffai-core", + "ffai-cuda", + "ffai-loader", + "ffai-metal", + "ffai-models", + "ffai-ops", + "ffai-runtime", + "ffai-vulkan", +] + +[[package]] +name = "ffai-cli" +version = "0.1.0" +dependencies = [ + "ffai", +] + +[[package]] +name = "ffai-core" +version = "0.1.0" +dependencies = [ + "metaltile-core", + "thiserror", +] + +[[package]] +name = "ffai-cuda" +version = "0.1.0" +dependencies = [ + "ffai-core", + "ffai-loader", + "ffai-models", + "ffai-modeltests", + "ffai-ops", + "metaltile-codegen", + "metaltile-core", + "metaltile-runtime", +] + +[[package]] +name = "ffai-ffi" +version = "0.1.0" +dependencies = [ + "ffai", +] + +[[package]] +name = "ffai-loader" +version = "0.1.0" +dependencies = [ + "ffai-core", + "memmap2", + "serde_json", +] + +[[package]] +name = "ffai-metal" +version = "0.1.0" +dependencies = [ + "ffai-core", + "ffai-loader", + "ffai-models", + "ffai-modeltests", + "ffai-ops", + "ffai-runtime", + "metaltile-core", + "metaltile-runtime", + "parking_lot", +] + +[[package]] +name = "ffai-models" +version = "0.1.0" +dependencies = [ + "ffai-core", + "ffai-loader", + "ffai-ops", + "serde_json", +] + +[[package]] +name = "ffai-modeltests" +version = "0.1.0" +dependencies = [ + "ffai-core", + "ffai-loader", + "ffai-models", + "ffai-ops", + "ffai-runtime", +] + +[[package]] +name = "ffai-ops" +version = "0.1.0" +dependencies = [ + "ffai-core", + "metaltile-codegen", + "metaltile-core", + "metaltile-std", + "parking_lot", + "rustc-hash", +] + +[[package]] +name = "ffai-runtime" +version = "0.1.0" + +[[package]] +name = "ffai-vulkan" +version = "0.1.0" +dependencies = [ + "ffai-core", + "ffai-loader", + "ffai-models", + "ffai-runtime", + "metaltile-core", + "metaltile-runtime", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metaltile" +version = "0.1.0" +dependencies = [ + "bytemuck", + "inventory", + "metaltile-codegen", + "metaltile-core", + "metaltile-macros", + "metaltile-runtime", + "objc2", + "objc2-foundation", + "objc2-metal", + "rayon", + "serde", +] + +[[package]] +name = "metaltile-codegen" +version = "0.1.0" +dependencies = [ + "inventory", + "metaltile-core", + "rustc-hash", + "serde", + "serde_json", + "smallvec", + "thiserror", + "tracing", +] + +[[package]] +name = "metaltile-core" +version = "0.1.0" +dependencies = [ + "inventory", + "metaltile-macros", + "rustc-hash", + "serde", + "serde_json", + "smallvec", + "thiserror", +] + +[[package]] +name = "metaltile-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "metaltile-runtime" +version = "0.1.0" +dependencies = [ + "metaltile-codegen", + "metaltile-core", + "objc2", + "objc2-foundation", + "objc2-metal", + "parking_lot", + "rustc-hash", + "smallvec", + "thiserror", + "tracing", +] + +[[package]] +name = "metaltile-std" +version = "0.1.0" +dependencies = [ + "half", + "metaltile", + "rustc-hash", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..0cb2aec6 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,71 @@ +[workspace] +resolver = "2" +members = [ + "crates/ffai-core", + "crates/ffai-ops", + "crates/ffai-loader", + "crates/ffai-runtime", + "crates/ffai-models", + "crates/ffai-modeltests", + "crates/ffai", + "crates/ffai-cli", + "crates/ffai-ffi", + "crates/backends/ffai-metal", + "crates/backends/ffai-cuda", + "crates/backends/ffai-vulkan", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +authors = ["Eric Kryski (@ekryski)", "Tom Turney (@TheTom)"] +repository = "https://github.com/TheTom/ffai" + +[workspace.dependencies] +# ── engine crates ──────────────────────────────────────────────────── +ffai-core = { path = "crates/ffai-core" } +ffai-ops = { path = "crates/ffai-ops" } +ffai-loader = { path = "crates/ffai-loader" } +ffai-runtime = { path = "crates/ffai-runtime" } +ffai-models = { path = "crates/ffai-models" } +ffai = { path = "crates/ffai" } +ffai-modeltests = { path = "crates/ffai-modeltests" } +# ── backend crates ─────────────────────────────────────────────────── +ffai-metal = { path = "crates/backends/ffai-metal" } +ffai-cuda = { path = "crates/backends/ffai-cuda" } +ffai-vulkan = { path = "crates/backends/ffai-vulkan" } +# ── shared kernel toolchain (external: the metaltile cuda/metal repo) ── +# Canonical pointer is the upstream perf branch (split backend PRs #273/#274 +# + conv consolidation), so a fresh clone (Eric included) is self-contained. Local co-development overrides +# these to the live checkout via the [patch] section below. +metaltile-core = { git = "https://github.com/0xClandestine/metaltile", branch = "tom/perf/cuda-prefill-fp4-moe" } +metaltile-codegen = { git = "https://github.com/0xClandestine/metaltile", branch = "tom/perf/cuda-prefill-fp4-moe" } +metaltile-runtime = { git = "https://github.com/0xClandestine/metaltile", branch = "tom/perf/cuda-prefill-fp4-moe" } +metaltile-std = { git = "https://github.com/0xClandestine/metaltile", branch = "tom/perf/cuda-prefill-fp4-moe" } +# ── third-party ────────────────────────────────────────────────────── +thiserror = "2" +serde_json = "1" +memmap2 = "0.9" +parking_lot = "0.12" # faster, unpoisoned locks on the dispatch hot path +rustc-hash = "2" # FxHash for already-hashed / densely-numeric keys + +# Local co-dev override: when the metaltile checkout sits beside this repo +# (../metaltile-cuda), build against it directly — instant, no fetch, edits +# to kernels/codegen pick up immediately. Eric / CI without that sibling +# fall through to the git branch above. Comment out to force the git dep. +[patch."https://github.com/0xClandestine/metaltile"] +metaltile-core = { path = "../../metaltile-cuda/crates/metaltile-core" } +metaltile-codegen = { path = "../../metaltile-cuda/crates/metaltile-codegen" } +metaltile-runtime = { path = "../../metaltile-cuda/crates/metaltile-runtime" } +metaltile-std = { path = "../../metaltile-cuda/crates/metaltile-std" } + +# Perf build for benchmarks (fat-LTO inlines parking_lot locks, Arc/Rc clones, +# FNV/Fx hash closures into the hot path). Kept OUT of [profile.release] on +# purpose: fat-LTO + codegen-units=1 balloons build time with the metaltile +# patch, and `panic="abort"` would break `cargo test`. (Rust-playbook split.) +[profile.bench] +inherits = "release" +lto = "fat" +codegen-units = 1 +debug = true diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 00000000..75b03f7f --- /dev/null +++ b/rust/README.md @@ -0,0 +1,66 @@ +# FFAI — Rust engine + +The cross-platform half of FFAI. One Rust core behind a single `Device` +trait; backends (CUDA, Vulkan, ROCm, Metal) are independent crates picked at +build time by cargo feature. + +> **The Swift engine (repo root) is the primary Apple path** — native Metal, +> ships to iPhone/iPad/Mac, fast. It is *maintained as a first-class engine*, +> not replaced. This Rust engine is the path to everything Swift can't reach: +> NVIDIA (CUDA), AMD (ROCm), and portable GPUs (Vulkan). Both share the same +> kernels via the **metaltile** IR, so a kernel is written once and lowered to +> MSL *and* CUDA/SPIR-V. + +## Layout + +``` +rust/ + crates/ + ffai-core/ Device trait, Tensor, DType, Binding — the one seam + ffai-ops/ semantic ops (rms_norm, matmul, …) → Kernel → dispatch + ffai-models/ model forward passes (ported from swift Models/, validated against it) + ffai-loader/ GGUF / SafeTensors / HF + ffai-runtime/ KV cache, sampler, decode loop + ffai/ umbrella: backend selection + device enumeration + ffai-cli/ `ffai` binary + backends/ + ffai-cuda/ wraps metaltile-runtime CudaDevice (NVRTC → PTX → driver) + ffai-metal/ metal-rs (cross-validation on Mac; Swift is primary on Apple) + ffai-vulkan/ SPIR-V (portable; pending metaltile SPIR-V target) +``` + +## Build + +```sh +# Mac dev box (default = metal stub) +cargo run -p ffai-cli + +# CUDA host (e.g. the GB10 / DGX Spark) +cargo run -p ffai-cli --no-default-features --features cuda +``` + +## How sharing works + +The boundary is exactly two things: the **`Device` trait** (`ffai-core`) and +the **op→kernel dispatch** (`ffai-ops`). Everything above them — models, +loaders, KV cache, sampler — is plain Rust that never names a GPU API, so it +runs on every backend unchanged. Everything below is a thin `Device` impl. +Kernels are already shared by metaltile. + +Adding a backend (e.g. Vulkan) is **one crate**, not a fork — that's the +whole point of the seam. + +## metaltile dependency + +Canonical pointer is the `feature/cuda-backend` branch of the metaltile repo +(the cuda+metal toolchain), so a fresh clone is self-contained. Local +co-development overrides it to a sibling `../../metaltile-cuda` checkout via +the `[patch]` in `Cargo.toml` — instant, no fetch. + +## Status + +Skeleton: the `Device` trait + Tensor + modular backend crates compile and the +CLI enumerates compiled backends. Backend `Device` impls and the model port +land next. The CUDA kernel corpus already passes 100% (4164/4164) bit-accurate +on GB10 in the metaltile repo — this engine wires those kernels into real +inference. diff --git a/rust/crates/backends/ffai-cuda/Cargo.toml b/rust/crates/backends/ffai-cuda/Cargo.toml new file mode 100644 index 00000000..33cc80f0 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/Cargo.toml @@ -0,0 +1,114 @@ +[package] +name = "ffai-cuda" +description = "FFAI CUDA backend — wraps metaltile-runtime's CudaDevice (NVRTC -> PTX -> driver) behind the ffai-core Device trait." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +ffai-core.workspace = true +metaltile-core.workspace = true +metaltile-codegen = { workspace = true, optional = true } +metaltile-runtime = { workspace = true, optional = true } + +[dev-dependencies] +ffai-modeltests.workspace = true +ffai-core.workspace = true +ffai-ops.workspace = true +ffai-models.workspace = true +ffai-loader.workspace = true +metaltile-core.workspace = true + +[features] +# Off by default so macOS / non-CUDA hosts build the crate as a stub. +# `--features cuda` pulls metaltile-runtime's real CUDA device. +cuda = ["dep:metaltile-runtime", "dep:metaltile-codegen", "metaltile-runtime/cuda"] + +[[test]] +name = "cuda_smoke" +required-features = ["cuda"] + +[[test]] +name = "ffai_model" +required-features = ["cuda"] + +[[test]] +name = "qwen3_real" +required-features = ["cuda"] + +[[test]] +name = "hf_model" +required-features = ["cuda"] + +[[test]] +name = "moe_test" +required-features = ["cuda"] + +[[test]] +name = "dsv4_test" +required-features = ["cuda"] + +[[test]] +name = "dsv4_mla" +required-features = ["cuda"] + +[[test]] +name = "dsv4_moe" +required-features = ["cuda"] + +[[test]] +name = "dsv4_layer" +required-features = ["cuda"] + +[[test]] +name = "qwen_moe" +required-features = ["cuda"] + +[[test]] +name = "ssm_test" +required-features = ["cuda"] + +[[test]] +name = "conv1d_test" +required-features = ["cuda"] + +[[test]] +name = "vit_ops" +required-features = ["cuda"] + +[[test]] +name = "siglip" +required-features = ["cuda"] + +[[test]] +name = "whisper" +required-features = ["cuda"] + +[[test]] +name = "whisper_base" +required-features = ["cuda"] + +[[test]] +name = "smolvlm" +required-features = ["cuda"] + +[[test]] +name = "gpt2_prefill" +required-features = ["cuda"] + +[[test]] +name = "llama_prefill" +required-features = ["cuda"] + +[[test]] +name = "gpt2_generate" +required-features = ["cuda"] + +[[test]] +name = "gpt2_kvcache" +required-features = ["cuda"] + +[[test]] +name = "all_models" +required-features = ["cuda"] diff --git a/rust/crates/backends/ffai-cuda/src/imp.rs b/rust/crates/backends/ffai-cuda/src/imp.rs new file mode 100644 index 00000000..61806fa1 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/src/imp.rs @@ -0,0 +1,798 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! Real CUDA backend (compiled under `--features cuda`). Wraps +//! `metaltile_runtime::CudaDevice` — the NVRTC → PTX → driver path that +//! runs the kernel corpus bit-accurately on GB10 — behind the shared +//! [`ffai_core::Device`] trait, so the engine layer above is identical to +//! the Metal/Vulkan paths. + +use std::any::Any; +use std::collections::HashMap; +use std::os::raw::c_void; +use std::sync::{Arc, RwLock}; + +use ffai_core::{Backend, Binding, Device, DeviceBuffer, Error, Grid, Kernel, Result}; +use metaltile_codegen::{CodegenBackend, CudaGenerator}; +use metaltile_core::ir::KernelMode; +use metaltile_runtime::{CudaDevice as MtCudaDevice, CudaModule, MetalTileError}; + +fn dispatch_err(e: MetalTileError) -> Error { + Error::Dispatch(e.to_string()) +} + +/// A compiled module, cached for reuse. The raw `CUmodule` is single- +/// context; we only ever touch it through its owning device, so the manual +/// `Send`/`Sync` are sound for our serialized-submission usage. +/// +/// Holds an `Arc` so the CUDA CONTEXT outlives this module. A +/// `CudaModule`'s `Drop` calls `cuModuleUnload`, which faults +/// (STATUS_ACCESS_VIOLATION, 0xc0000005) if the context was already destroyed — +/// which is exactly what happened at process teardown: this device's `dev` field +/// (its only context-keepalive) dropped before the `modules` cache, destroying +/// the context, then the cached modules unloaded into the dead context. Pinning +/// the context here (and dropping `module` before `_dev` via field order below) +/// guarantees the module always unloads while its context is still live — the +/// same invariant `CudaBuffer` already upholds for device allocations. +struct CachedModule { + // Field DECLARATION ORDER is DROP ORDER: `module` (cuModuleUnload) must run + // BEFORE `_dev` (which may drop the last Arc and destroy the context). + module: CudaModule, + _dev: Arc, +} +unsafe impl Send for CachedModule {} +unsafe impl Sync for CachedModule {} + +/// CUDA implementation of the shared [`Device`] trait. Holds the metaltile +/// runtime device in an `Arc` so persistent tensors (which free on drop) +/// keep the CUDA context alive as long as any buffer is live. +pub struct CudaDevice { + dev: Arc, + name: String, + /// Compile-once cache: kernel name → loaded module. + modules: RwLock>>, + /// Memoized shared-mem bytes per (kernel, block_x) — avoids re-walking the + /// kernel IR on every dispatch (hot in decode: ~hundreds of dispatches/token). + shared: RwLock>, +} + +impl CudaDevice { + /// Probe for a CUDA device; `Ok(None)` if none is present. + pub fn create() -> Result>> { + match MtCudaDevice::create().map_err(dispatch_err)? { + Some(d) => { + let (maj, min) = d.compute_capability(); + let dev = CudaDevice { + dev: Arc::new(d), + name: format!("CUDA device (sm_{maj}{min})"), + modules: RwLock::new(HashMap::new()), + shared: RwLock::new(HashMap::new()), + }; + Ok(Some(Arc::new(dev))) + } + None => Ok(None), + } + } + + fn module_for(&self, kernel: &Kernel) -> Result> { + if let Some(m) = self.modules.read().unwrap().get(&kernel.name) { + return Ok(m.clone()); + } + let cg = CudaGenerator::new(); + let src = cg + .generate(kernel) + .map_err(|e| Error::Codegen(format!("{e:?}")))?; + // MT_DUMP_CUDA_SRC=: dump generated CUDA C++ for kernel inspection. + if let Ok(dir) = std::env::var("MT_DUMP_CUDA_SRC") { + let path = format!("{}/{}.cu", dir, kernel.name); + let _ = std::fs::write(&path, &src); + } + let module = self + .dev + .compile(&src, &format!("{}.cu", kernel.name)) + .map_err(dispatch_err)?; + let cached = Arc::new(CachedModule { module, _dev: self.dev.clone() }); + self.modules + .write() + .unwrap() + .insert(kernel.name.clone(), cached.clone()); + Ok(cached) + } +} + +/// A persistent CUDA allocation. Frees on drop; holds an `Arc` of the +/// device so the context outlives the buffer. +pub struct CudaBuffer { + ptr: u64, + len: usize, + dev: Arc, +} +// ptr is a plain integer handle; dev is an Arc. Sound to move/share for our +// serialized usage. +unsafe impl Send for CudaBuffer {} +unsafe impl Sync for CudaBuffer {} + +impl DeviceBuffer for CudaBuffer { + fn len(&self) -> usize { + self.len + } + fn as_any(&self) -> &dyn Any { + self + } +} + +impl Drop for CudaBuffer { + fn drop(&mut self) { + // Return to the device's size-bucketed pool instead of a synchronous + // cuMemFree — the hot decode path reuses these every token. + self.dev.free_raw_pooled(self.ptr, self.len); + } +} + +/// Element count of the kernel's first output param, derived from the +/// binding at that index. Used for the synthetic `_n_elems` Elementwise arg. +fn first_output_elems(kernel: &Kernel, bindings: &[Binding]) -> u32 { + if let Some(i) = kernel.params.iter().position(|p| p.is_output) { + let dt = kernel.params[i].dtype.size_bytes().max(1); + if let Some(Binding::Buffer(b)) = bindings.get(i) { + return (b.len() / dt) as u32; + } + } + 0 +} + +impl Device for CudaDevice { + fn backend(&self) -> Backend { + Backend::Cuda + } + + fn name(&self) -> &str { + &self.name + } + + fn alloc(&self, len: usize) -> Result> { + let ptr = self.dev.alloc_raw(len).map_err(dispatch_err)?; + Ok(Arc::new(CudaBuffer { ptr, len, dev: self.dev.clone() })) + } + + fn upload(&self, bytes: &[u8]) -> Result> { + let ptr = self.dev.alloc_raw(bytes.len()).map_err(dispatch_err)?; + self.dev.htod(ptr, bytes).map_err(dispatch_err)?; + Ok(Arc::new(CudaBuffer { ptr, len: bytes.len(), dev: self.dev.clone() })) + } + + fn alloc_zeroed(&self, len: usize) -> Result> { + // Stream-ordered device memset — no host zero buffer, no pageable + // H2D copy. A [s,hid] f32 MoE accumulator is ~22 MB; uploading zeros + // for it cost ~4-6 ms of host-blocking copy per E-layer. + let ptr = self.dev.alloc_raw(len).map_err(dispatch_err)?; + self.dev.memset_zero_raw(ptr, len).map_err(dispatch_err)?; + Ok(Arc::new(CudaBuffer { ptr, len, dev: self.dev.clone() })) + } + + fn download(&self, buf: &dyn DeviceBuffer, out: &mut [u8]) -> Result<()> { + let cb = buf + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg("download: buffer is not a CudaBuffer".into()))?; + self.dev.dtoh(cb.ptr, out).map_err(dispatch_err) + } + + fn dispatch(&self, kernel: &Kernel, bindings: &[Binding], grid: Grid) -> Result<()> { + let module = self.module_for(kernel)?; + let func = module.module.function(&kernel.name).map_err(dispatch_err)?; + let skey = (kernel.name.clone(), grid.block[0]); + let shared = if let Some(&s) = self.shared.read().unwrap().get(&skey) { + s + } else { + let s = CudaGenerator::new().shared_bytes(kernel, grid.block[0]) as u32; + self.shared.write().unwrap().insert(skey, s); + s + }; + + // Marshal kernel args: device ptrs / scalar bytes in binding order, + // then the synthetic _n_elems for Elementwise kernels. `ptr_store` + // and `scalar_store` back the raw pointers, so they outlive `args`. + let mut ptr_store: Vec = Vec::new(); + let mut scalar_store: Vec> = Vec::new(); + enum Slot { + Ptr(usize), + Scalar(usize), + } + let mut slots: Vec = Vec::new(); + for b in bindings { + match b { + Binding::Buffer(buf) => { + let cb = buf + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg("dispatch: binding is not a CudaBuffer".into()))?; + slots.push(Slot::Ptr(ptr_store.len())); + ptr_store.push(cb.ptr); + } + Binding::Scalar(bytes) => { + slots.push(Slot::Scalar(scalar_store.len())); + scalar_store.push(bytes.clone()); + } + } + } + if kernel.mode == KernelMode::Elementwise { + let n = first_output_elems(kernel, bindings); + slots.push(Slot::Scalar(scalar_store.len())); + scalar_store.push(n.to_le_bytes().to_vec()); + } + + let mut args: Vec<*mut c_void> = Vec::with_capacity(slots.len()); + for s in &slots { + match s { + Slot::Ptr(i) => args.push(&ptr_store[*i] as *const u64 as *mut c_void), + Slot::Scalar(i) => args.push(scalar_store[*i].as_ptr() as *mut c_void), + } + } + + // Async launch (no per-dispatch cuCtxSynchronize) — kernels pipeline on the + // ordered default stream; `download`/`synchronize` sync when results are read. + self.dev + .launch_async(func, grid.grid, grid.block, shared, &mut args) + .map_err(dispatch_err) + } + + fn synchronize(&self) -> Result<()> { + self.dev.synchronize().map_err(dispatch_err) + } + + fn begin_capture(&self) -> Result<()> { + self.dev.begin_capture().map_err(dispatch_err) + } + fn end_capture(&self) -> Result { + self.dev.end_capture().map(|exec| exec as usize as u64).map_err(dispatch_err) + } + fn graph_launch(&self, exec: u64) -> Result<()> { + self.dev.graph_launch(exec as usize as *mut std::ffi::c_void).map_err(dispatch_err) + } + fn graph_launch_batch(&self, exec: u64, n: usize) -> Result<()> { + self.dev.graph_launch_batch(exec as usize as *mut std::ffi::c_void, n).map_err(dispatch_err) + } + + fn dispatch_raw_cuda( + &self, + src: &str, + prog_name: &str, + fn_name: &str, + ptrs: &[(&dyn ffai_core::DeviceBuffer, usize)], + scalars: &[Vec], + grid: [u32; 3], + block: [u32; 3], + shared_bytes: u32, + cooperative: bool, + ) -> ffai_core::Result<()> { + // Compile (or reuse cached) module. + let module = { + let r = self.modules.read().unwrap(); + r.get(fn_name).cloned() + }; + let module = if let Some(m) = module { + m + } else { + let m = self.dev.compile(src, prog_name).map_err(dispatch_err)?; + let cached = Arc::new(CachedModule { module: m, _dev: self.dev.clone() }); + self.modules.write().unwrap().insert(fn_name.to_string(), cached.clone()); + cached + }; + let func = module.module.function(fn_name).map_err(dispatch_err)?; + + // Build args: device pointers (with offset applied) then scalar bytes. + let mut ptr_store: Vec = Vec::new(); + let mut scalar_store: Vec> = Vec::new(); + enum Slot { Ptr(usize), Scalar(usize) } + let mut slots: Vec = Vec::new(); + + for (buf, offset) in ptrs { + let cb = buf.as_any().downcast_ref::() + .ok_or_else(|| ffai_core::Error::Msg("dispatch_raw_cuda: buffer is not CudaBuffer".into()))?; + slots.push(Slot::Ptr(ptr_store.len())); + ptr_store.push(cb.ptr + *offset as u64); + } + for s in scalars { + slots.push(Slot::Scalar(scalar_store.len())); + scalar_store.push(s.clone()); + } + if fn_name.contains("marlin") && std::env::var("NEMOTRON_MARLIN_DBG").is_ok() { + eprintln!("[MARLINPTR] fn={} grid={:?} block={:?} smem={} ptrs={:?} scalars_lens={:?}", + fn_name, grid, block, shared_bytes, + ptr_store.iter().map(|p| format!("0x{:x}", p)).collect::>(), + scalars.iter().map(|s| s.len()).collect::>()); + } + + let mut args: Vec<*mut c_void> = slots.iter().map(|s| match s { + Slot::Ptr(i) => &ptr_store[*i] as *const u64 as *mut c_void, + Slot::Scalar(i) => scalar_store[*i].as_ptr() as *mut c_void, + }).collect(); + + // Use cooperative launch only when NOT inside a CUDA graph capture + // (cuLaunchCooperativeKernel is not capturable). Fall back to regular + // launch during capture — the caller (moe_fused_ffn) must not use + // grid.sync() in that code path (handled by NEMOTRON_GRAPH exclusion). + if cooperative && !self.dev.is_capturing() { + self.dev.launch_async_coop(func, grid, block, shared_bytes, &mut args).map_err(dispatch_err) + } else { + self.dev.launch_async(func, grid, block, shared_bytes, &mut args).map_err(dispatch_err) + } + } + + fn gemm_tc( + &self, + x: &dyn DeviceBuffer, + w: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + m: usize, + n: usize, + k: usize, + dtype: ffai_core::DType, + ) -> Result<()> { + let xb = x.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc: x not CudaBuffer".into()))?; + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc: w not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc: out not CudaBuffer".into()))?; + self.dev.gemm_cublas(xb.ptr, wb.ptr, ob.ptr, m, n, k, dtype).map_err(dispatch_err) + } + + fn gemm_tc_out_f32( + &self, + x: &dyn DeviceBuffer, + w: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + m: usize, + n: usize, + k: usize, + ab_dtype: ffai_core::DType, + ) -> Result<()> { + let xb = x.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc_out_f32: x not CudaBuffer".into()))?; + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc_out_f32: w not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc_out_f32: out not CudaBuffer".into()))?; + self.dev.gemm_cublas_f32out(xb.ptr, wb.ptr, ob.ptr, m, n, k, ab_dtype).map_err(dispatch_err) + } + + fn gemm_fp4( + &self, + x_pack: &dyn DeviceBuffer, + x_sf: &dyn DeviceBuffer, + w_pack: &dyn DeviceBuffer, + w_sf: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + m: usize, + n: usize, + k: usize, + out_f32: bool, + d_scale: Option<&dyn DeviceBuffer>, + ) -> Result<()> { + let xb = x_pack.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp4: x_pack not CudaBuffer".into()))?; + let xs = x_sf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp4: x_sf not CudaBuffer".into()))?; + let wb = w_pack.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp4: w_pack not CudaBuffer".into()))?; + let ws = w_sf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp4: w_sf not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp4: out not CudaBuffer".into()))?; + let ds = match d_scale { + Some(t) => t.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp4: d_scale not CudaBuffer".into()))?.ptr, + None => 0u64, + }; + self.dev.gemm_cublaslt_fp4(xb.ptr, xs.ptr, wb.ptr, ws.ptr, ob.ptr, m, n, k, out_f32, ds).map_err(dispatch_err) + } + + fn gemm_fp8( + &self, + x: &dyn DeviceBuffer, + x_scale: &dyn DeviceBuffer, + w: &dyn DeviceBuffer, + w_scale: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + m: usize, + n: usize, + k: usize, + out_f32: bool, + ) -> Result<()> { + let xb = x.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp8: x not CudaBuffer".into()))?; + let xs = x_scale.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp8: x_scale not CudaBuffer".into()))?; + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp8: w not CudaBuffer".into()))?; + let ws = w_scale.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp8: w_scale not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_fp8: out not CudaBuffer".into()))?; + self.dev.gemm_cublaslt_fp8(xb.ptr, xs.ptr, wb.ptr, ws.ptr, ob.ptr, m, n, k, out_f32).map_err(dispatch_err) + } + + fn moe_grouped_cutlass( + &self, + a: &dyn DeviceBuffer, + w: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + group_rows: &[i32], + expert_ids: &[i32], + n: usize, + k: usize, + ) -> Result<()> { + let ab = a.as_any().downcast_ref::().ok_or_else(|| Error::Msg("moe_grouped_cutlass: a not CudaBuffer".into()))?; + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("moe_grouped_cutlass: w not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("moe_grouped_cutlass: out not CudaBuffer".into()))?; + self.dev.moe_grouped_cutlass(ab.ptr, wb.ptr, ob.ptr, group_rows, expert_ids, n, k).map_err(dispatch_err) + } + + fn moe_grouped_cutlass_fp4( + &self, + a: &dyn DeviceBuffer, + sfa: &dyn DeviceBuffer, + w: &dyn DeviceBuffer, + sfw: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + group_rows: &[i32], + expert_ids: &[i32], + sfa_off: &[i64], + alpha_vec: Option<&dyn DeviceBuffer>, + n: usize, + k: usize, + ) -> Result<()> { + let dc = |b: &dyn DeviceBuffer, what: &str| -> Result { + Ok(b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg(format!("moe_grouped_cutlass_fp4: {what} not CudaBuffer")))? + .ptr) + }; + let (ap, sfap, wp, sfwp, op) = + (dc(a, "a")?, dc(sfa, "sfa")?, dc(w, "w")?, dc(sfw, "sfw")?, dc(out, "out")?); + let alp = match alpha_vec { + Some(b) => dc(b, "alpha_vec")?, + None => 0, + }; + self.dev + .moe_grouped_cutlass_fp4(ap, sfap, wp, sfwp, op, group_rows, expert_ids, sfa_off, alp, n, k) + .map_err(dispatch_err) + } + + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_w4a8( + &self, + a: &dyn DeviceBuffer, + sfa: &dyn DeviceBuffer, + w: &dyn DeviceBuffer, + sfw: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + group_rows: &[i32], + expert_ids: &[i32], + sfa_off: &[i64], + sfb_exp_bytes: i64, + alpha_vec: Option<&dyn DeviceBuffer>, + n: usize, + k: usize, + ) -> Result<()> { + let dc = |b: &dyn DeviceBuffer, what: &str| -> Result { + Ok(b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg(format!("moe_grouped_cutlass_w4a8: {what} not CudaBuffer")))? + .ptr) + }; + let (ap, sfap, wp, sfwp, op) = + (dc(a, "a")?, dc(sfa, "sfa")?, dc(w, "w")?, dc(sfw, "sfw")?, dc(out, "out")?); + let alp = match alpha_vec { + Some(b) => dc(b, "alpha_vec")?, + None => 0, + }; + self.dev + .moe_grouped_cutlass_w4a8(ap, sfap, wp, sfwp, op, group_rows, expert_ids, sfa_off, sfb_exp_bytes, alp, n, k) + .map_err(dispatch_err) + } + + fn w4a8_actquant( + &self, + x: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + sf: &dyn DeviceBuffer, + group_rows: &[i32], + sfa_off: &[i64], + n: usize, + k: usize, + ) -> Result<()> { + let dc = |b: &dyn DeviceBuffer, what: &str| -> Result { + Ok(b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg(format!("w4a8_actquant: {what} not CudaBuffer")))? + .ptr) + }; + let (xp, op, sfp) = (dc(x, "x")?, dc(out, "out")?, dc(sf, "sf")?); + self.dev + .w4a8_actquant_run(xp, op, sfp, group_rows, sfa_off, n, k) + .map_err(dispatch_err) + } + + fn w4a8_packw( + &self, + w: &dyn DeviceBuffer, + outp: &dyn DeviceBuffer, + sf: &dyn DeviceBuffer, + n_exp: usize, + n: usize, + k: usize, + ) -> Result { + let dc = |b: &dyn DeviceBuffer, what: &str| -> Result { + Ok(b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg(format!("w4a8_packw: {what} not CudaBuffer")))? + .ptr) + }; + let (wp, op, sfp) = (dc(w, "w")?, dc(outp, "outp")?, dc(sf, "sf")?); + self.dev + .w4a8_packw_run(wp, op, sfp, n_exp, n, k) + .map_err(dispatch_err) + } + + fn moe_grouped_cutlass_w8a8( + &self, + a: &dyn DeviceBuffer, + sfa: &dyn DeviceBuffer, + w: &dyn DeviceBuffer, + sfw: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + group_rows: &[i32], + expert_ids: &[i32], + sfa_off: &[i64], + sfb_exp_bytes: i64, + alpha_vec: Option<&dyn DeviceBuffer>, + n: usize, + k: usize, + ) -> Result<()> { + let dc = |b: &dyn DeviceBuffer, what: &str| -> Result { + Ok(b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg(format!("moe_grouped_cutlass_w8a8: {what} not CudaBuffer")))? + .ptr) + }; + let (ap, sfap, wp, sfwp, op) = + (dc(a, "a")?, dc(sfa, "sfa")?, dc(w, "w")?, dc(sfw, "sfw")?, dc(out, "out")?); + let alp = match alpha_vec { + Some(b) => dc(b, "alpha_vec")?, + None => 0, + }; + self.dev + .moe_grouped_cutlass_w8a8(ap, sfap, wp, sfwp, op, group_rows, expert_ids, sfa_off, sfb_exp_bytes, alp, n, k) + .map_err(dispatch_err) + } + + fn w8a8_actquant( + &self, + x: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + sf: &dyn DeviceBuffer, + group_rows: &[i32], + sfa_off: &[i64], + n: usize, + k: usize, + ) -> Result<()> { + let dc = |b: &dyn DeviceBuffer, what: &str| -> Result { + Ok(b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg(format!("w8a8_actquant: {what} not CudaBuffer")))? + .ptr) + }; + let (xp, op, sfp) = (dc(x, "x")?, dc(out, "out")?, dc(sf, "sf")?); + self.dev + .w8a8_actquant_run(xp, op, sfp, group_rows, sfa_off, n, k) + .map_err(dispatch_err) + } + + fn w8a8_packw( + &self, + w: &dyn DeviceBuffer, + outp: &dyn DeviceBuffer, + sf: &dyn DeviceBuffer, + n_exp: usize, + n: usize, + k: usize, + ) -> Result { + let dc = |b: &dyn DeviceBuffer, what: &str| -> Result { + Ok(b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg(format!("w8a8_packw: {what} not CudaBuffer")))? + .ptr) + }; + let (wp, op, sfp) = (dc(w, "w")?, dc(outp, "outp")?, dc(sf, "sf")?); + self.dev + .w8a8_packw_run(wp, op, sfp, n_exp, n, k) + .map_err(dispatch_err) + } + + fn moe_grouped_cutlass_fp4_prepare( + &self, + w: &dyn DeviceBuffer, + sfw: &dyn DeviceBuffer, + alpha: &dyn DeviceBuffer, + n_groups: usize, + n: usize, + k: usize, + max_m_total: usize, + ) -> Result { + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fp4_prepare: w not CudaBuffer".into()))?; + let sb = sfw.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fp4_prepare: sfw not CudaBuffer".into()))?; + let al = alpha.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fp4_prepare: alpha not CudaBuffer".into()))?; + self.dev.moe_grouped_cutlass_fp4_prepare(wb.ptr, sb.ptr, al.ptr, n_groups, n, k, max_m_total).map_err(dispatch_err) + } + + fn moe_grouped_cutlass_fp4_run( + &self, + handle: u64, + a: &dyn DeviceBuffer, + sfa: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + off_dev: &dyn DeviceBuffer, + ) -> Result<()> { + let ab = a.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fp4_run: a not CudaBuffer".into()))?; + let sb = sfa.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fp4_run: sfa not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fp4_run: out not CudaBuffer".into()))?; + let fb = off_dev.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fp4_run: off not CudaBuffer".into()))?; + self.dev.moe_grouped_cutlass_fp4_run(handle, ab.ptr, sb.ptr, ob.ptr, fb.ptr).map_err(dispatch_err) + } + + fn moe_grouped_cutlass_fp4_FUSEDACT_prepare( + &self, + w: &dyn DeviceBuffer, + sfw: &dyn DeviceBuffer, + alpha: &dyn DeviceBuffer, + norm_constant: &dyn DeviceBuffer, + n_groups: usize, + n: usize, + k: usize, + max_m_total: usize, + ) -> Result { + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_prepare: w not CudaBuffer".into()))?; + let sb = sfw.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_prepare: sfw not CudaBuffer".into()))?; + let al = alpha.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_prepare: alpha not CudaBuffer".into()))?; + let nc = norm_constant.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_prepare: norm_constant not CudaBuffer".into()))?; + self.dev.moe_grouped_cutlass_fp4_FUSEDACT_prepare(wb.ptr, sb.ptr, al.ptr, nc.ptr, n_groups, n, k, max_m_total).map_err(dispatch_err) + } + + fn moe_grouped_cutlass_fp4_FUSEDACT_run( + &self, + handle: u64, + a: &dyn DeviceBuffer, + sfa: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + sfd: &dyn DeviceBuffer, + off_dev: &dyn DeviceBuffer, + ) -> Result<()> { + let ab = a.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_run: a not CudaBuffer".into()))?; + let sb = sfa.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_run: sfa not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_run: out not CudaBuffer".into()))?; + let db = sfd.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_run: sfd not CudaBuffer".into()))?; + let fb = off_dev.as_any().downcast_ref::().ok_or_else(|| Error::Msg("fused_run: off not CudaBuffer".into()))?; + self.dev.moe_grouped_cutlass_fp4_FUSEDACT_run(handle, ab.ptr, sb.ptr, ob.ptr, db.ptr, fb.ptr).map_err(dispatch_err) + } + + fn moe_grouped_cutlass_fp4_AMAX_prepare( + &self, + w: &dyn DeviceBuffer, + sfw: &dyn DeviceBuffer, + d_amax: &dyn DeviceBuffer, + n_groups: usize, + n: usize, + k: usize, + max_m_total: usize, + ) -> Result { + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("amax_prepare: w not CudaBuffer".into()))?; + let sb = sfw.as_any().downcast_ref::().ok_or_else(|| Error::Msg("amax_prepare: sfw not CudaBuffer".into()))?; + let am = d_amax.as_any().downcast_ref::().ok_or_else(|| Error::Msg("amax_prepare: d_amax not CudaBuffer".into()))?; + self.dev.moe_grouped_cutlass_fp4_AMAX_prepare(wb.ptr, sb.ptr, am.ptr, n_groups, n, k, max_m_total).map_err(dispatch_err) + } + + fn moe_grouped_cutlass_fp4_AMAX_run( + &self, + handle: u64, + a: &dyn DeviceBuffer, + sfa: &dyn DeviceBuffer, + out: &dyn DeviceBuffer, + off_dev: &dyn DeviceBuffer, + ) -> Result<()> { + let ab = a.as_any().downcast_ref::().ok_or_else(|| Error::Msg("amax_run: a not CudaBuffer".into()))?; + let sb = sfa.as_any().downcast_ref::().ok_or_else(|| Error::Msg("amax_run: sfa not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("amax_run: out not CudaBuffer".into()))?; + let fb = off_dev.as_any().downcast_ref::().ok_or_else(|| Error::Msg("amax_run: off not CudaBuffer".into()))?; + self.dev.moe_grouped_cutlass_fp4_AMAX_run(handle, ab.ptr, sb.ptr, ob.ptr, fb.ptr).map_err(dispatch_err) + } + + fn gemm_tc_off( + &self, + x: &dyn DeviceBuffer, x_off: usize, + w: &dyn DeviceBuffer, w_off: usize, + out: &dyn DeviceBuffer, out_off: usize, + m: usize, n: usize, k: usize, dtype: ffai_core::DType, + ) -> Result<()> { + let xb = x.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc_off: x not CudaBuffer".into()))?; + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc_off: w not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_tc_off: out not CudaBuffer".into()))?; + self.dev.gemm_cublas( + xb.ptr + x_off as u64, + wb.ptr + w_off as u64, + ob.ptr + out_off as u64, + m, n, k, dtype, + ).map_err(dispatch_err) + } + + fn gemm_strided_batched( + &self, + x: &dyn DeviceBuffer, stride_x: i64, + w: &dyn DeviceBuffer, stride_w: i64, + out: &dyn DeviceBuffer, stride_out: i64, + m: usize, n: usize, k: usize, + batch_count: usize, + dtype: ffai_core::DType, + ) -> Result<()> { + let xb = x.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_strided_batched: x not CudaBuffer".into()))?; + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_strided_batched: w not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_strided_batched: out not CudaBuffer".into()))?; + self.dev.gemm_cublas_strided_batched( + xb.ptr, stride_x, + wb.ptr, stride_w, + ob.ptr, stride_out, + m, n, k, batch_count, dtype, + ).map_err(dispatch_err) + } + + fn gemm_strided_batched_off( + &self, + x: &dyn DeviceBuffer, x_off: usize, stride_x: i64, + w: &dyn DeviceBuffer, w_off: usize, stride_w: i64, + out: &dyn DeviceBuffer, out_off: usize, stride_out: i64, + m: usize, n: usize, k: usize, + batch_count: usize, + dtype: ffai_core::DType, + ) -> Result<()> { + let xb = x.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_strided_batched_off: x not CudaBuffer".into()))?; + let wb = w.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_strided_batched_off: w not CudaBuffer".into()))?; + let ob = out.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_strided_batched_off: out not CudaBuffer".into()))?; + self.dev.gemm_cublas_strided_batched( + xb.ptr + x_off as u64, stride_x, + wb.ptr + w_off as u64, stride_w, + ob.ptr + out_off as u64, stride_out, + m, n, k, batch_count, dtype, + ).map_err(dispatch_err) + } + + fn gemm_batched( + &self, + x_buf: &dyn ffai_core::DeviceBuffer, + x_offsets: &[usize], + w_buf: &dyn ffai_core::DeviceBuffer, + w_offsets: &[usize], + out_buf: &dyn ffai_core::DeviceBuffer, + out_offsets: &[usize], + m: usize, + n: usize, + k: usize, + dtype: ffai_core::DType, + ) -> Result<()> { + let xb = x_buf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_batched: x_buf not CudaBuffer".into()))?; + let wb = w_buf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_batched: w_buf not CudaBuffer".into()))?; + let ob = out_buf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_batched: out_buf not CudaBuffer".into()))?; + let x_ptrs: Vec = x_offsets.iter().map(|&off| xb.ptr + off as u64).collect(); + let w_ptrs: Vec = w_offsets.iter().map(|&off| wb.ptr + off as u64).collect(); + let out_ptrs: Vec = out_offsets.iter().map(|&off| ob.ptr + off as u64).collect(); + self.dev.gemm_cublas_batched(&x_ptrs, &w_ptrs, &out_ptrs, m, n, k, dtype) + .map_err(dispatch_err) + } + + fn gemm_grouped( + &self, + x_buf: &dyn ffai_core::DeviceBuffer, + x_offsets: &[usize], + w_buf: &dyn ffai_core::DeviceBuffer, + w_offsets: &[usize], + out_buf: &dyn ffai_core::DeviceBuffer, + out_offsets: &[usize], + m_per_group: &[i32], + n: usize, + k: usize, + dtype: ffai_core::DType, + ) -> Result<()> { + let xb = x_buf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_grouped: x_buf not CudaBuffer".into()))?; + let wb = w_buf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_grouped: w_buf not CudaBuffer".into()))?; + let ob = out_buf.as_any().downcast_ref::().ok_or_else(|| Error::Msg("gemm_grouped: out_buf not CudaBuffer".into()))?; + let x_ptrs: Vec = x_offsets.iter().map(|&off| xb.ptr + off as u64).collect(); + let w_ptrs: Vec = w_offsets.iter().map(|&off| wb.ptr + off as u64).collect(); + let out_ptrs: Vec = out_offsets.iter().map(|&off| ob.ptr + off as u64).collect(); + self.dev.gemm_cublas_grouped(&x_ptrs, &w_ptrs, &out_ptrs, m_per_group, n, k, dtype) + .map_err(dispatch_err) + } +} diff --git a/rust/crates/backends/ffai-cuda/src/lib.rs b/rust/crates/backends/ffai-cuda/src/lib.rs new file mode 100644 index 00000000..5e8a3415 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/src/lib.rs @@ -0,0 +1,34 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-cuda +//! +//! CUDA backend for the FFAI engine. Under `--features cuda` it wraps +//! `metaltile_runtime::CudaDevice` (NVRTC → PTX → driver) behind the shared +//! [`ffai_core::Device`] trait — the same seam the Metal/Vulkan backends +//! implement, so everything above it is backend-agnostic. Without the +//! feature it is a stub so non-CUDA hosts still build the workspace. + +#[cfg(feature = "cuda")] +mod imp; +#[cfg(feature = "cuda")] +pub use imp::{CudaBuffer, CudaDevice}; + +#[cfg(not(feature = "cuda"))] +mod stub { + use ffai_core::{Device, Result}; + use std::sync::Arc; + + /// Stub: the crate builds on non-CUDA hosts, but no device is available + /// until compiled with `--features cuda`. + pub struct CudaDevice; + + impl CudaDevice { + pub fn create() -> Result>> { + Ok(None) + } + } +} + +#[cfg(not(feature = "cuda"))] +pub use stub::CudaDevice; diff --git a/rust/crates/backends/ffai-cuda/tests/all_models.rs b/rust/crates/backends/ffai-cuda/tests/all_models.rs new file mode 100644 index 00000000..08653538 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/all_models.rs @@ -0,0 +1,18 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! The entire shared model suite on CUDA — ONE file (mirror of the Metal one). +use ffai_cuda::CudaDevice; +#[test] +fn all_models_on_cuda() { + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + ffai_modeltests::run_all(dev.as_ref(), "GB10 sm_121"); +} + +/// FAST FUSE_SLICECAST lever A/B on a small same-family model (loads in seconds). +/// See `ffai_modeltests::bench_smallmodel_fuse_slicecast`. Default mamba2-130m; +/// FUSE_SC_MODEL=falcon for Falcon-H1-0.5B. Off-vs-on bit-exact + timing. +#[test] +fn smallmodel_fuse_slicecast_ab() { + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + ffai_modeltests::bench_smallmodel_fuse_slicecast(dev.as_ref(), "GB10 sm_121"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/conv1d_test.rs b/rust/crates/backends/ffai-cuda/tests/conv1d_test.rs new file mode 100644 index 00000000..2fbc5422 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/conv1d_test.rs @@ -0,0 +1,43 @@ +#![cfg(feature = "cuda")] +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Causal conv1d step (conv1d_causal_step) on CUDA vs CPU — the short-conv +//! used by Mamba2 + audio front-ends. +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_ops::conv1d_causal_step; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn tn(d:&dyn Device,v:&[f32],sh:Vec)->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),sh,DType::F32)} + +#[test] +fn conv1d_causal_step_on_cuda_matches_cpu(){ + let Some(dev)=CudaDevice::create().expect("metal") else { eprintln!("no CUDA — skip"); return; }; + let (nc, ks) = (128usize, 4usize); + let x: Vec = (0..nc).map(|i| ((i as f32)*0.013).sin()).collect(); + let w: Vec = (0..ks*nc).map(|i| 0.1+((i as f32)*0.019).cos()*0.2).collect(); + let b: Vec = (0..nc).map(|i| (i as f32)*0.001-0.05).collect(); + let st: Vec = (0..(ks-1)*nc).map(|i| ((i as f32)*0.007).sin()*0.5).collect(); + + let st_t = tn(dev.as_ref(),&st,vec![(ks-1)*nc]); + let y = conv1d_causal_step(dev.as_ref(), &tn(dev.as_ref(),&x,vec![nc]), &tn(dev.as_ref(),&w,vec![ks*nc]), &tn(dev.as_ref(),&b,vec![nc]), &st_t, nc as u32, ks as u32).unwrap(); + dev.synchronize().unwrap(); + let mut yb=vec![0u8;nc*4]; dev.download(y.buffer.as_ref(),&mut yb).unwrap(); let yv=fb(&yb); + let mut sb=vec![0u8;(ks-1)*nc*4]; dev.download(st_t.buffer.as_ref(),&mut sb).unwrap(); let so=fb(&sb); + + // CPU ref + let mut y_ref=vec![0.0f32;nc]; let mut s_ref=vec![0.0f32;(ks-1)*nc]; + for d in 0..nc { + let mut acc=b[d]+w[(ks-1)*nc+d]*x[d]; + for k in 0..ks-1 { acc += w[k*nc+d]*st[k*nc+d]; } + y_ref[d]=acc; + for k in 0..ks.saturating_sub(2) { s_ref[k*nc+d]=st[(k+1)*nc+d]; } + s_ref[(ks-2)*nc+d]=x[d]; + } + let mut ey=0.0f32; for i in 0..nc { ey=ey.max((yv[i]-y_ref[i]).abs()); } + let mut es=0.0f32; for i in 0..(ks-1)*nc { es=es.max((so[i]-s_ref[i]).abs()); } + eprintln!("conv1d_causal_step on CUDA vs CPU: y max|Δ|={ey:.3e} state max|Δ|={es:.3e}"); + assert!(ey<=1e-4 && es<=1e-4, "conv1d mismatch y={ey:.3e} state={es:.3e}"); + eprintln!("✅ Causal conv1d step runs on CUDA through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/cuda_smoke.rs b/rust/crates/backends/ffai-cuda/tests/cuda_smoke.rs new file mode 100644 index 00000000..c1fae8d0 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/cuda_smoke.rs @@ -0,0 +1,351 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Prove the shared engine seam runs a kernel end-to-end on real CUDA +//! hardware: build a metaltile `Kernel` IR, then drive it entirely through +//! the backend-neutral `ffai_core::Device` trait (alloc / upload / dispatch +//! / download / synchronize) — no CUDA-specific code in sight. This is the +//! concrete proof that CUDA consumes the shared layer. +//! +//! Runs only with `--features cuda` on a CUDA host. Skips (no failure) when +//! no device is present. +#![cfg(feature = "cuda")] + +use ffai_core::{Binding, DType, Grid, Tensor}; +use ffai_cuda::CudaDevice; +use metaltile_core::{ + ir::{BinOpKind, IndexExpr, Kernel, Op, Param, ParamKind, ValueId}, + shape::Shape, +}; + +fn to_bytes(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn from_bytes(b: &[u8]) -> Vec { + b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() +} + +/// out[i] = a[i] + b[i] — Elementwise f32. +fn vector_add_ir() -> Kernel { + let mut k = Kernel::new("vector_add"); + for (name, is_out) in [("a", false), ("b", false), ("c", true)] { + k.params.push(Param { + name: name.into(), + dtype: DType::F32, + shape: Shape::scalar(), + is_output: is_out, + kind: ParamKind::Tensor, + }); + } + k.body.push_op(Op::ProgramId { axis: 0 }, ValueId::new(0)); + k.body.name_value(ValueId::new(0), "idx"); + k.body.push_op( + Op::Load { + src: "a".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + mask: None, + other: None, + }, + ValueId::new(1), + ); + k.body.push_op( + Op::Load { + src: "b".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + mask: None, + other: None, + }, + ValueId::new(2), + ); + k.body.push_op( + Op::BinOp { op: BinOpKind::Add, lhs: ValueId::new(1), rhs: ValueId::new(2) }, + ValueId::new(3), + ); + k.body.push_op_no_result(Op::Store { + dst: "c".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + value: ValueId::new(3), + mask: None, + }); + k +} + +#[test] +fn vector_add_through_shared_device_trait() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + eprintln!("device: {}", dev.name()); + + const N: usize = 4096; + let a: Vec = (0..N).map(|i| i as f32).collect(); + let b: Vec = (0..N).map(|i| (2 * i) as f32).collect(); + + let abuf = dev.upload(&to_bytes(&a)).unwrap(); + let bbuf = dev.upload(&to_bytes(&b)).unwrap(); + let cbuf = dev.alloc(N * 4).unwrap(); + + let k = vector_add_ir(); + let grid = Grid::d1((N as u32).div_ceil(256), 256); + dev.dispatch( + &k, + &[Binding::Buffer(abuf), Binding::Buffer(bbuf), Binding::Buffer(cbuf.clone())], + grid, + ) + .unwrap(); + dev.synchronize().unwrap(); + + let mut out = vec![0u8; N * 4]; + dev.download(cbuf.as_ref(), &mut out).unwrap(); + let c = from_bytes(&out); + + let mut max_err = 0.0f32; + for i in 0..N { + max_err = max_err.max((c[i] - (a[i] + b[i])).abs()); + } + assert!(max_err <= 1e-6, "vector_add via ffai Device mismatch: max|Δ|={max_err:.3e}"); + eprintln!("vector_add through ffai_core::Device on CUDA: OK (max|Δ|={max_err:.1e})"); +} + +/// Drive the real op layer (`ffai_ops::add` / `mul`) on CUDA — the same +/// calls model code makes, executed through the shared Device trait. +#[test] +fn ffai_ops_elementwise_on_cuda() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + + const N: usize = 2048; + let a: Vec = (0..N).map(|i| (i % 17) as f32 - 8.0).collect(); + let b: Vec = (0..N).map(|i| (i % 5) as f32 + 1.0).collect(); + + let ta = Tensor::new(dev.upload(&to_bytes(&a)).unwrap(), vec![N], DType::F32); + let tb = Tensor::new(dev.upload(&to_bytes(&b)).unwrap(), vec![N], DType::F32); + + let sum = ffai_ops::add(dev.as_ref(), &ta, &tb).unwrap(); + let prod = ffai_ops::mul(dev.as_ref(), &ta, &tb).unwrap(); + dev.synchronize().unwrap(); + + let mut sbytes = vec![0u8; N * 4]; + let mut pbytes = vec![0u8; N * 4]; + dev.download(sum.buffer.as_ref(), &mut sbytes).unwrap(); + dev.download(prod.buffer.as_ref(), &mut pbytes).unwrap(); + let s = from_bytes(&sbytes); + let p = from_bytes(&pbytes); + + let mut err = 0.0f32; + for i in 0..N { + err = err.max((s[i] - (a[i] + b[i])).abs()); + err = err.max((p[i] - (a[i] * b[i])).abs()); + } + assert!(err <= 1e-6, "ffai_ops add/mul on CUDA mismatch: max|Δ|={err:.3e}"); + eprintln!("ffai_ops::add + ffai_ops::mul on CUDA: OK (max|Δ|={err:.1e})"); +} + +/// Heavier ops via the registered-kernel lookup: rms_norm (mt_rms_norm) and +/// gemv (mt_gemv), driven through the shared Device trait on CUDA and +/// checked against a CPU reference. This is the mechanism every transformer +/// op rides on. +#[test] +fn ffai_ops_rms_norm_and_gemv_on_cuda() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + + // ── rms_norm: [rows, n] ────────────────────────────────────────── + const ROWS: usize = 4; + const N: usize = 512; + let x: Vec = (0..ROWS * N).map(|i| ((i % 13) as f32 - 6.0) * 0.1).collect(); + let w: Vec = (0..N).map(|j| 1.0 + (j % 7) as f32 * 0.05).collect(); + let eps = 1e-5f32; + + let tx = Tensor::new(dev.upload(&to_bytes(&x)).unwrap(), vec![ROWS, N], DType::F32); + let tw = Tensor::new(dev.upload(&to_bytes(&w)).unwrap(), vec![N], DType::F32); + let ty = ffai_ops::rms_norm(dev.as_ref(), &tx, &tw, eps).unwrap(); + dev.synchronize().unwrap(); + let mut yb = vec![0u8; ROWS * N * 4]; + dev.download(ty.buffer.as_ref(), &mut yb).unwrap(); + let y = from_bytes(&yb); + + let mut rms_err = 0.0f32; + for r in 0..ROWS { + let row = &x[r * N..(r + 1) * N]; + let ms: f32 = row.iter().map(|v| v * v).sum::() / N as f32; + let scale = 1.0 / (ms + eps).sqrt(); + for j in 0..N { + let want = row[j] * scale * w[j]; + rms_err = rms_err.max((y[r * N + j] - want).abs()); + } + } + assert!(rms_err <= 1e-4, "rms_norm on CUDA mismatch: max|Δ|={rms_err:.3e}"); + eprintln!("ffai_ops::rms_norm (mt_rms_norm) on CUDA: OK (max|Δ|={rms_err:.1e})"); + + // ── gemv: [M,K] @ [K] ──────────────────────────────────────────── + const M: usize = 64; + const K: usize = 512; + let mat: Vec = (0..M * K).map(|i| ((i % 11) as f32 - 5.0) * 0.02).collect(); + let vecd: Vec = (0..K).map(|j| ((j % 9) as f32 - 4.0) * 0.05).collect(); + + let tmat = Tensor::new(dev.upload(&to_bytes(&mat)).unwrap(), vec![M, K], DType::F32); + let tvec = Tensor::new(dev.upload(&to_bytes(&vecd)).unwrap(), vec![K], DType::F32); + let tout = ffai_ops::gemv(dev.as_ref(), &tmat, &tvec).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; M * 4]; + dev.download(tout.buffer.as_ref(), &mut ob).unwrap(); + let got = from_bytes(&ob); + + let mut gemv_err = 0.0f32; + for r in 0..M { + let want: f32 = (0..K).map(|j| mat[r * K + j] * vecd[j]).sum(); + gemv_err = gemv_err.max((got[r] - want).abs()); + } + assert!(gemv_err <= 1e-3, "gemv on CUDA mismatch: max|Δ|={gemv_err:.3e}"); + eprintln!("ffai_ops::gemv (mt_gemv) on CUDA: OK (max|Δ|={gemv_err:.1e})"); +} + +fn sigmoid(x: f32) -> f32 { + 1.0 / (1.0 + (-x).exp()) +} + +/// The remaining transformer ops via registered kernels: silu, swiglu, +/// gather (embedding), softmax — all through the shared Device trait on CUDA. +#[test] +fn ffai_ops_transformer_ops_on_cuda() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + + // ── silu ───────────────────────────────────────────────────────── + let g: Vec = (0..1024).map(|i| (i % 21) as f32 * 0.1 - 1.0).collect(); + let tg = Tensor::new(dev.upload(&to_bytes(&g)).unwrap(), vec![1024], DType::F32); + let ts = ffai_ops::silu(dev.as_ref(), &tg).unwrap(); + dev.synchronize().unwrap(); + let mut sb = vec![0u8; 1024 * 4]; + dev.download(ts.buffer.as_ref(), &mut sb).unwrap(); + let s = from_bytes(&sb); + let mut e = 0.0f32; + for i in 0..1024 { + e = e.max((s[i] - g[i] * sigmoid(g[i])).abs()); + } + assert!(e <= 1e-5, "silu mismatch: {e:.2e}"); + eprintln!("ffai_ops::silu (mt_silu) on CUDA: OK (max|Δ|={e:.1e})"); + + // ── swiglu ─────────────────────────────────────────────────────── + let up: Vec = (0..1024).map(|i| (i % 13) as f32 * 0.05).collect(); + let tu = Tensor::new(dev.upload(&to_bytes(&up)).unwrap(), vec![1024], DType::F32); + let tw = ffai_ops::swiglu(dev.as_ref(), &tg, &tu).unwrap(); + dev.synchronize().unwrap(); + let mut wb = vec![0u8; 1024 * 4]; + dev.download(tw.buffer.as_ref(), &mut wb).unwrap(); + let w = from_bytes(&wb); + e = 0.0; + for i in 0..1024 { + e = e.max((w[i] - g[i] * sigmoid(g[i]) * up[i]).abs()); + } + assert!(e <= 1e-5, "swiglu mismatch: {e:.2e}"); + eprintln!("ffai_ops::swiglu (mt_swiglu) on CUDA: OK (max|Δ|={e:.1e})"); + + // ── gather (embedding) ─────────────────────────────────────────── + const VOCAB: usize = 8; + const DIM: usize = 512; + let table: Vec = (0..VOCAB * DIM).map(|i| i as f32 * 0.001).collect(); + let ids: [u32; 4] = [2, 5, 0, 7]; + let id_bytes: Vec = ids.iter().flat_map(|v| v.to_le_bytes()).collect(); + let tt = Tensor::new(dev.upload(&to_bytes(&table)).unwrap(), vec![VOCAB, DIM], DType::F32); + let ti = Tensor::new(dev.upload(&id_bytes).unwrap(), vec![4], DType::U32); + let tge = ffai_ops::gather(dev.as_ref(), &tt, &ti).unwrap(); + dev.synchronize().unwrap(); + let mut gb = vec![0u8; 4 * DIM * 4]; + dev.download(tge.buffer.as_ref(), &mut gb).unwrap(); + let go = from_bytes(&gb); + e = 0.0; + for (t, &id) in ids.iter().enumerate() { + for d in 0..DIM { + e = e.max((go[t * DIM + d] - table[id as usize * DIM + d]).abs()); + } + } + assert!(e == 0.0, "gather mismatch: {e:.2e}"); + eprintln!("ffai_ops::gather (ffai_gather) on CUDA: OK (exact)"); + + // ── softmax ────────────────────────────────────────────────────── + const ROWS: usize = 2; + const NW: usize = 1024; + let xs: Vec = (0..ROWS * NW).map(|i| ((i % 37) as f32 - 18.0) * 0.1).collect(); + let txs = Tensor::new(dev.upload(&to_bytes(&xs)).unwrap(), vec![ROWS, NW], DType::F32); + let tsm = ffai_ops::softmax(dev.as_ref(), &txs).unwrap(); + dev.synchronize().unwrap(); + let mut smb = vec![0u8; ROWS * NW * 4]; + dev.download(tsm.buffer.as_ref(), &mut smb).unwrap(); + let sm = from_bytes(&smb); + e = 0.0; + for r in 0..ROWS { + let row = &xs[r * NW..(r + 1) * NW]; + let m = row.iter().cloned().fold(f32::MIN, f32::max); + let exps: Vec = row.iter().map(|v| (v - m).exp()).collect(); + let sum: f32 = exps.iter().sum(); + for j in 0..NW { + e = e.max((sm[r * NW + j] - exps[j] / sum).abs()); + } + } + assert!(e <= 1e-5, "softmax mismatch: {e:.2e}"); + eprintln!("ffai_ops::softmax (mt_softmax) on CUDA: OK (max|Δ|={e:.1e})"); +} + +/// Decode-time attention (sdpa_decode) on CUDA vs a CPU reference, single +/// q-head / single kv-head, head_dim=64. The gatekeeper op for a real +/// transformer forward. +#[test] +fn ffai_ops_sdpa_decode_on_cuda() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + + const HD: usize = 64; + const NKV: usize = 128; + let scale = 1.0f32 / (HD as f32).sqrt(); + + let q: Vec = (0..HD).map(|d| ((d % 9) as f32 - 4.0) * 0.05).collect(); + let kc: Vec = (0..NKV * HD).map(|i| ((i % 17) as f32 - 8.0) * 0.02).collect(); + let vc: Vec = (0..NKV * HD).map(|i| ((i % 11) as f32 - 5.0) * 0.03).collect(); + + let tq = Tensor::new(dev.upload(&to_bytes(&q)).unwrap(), vec![1, HD], DType::F32); + let tk = Tensor::new(dev.upload(&to_bytes(&kc)).unwrap(), vec![NKV, HD], DType::F32); + let tv = Tensor::new(dev.upload(&to_bytes(&vc)).unwrap(), vec![NKV, HD], DType::F32); + + // n_kv=128, kv_stride=128 (cache exactly filled), heads_per_group=1. + let tout = + ffai_ops::sdpa_decode(dev.as_ref(), &tq, &tk, &tv, HD, NKV as u32, NKV as u32, 1, scale) + .unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; HD * 4]; + dev.download(tout.buffer.as_ref(), &mut ob).unwrap(); + let got = from_bytes(&ob); + + // CPU reference: scores = scale·(q·k[t]); softmax; out = Σ p[t]·v[t]. + let mut scores = vec![0.0f32; NKV]; + for t in 0..NKV { + let dot: f32 = (0..HD).map(|d| q[d] * kc[t * HD + d]).sum(); + scores[t] = scale * dot; + } + let m = scores.iter().cloned().fold(f32::MIN, f32::max); + let exps: Vec = scores.iter().map(|s| (s - m).exp()).collect(); + let sum: f32 = exps.iter().sum(); + let mut want = vec![0.0f32; HD]; + for t in 0..NKV { + let p = exps[t] / sum; + for d in 0..HD { + want[d] += p * vc[t * HD + d]; + } + } + + let mut err = 0.0f32; + for d in 0..HD { + err = err.max((got[d] - want[d]).abs()); + } + assert!(err <= 1e-4, "sdpa_decode on CUDA mismatch: max|Δ|={err:.3e}"); + eprintln!("ffai_ops::sdpa_decode (ffai_sdpa_decode_d64) on CUDA: OK (max|Δ|={err:.1e})"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/dsv4_layer.rs b/rust/crates/backends/ffai-cuda/tests/dsv4_layer.rs new file mode 100644 index 00000000..06072644 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/dsv4_layer.rs @@ -0,0 +1,68 @@ +#![cfg(feature = "cuda")] +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full DSv4 attention SUBBLOCK (mHC ⊗ MLA): mHC mix → sinkhorn split → +//! collapse → MLA → expand → new 4-channel state. On Metal vs CPU. This is +//! the complete DSv4 attention layer unit. pos=0 → RoPE identity (validates +//! the layer wiring; RoPE verified separately). +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_models::dsv4::{dsv4_attn_subblock, MhcWeights, MlaConfig, MlaWeights}; +use ffai_ops::dsv4_mhc_sinkhorn_split; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn fill(n:usize,s:usize)->Vec{(0..n).map(|i|(((i*7+s*131)%89) as f32-44.0)*0.008).collect()} +fn tn(d:&dyn Device,v:&[f32],sh:Vec)->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),sh,DType::F32)} +fn rms(x:&[f32],w:&[f32],eps:f32)->Vec{let n=x.len();let ms:f32=x.iter().map(|v|v*v).sum::()/n as f32;let s=1.0/(ms+eps).sqrt();(0..n).map(|i|x[i]*s*w[i]).collect()} +fn mv(m:&[f32],v:&[f32],r:usize,k:usize)->Vec{(0..r).map(|i|(0..k).map(|c|m[i*k+c]*v[c]).sum()).collect()} + +#[allow(clippy::too_many_arguments)] +fn cpu_mla(x:&[f32], an:&[f32], qa:&[f32], qan:&[f32], qb:&[f32], kv:&[f32], kvan:&[f32], sink:&[f32], oa:&[Vec], ob:&[f32], cfg:&MlaConfig)->Vec{ + let (h,hd,ql,qd,ol,og)=(cfg.hidden,cfg.head_dim,cfg.q_lora_rank,cfg.n_heads*cfg.head_dim,cfg.o_lora_rank,cfg.o_groups); + let gsize=qd/og; let xn=rms(x,an,cfg.eps); + let q0=mv(qb,&rms(&mv(qa,&xn,ql,h),qan,cfg.eps),qd,ql); + let mut q=q0.clone(); + for hh in 0..cfg.n_heads{let row=&q0[hh*hd..(hh+1)*hd];let ms:f32=row.iter().map(|v|v*v).sum::()/hd as f32;let s=1.0/(ms+cfg.eps).sqrt();for d in 0..hd{q[hh*hd+d]=row[d]*s;}} + let kvn=rms(&mv(kv,&xn,hd,h),kvan,cfg.eps); let scale=1.0/(hd as f32).sqrt(); + let mut attn=vec![0.0f32;qd]; + for hh in 0..cfg.n_heads{let sc=scale*(0..hd).map(|d|q[hh*hd+d]*kvn[d]).sum::();let m=sc.max(sink[hh]);let p=(sc-m).exp()/((sc-m).exp()+(sink[hh]-m).exp());for d in 0..hd{attn[hh*hd+d]=p*kvn[d];}} + let mut olow=vec![0.0f32;og*ol]; + for g in 0..og{let s=&attn[g*gsize..(g+1)*gsize];let r=mv(&oa[g],s,ol,gsize);olow[g*ol..(g+1)*ol].copy_from_slice(&r);} + mv(ob,&olow,h,og*ol) +} + +#[test] +fn dsv4_attn_subblock_on_cuda_matches_cpu(){ + let Some(dev)=CudaDevice::create().expect("metal init") else { eprintln!("no CUDA — skip"); return; }; + let cfg=MlaConfig{hidden:512,n_heads:2,head_dim:512,q_lora_rank:256,n_nope:448,half_rot:32,o_lora_rank:64,o_groups:8,rope_theta:10000.0,eps:1e-6}; + let (h,hd,ql,qd,ol,og)=(cfg.hidden,cfg.head_dim,cfg.q_lora_rank,cfg.n_heads*cfg.head_dim,cfg.o_lora_rank,cfg.o_groups); + let nhc=4; let gsize=qd/og; let eps=1e-6f32; let iters=20u32; + // weights + let an=fill(h,1);let qa=fill(ql*h,2);let qan=fill(ql,3);let qb=fill(qd*ql,4);let kv=fill(hd*h,5);let kvan=fill(hd,6);let sink=vec![0.4f32,-0.2]; + let oa:Vec>=(0..og).map(|g|fill(ol*gsize,20+g)).collect();let ob=fill(h*(og*ol),40); + let hc_fn=fill(24*nhc*h,7);let hc_scale=[0.5f32,0.7,0.9];let hc_base=fill(24,8); + let hc_state=fill(nhc*h,99); + + let mla=MlaWeights{attn_norm:tn(dev.as_ref(),&an,vec![h]),q_a:tn(dev.as_ref(),&qa,vec![ql,h]),q_a_norm:tn(dev.as_ref(),&qan,vec![ql]),q_b:tn(dev.as_ref(),&qb,vec![qd,ql]),kv:tn(dev.as_ref(),&kv,vec![hd,h]),kv_a_norm:tn(dev.as_ref(),&kvan,vec![hd]),sink:tn(dev.as_ref(),&sink,vec![2]),output_a:oa.iter().map(|g|tn(dev.as_ref(),g,vec![ol,gsize])).collect(),output_b:tn(dev.as_ref(),&ob,vec![h,og*ol])}; + let mhc=MhcWeights{hc_fn:tn(dev.as_ref(),&hc_fn,vec![24,nhc*h]),hc_scale,hc_base:hc_base.clone()}; + let ts=tn(dev.as_ref(),&hc_state,vec![nhc,h]); + + let out=dsv4_attn_subblock(dev.as_ref(),&cfg,&mhc,&mla,&ts,0,eps,iters).unwrap(); + dev.synchronize().unwrap(); + let mut ob_b=vec![0u8;nhc*h*4];dev.download(out.buffer.as_ref(),&mut ob_b).unwrap(); + let got=fb(&ob_b); + + // CPU ref + let mixes=mv(&hc_fn,&hc_state,24,nhc*h); + let (pre,post,comb)=dsv4_mhc_sinkhorn_split(&mixes,hc_scale,&hc_base,eps,iters); + let x:Vec=(0..h).map(|d|(0..nhc).map(|c|pre[c]*hc_state[c*h+d]).sum()).collect(); + let blk=cpu_mla(&x,&an,&qa,&qan,&qb,&kv,&kvan,&sink,&oa,&ob,&cfg); + let mut want=vec![0.0f32;nhc*h]; + for dst in 0..nhc{for d in 0..h{let mut a=blk[d]*post[dst];for src in 0..nhc{a+=comb[dst*nhc+src]*hc_state[src*h+d];}want[dst*h+d]=a;}} + + let mut e=0.0f32;for i in 0..nhc*h{e=e.max((got[i]-want[i]).abs());} + eprintln!("DSv4 attn subblock (mHC⊗MLA) on CUDA vs CPU: max|Δ|={e:.3e}"); + assert!(e<=5e-3,"subblock mismatch: {e:.3e}"); + eprintln!("✅ Full DSv4 attention layer unit runs on CUDA through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/dsv4_mla.rs b/rust/crates/backends/ffai-cuda/tests/dsv4_mla.rs new file mode 100644 index 00000000..b9671189 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/dsv4_mla.rs @@ -0,0 +1,77 @@ +#![cfg(feature = "cuda")] +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full DSv4 MLA attention composite on CUDA vs CPU. pos=0 → RoPE is +//! identity, so this validates the COMPOSITION wiring (q low-rank → +//! per-head q-norm → sink-SDPA → grouped O-LoRA); RoPE itself is verified +//! separately in dsv4_test. +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_models::dsv4::{mla_attention, MlaConfig, MlaWeights}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn fill(n: usize, s: usize) -> Vec { (0..n).map(|i| (((i * 7 + s * 131) % 89) as f32 - 44.0) * 0.01).collect() } +fn tn(d: &dyn Device, v: &[f32], shape: Vec) -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), shape, DType::F32) } +fn rms(x: &[f32], w: &[f32], eps: f32) -> Vec { + let n = x.len(); let ms: f32 = x.iter().map(|v| v*v).sum::()/n as f32; let s = 1.0/(ms+eps).sqrt(); + (0..n).map(|i| x[i]*s*w[i]).collect() +} +fn mv(m: &[f32], v: &[f32], rows: usize, k: usize) -> Vec { (0..rows).map(|r| (0..k).map(|c| m[r*k+c]*v[c]).sum()).collect() } + +#[test] +fn dsv4_mla_attention_on_cuda_matches_cpu() { + let Some(dev) = CudaDevice::create().expect("metal init") else { eprintln!("no CUDA — skip"); return; }; + let cfg = MlaConfig { hidden:512, n_heads:2, head_dim:512, q_lora_rank:256, n_nope:448, half_rot:32, o_lora_rank:64, o_groups:8, rope_theta:10000.0, eps:1e-6 }; + let (h, hd, ql, qd, ol, og) = (cfg.hidden, cfg.head_dim, cfg.q_lora_rank, cfg.n_heads*cfg.head_dim, cfg.o_lora_rank, cfg.o_groups); + let gsize = qd / og; + + let attn_norm = fill(h,1); let q_a = fill(ql*h,2); let q_a_norm = fill(ql,3); let q_b = fill(qd*ql,4); + let kv = fill(hd*h,5); let kv_a_norm = fill(hd,6); let sink = vec![0.4f32,-0.2]; + let output_a: Vec> = (0..og).map(|g| fill(ol*gsize, 20+g)).collect(); + let output_b = fill(h*(og*ol), 40); + let x = fill(h, 99); + + let w = MlaWeights { + attn_norm: tn(dev.as_ref(),&attn_norm,vec![h]), + q_a: tn(dev.as_ref(),&q_a,vec![ql,h]), q_a_norm: tn(dev.as_ref(),&q_a_norm,vec![ql]), + q_b: tn(dev.as_ref(),&q_b,vec![qd,ql]), + kv: tn(dev.as_ref(),&kv,vec![hd,h]), kv_a_norm: tn(dev.as_ref(),&kv_a_norm,vec![hd]), + sink: tn(dev.as_ref(),&sink,vec![2]), + output_a: output_a.iter().map(|g| tn(dev.as_ref(),g,vec![ol,gsize])).collect(), + output_b: tn(dev.as_ref(),&output_b,vec![h,og*ol]), + }; + let tx = tn(dev.as_ref(),&x,vec![h]); + let out = mla_attention(dev.as_ref(), &cfg, &w, &tx, 0).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; h*4]; dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU ref (pos=0 → rope identity). + let xn = rms(&x, &attn_norm, cfg.eps); + let qa = mv(&q_a,&xn,ql,h); let qan = rms(&qa,&q_a_norm,cfg.eps); let qf = mv(&q_b,&qan,qd,ql); + let mut q = qf.clone(); + for hh in 0..cfg.n_heads { // per-head unit RMS (ones weight) + let row = &qf[hh*hd..(hh+1)*hd]; + let ms: f32 = row.iter().map(|v| v*v).sum::()/hd as f32; let s = 1.0/(ms+cfg.eps).sqrt(); + for d in 0..hd { q[hh*hd+d] = row[d]*s; } + } + let kvn = rms(&mv(&kv,&xn,hd,h), &kv_a_norm, cfg.eps); + let scale = 1.0/(hd as f32).sqrt(); + let mut attn = vec![0.0f32; qd]; + for hh in 0..cfg.n_heads { + let score = scale * (0..hd).map(|d| q[hh*hd+d]*kvn[d]).sum::(); + let m = score.max(sink[hh]); + let p = (score-m).exp() / ((score-m).exp() + (sink[hh]-m).exp()); + for d in 0..hd { attn[hh*hd+d] = p*kvn[d]; } + } + // grouped O + let mut o_low = vec![0.0f32; og*ol]; + for g in 0..og { let s = &attn[g*gsize..(g+1)*gsize]; let r = mv(&output_a[g], s, ol, gsize); o_low[g*ol..(g+1)*ol].copy_from_slice(&r); } + let want = mv(&output_b, &o_low, h, og*ol); + + let mut e = 0.0f32; for i in 0..h { e = e.max((got[i]-want[i]).abs()); } + eprintln!("DSv4 MLA attention on CUDA vs CPU: max|Δ|={e:.3e}"); + assert!(e <= 5e-3, "mla mismatch: {e:.3e}"); + eprintln!("✅ DSv4 MLA attention composite runs on CUDA through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/dsv4_moe.rs b/rust/crates/backends/ffai-cuda/tests/dsv4_moe.rs new file mode 100644 index 00000000..bbc943bf --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/dsv4_moe.rs @@ -0,0 +1,60 @@ +#![cfg(feature = "cuda")] +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! DSv4 MoE feed-forward (sqrtsoftplus route + clamped-SwiGLU experts + +//! shared expert) on CUDA vs CPU. +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_models::dsv4::{dsv4_moe, Dsv4Expert, Dsv4Moe}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn fill(n: usize, s: usize) -> Vec { (0..n).map(|i| (((i*7+s*131)%89) as f32 - 44.0)*0.01).collect() } +fn tn(d: &dyn Device, v: &[f32], shape: Vec) -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), shape, DType::F32) } +fn mv(m:&[f32],v:&[f32],r:usize,k:usize)->Vec{(0..r).map(|i|(0..k).map(|c|m[i*k+c]*v[c]).sum()).collect()} +fn sl(g:&[f32],u:&[f32],lim:f32)->Vec{(0..g.len()).map(|i|{let gl=g[i].min(lim);let s=gl/(1.0+(-gl).exp());s*u[i].clamp(-lim,lim)}).collect()} + +#[test] +fn dsv4_moe_on_cuda_matches_cpu() { + let Some(dev)=CudaDevice::create().expect("metal init") else { eprintln!("no CUDA — skip"); return; }; + let (h, im, ne, tk) = (256usize, 512usize, 8usize, 2usize); + let (rs, lim) = (1.5f32, 10.0f32); + let router = fill(ne*h, 1); + let bias: Vec = (0..ne).map(|i| (i as f32)*0.03 - 0.1).collect(); + let ex: Vec<(Vec,Vec,Vec)> = (0..ne).map(|e| (fill(im*h,10+e), fill(im*h,30+e), fill(h*im,50+e))).collect(); + let sh = (fill(im*h,200), fill(im*h,210), fill(h*im,220)); + let x = fill(h, 99); + + let mk = |g:&Vec,u:&Vec,d:&Vec| Dsv4Expert{gate:tn(dev.as_ref(),g,vec![im,h]),up:tn(dev.as_ref(),u,vec![im,h]),down:tn(dev.as_ref(),d,vec![h,im])}; + let w = Dsv4Moe { + router: tn(dev.as_ref(),&router,vec![ne,h]), bias: bias.clone(), + experts: ex.iter().map(|(g,u,d)| mk(g,u,d)).collect(), + shared: mk(&sh.0,&sh.1,&sh.2), top_k: tk, routed_scaling: rs, swiglu_limit: lim, + }; + let tx = tn(dev.as_ref(),&x,vec![h]); + let out = dsv4_moe(dev.as_ref(), &w, &tx).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; h*4]; dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU ref + let logits = mv(&router,&x,ne,h); + let unb: Vec = logits.iter().map(|&v| (v.max(0.0)+(1.0+(-v.abs()).exp()).ln()).sqrt()).collect(); + let bia: Vec = unb.iter().zip(&bias).map(|(u,b)| u+b).collect(); + let mut ord: Vec = (0..ne).collect(); ord.sort_by(|&a,&b| bia[b].total_cmp(&bia[a])); + let top: Vec = ord.into_iter().take(tk).collect(); + let den: f32 = top.iter().map(|&e| unb[e]).sum(); + let wts: Vec = top.iter().map(|&e| unb[e]/den*rs).collect(); + let mut acc = vec![0.0f32; h]; + for (&e,&gw) in top.iter().zip(&wts) { + let (g,u,d)=&ex[e]; let inner=sl(&mv(g,&x,im,h),&mv(u,&x,im,h),lim); let o=mv(d,&inner,h,im); + for i in 0..h { acc[i]+=gw*o[i]; } + } + let si=sl(&mv(&sh.0,&x,im,h),&mv(&sh.1,&x,im,h),lim); let so=mv(&sh.2,&si,h,im); + for i in 0..h { acc[i]+=so[i]; } + + let mut e=0.0f32; for i in 0..h { e=e.max((got[i]-acc[i]).abs()); } + eprintln!("DSv4 MoE on CUDA vs CPU: max|Δ|={e:.3e} (top {top:?})"); + assert!(e <= 5e-3, "dsv4 moe mismatch: {e:.3e}"); + eprintln!("✅ DSv4 MoE feed-forward runs on CUDA through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/dsv4_test.rs b/rust/crates/backends/ffai-cuda/tests/dsv4_test.rs new file mode 100644 index 00000000..7c121b9b --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/dsv4_test.rs @@ -0,0 +1,217 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! DeepSeek-V4 MLA primitive: partial RoPE on the rope tail, on Metal vs a +//! CPU reference. First validated DSv4-specific op on the shared layer. + +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_ops::{ + dsv4_mhc_collapse, dsv4_mhc_expand, dsv4_partial_rope, sdpa_decode_sink, sqrtsoftplus_route, + swiglu_limit, +}; + +fn tb(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn fb(b: &[u8]) -> Vec { + b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() +} + +#[test] +fn dsv4_partial_rope_on_metal_matches_cpu() { + let Some(dev) = CudaDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + const NH: usize = 4; + const HD: usize = 512; + const NOPE: usize = 448; + const HALF: usize = 32; // (HD-NOPE)/2 = 32, n_rot = 64 + let pos: u32 = 7; + let theta = 10_000.0f32; + + let qk: Vec = (0..NH * HD).map(|i| ((i % 23) as f32 - 11.0) * 0.05).collect(); + let tq = Tensor::new(dev.upload(&tb(&qk)).unwrap(), vec![NH, HD], DType::F32); + + let out = dsv4_partial_rope( + dev.as_ref(), &tq, NH as u32, HD as u32, NOPE as u32, HALF as u32, pos, theta, false, + ) + .unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; NH * HD * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU reference (matches the kernel's own test). + let mut want = qk.clone(); + for head in 0..NH { + for p in 0..HALF { + let inv_freq = (-(p as f32) * 2.0 * theta.ln() / (2.0 * HALF as f32)).exp(); + let th = pos as f32 * inv_freq; + let (c, s) = (th.cos(), th.sin()); + let lo = head * HD + NOPE + 2 * p; + let hi = lo + 1; + want[lo] = qk[lo] * c - qk[hi] * s; + want[hi] = qk[lo] * s + qk[hi] * c; + } + } + + let mut err = 0.0f32; + for i in 0..NH * HD { + err = err.max((got[i] - want[i]).abs()); + } + eprintln!("dsv4_partial_rope on Metal vs CPU: max|Δ|={err:.3e}"); + assert!(err <= 1e-4, "partial_rope mismatch: {err:.3e}"); + eprintln!("✅ DSv4 partial RoPE runs on GB10 sm_121 through the shared op layer, matches CPU."); +} + +#[test] +fn dsv4_sink_sdpa_on_metal_matches_cpu() { + let Some(dev) = CudaDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + const NQ: usize = 2; + const HD: usize = 512; + const NKV: usize = 64; + const HPG: usize = 2; // n_kv_heads = 1 + let scale = 1.0f32 / (HD as f32).sqrt(); + + let q: Vec = (0..NQ * HD).map(|i| ((i % 19) as f32 - 9.0) * 0.03).collect(); + let kc: Vec = (0..NKV * HD).map(|i| ((i % 23) as f32 - 11.0) * 0.02).collect(); + let vc: Vec = (0..NKV * HD).map(|i| ((i % 13) as f32 - 6.0) * 0.025).collect(); + let sink: Vec = vec![0.5, -0.3]; + + let tq = Tensor::new(dev.upload(&tb(&q)).unwrap(), vec![NQ, HD], DType::F32); + let tk = Tensor::new(dev.upload(&tb(&kc)).unwrap(), vec![NKV, HD], DType::F32); + let tv = Tensor::new(dev.upload(&tb(&vc)).unwrap(), vec![NKV, HD], DType::F32); + let ts = Tensor::new(dev.upload(&tb(&sink)).unwrap(), vec![NQ], DType::F32); + + let out = sdpa_decode_sink(dev.as_ref(), &tq, &tk, &tv, &ts, NKV as u32, NKV as u32, HPG as u32, scale).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; NQ * HD * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU reference (single kv head; sink extends the denominator). + let mut want = vec![0.0f32; NQ * HD]; + for h in 0..NQ { + let scores: Vec = (0..NKV) + .map(|t| scale * (0..HD).map(|d| q[h * HD + d] * kc[t * HD + d]).sum::()) + .collect(); + let m0 = scores.iter().cloned().fold(f32::MIN, f32::max); + let m = m0.max(sink[h]); + let exps: Vec = scores.iter().map(|s| (s - m).exp()).collect(); + let denom: f32 = exps.iter().sum::() + (sink[h] - m).exp(); + for t in 0..NKV { + let p = exps[t] / denom; + for d in 0..HD { + want[h * HD + d] += p * vc[t * HD + d]; + } + } + } + + let mut err = 0.0f32; + for i in 0..NQ * HD { + err = err.max((got[i] - want[i]).abs()); + } + eprintln!("dsv4 sink-SDPA on Metal vs CPU: max|Δ|={err:.3e}"); + assert!(err <= 1e-4, "sink sdpa mismatch: {err:.3e}"); + eprintln!("✅ DSv4 d512 sink-SDPA runs on GB10 sm_121 through the shared op layer, matches CPU."); +} + +#[test] +fn dsv4_moe_ops_on_metal_match_cpu() { + let Some(dev) = CudaDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let lim = 10.0f32; + let sig = |x: f32| x / (1.0 + (-x).exp()); + + // ── swiglu_limit ── + let g: Vec = (0..1024).map(|i| (i % 41) as f32 * 0.8 - 16.0).collect(); + let u: Vec = (0..1024).map(|i| (i % 37) as f32 * 0.9 - 16.0).collect(); + let tg = Tensor::new(dev.upload(&tb(&g)).unwrap(), vec![1024], DType::F32); + let tu = Tensor::new(dev.upload(&tb(&u)).unwrap(), vec![1024], DType::F32); + let out = swiglu_limit(dev.as_ref(), &tg, &tu, lim).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; 1024 * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + let mut e = 0.0f32; + for i in 0..1024 { + let want = sig(g[i].min(lim)) * u[i].clamp(-lim, lim); + e = e.max((got[i] - want).abs()); + } + assert!(e <= 1e-5, "swiglu_limit mismatch: {e:.3e}"); + eprintln!("✅ DSv4 swiglu_limit on CUDA: max|Δ|={e:.1e}"); + + // ── sqrtsoftplus router (host-side) ── + let logits: Vec = (0..8).map(|i| (i as f32 - 4.0) * 1.3).collect(); + let bias: Vec = (0..8).map(|i| (i as f32) * 0.05 - 0.2).collect(); + let (unb, bia) = sqrtsoftplus_route(&logits, &bias); + let mut e2 = 0.0f32; + for i in 0..8 { + let sp = logits[i].max(0.0) + (1.0 + (-logits[i].abs()).exp()).ln(); + let un = sp.sqrt(); + e2 = e2.max((unb[i] - un).abs()).max((bia[i] - (un + bias[i])).abs()); + } + assert!(e2 <= 1e-6, "router mismatch: {e2:.3e}"); + eprintln!("✅ DSv4 sqrtsoftplus router (host) matches reference: max|Δ|={e2:.1e}"); +} + +#[test] +fn dsv4_mhc_on_metal_matches_cpu() { + let Some(dev) = CudaDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + const H: usize = 512; // multiple of 256 + const NHC: usize = 4; + + // ── collapse ── + let state: Vec = (0..NHC * H).map(|i| ((i % 17) as f32 - 8.0) * 0.1).collect(); + let pre: Vec = vec![0.6, 0.9, 0.3, 1.1]; + let ts = Tensor::new(dev.upload(&tb(&state)).unwrap(), vec![NHC, H], DType::F32); + let tp = Tensor::new(dev.upload(&tb(&pre)).unwrap(), vec![NHC], DType::F32); + let out = dsv4_mhc_collapse(dev.as_ref(), &ts, &tp, H as u32, NHC as u32).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; H * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + let mut e = 0.0f32; + for d in 0..H { + let want: f32 = (0..NHC).map(|c| pre[c] * state[c * H + d]).sum(); + e = e.max((got[d] - want).abs()); + } + assert!(e <= 1e-4, "collapse mismatch: {e:.3e}"); + eprintln!("✅ DSv4 mHC collapse on CUDA: max|Δ|={e:.1e}"); + + // ── expand ── + let block_out: Vec = (0..H).map(|i| ((i % 13) as f32 - 6.0) * 0.05).collect(); + let post: Vec = vec![1.2, 0.8, 1.0, 0.5]; + let comb: Vec = (0..NHC * NHC).map(|i| ((i % 7) as f32 - 3.0) * 0.1).collect(); + let resid: Vec = (0..NHC * H).map(|i| ((i % 11) as f32 - 5.0) * 0.07).collect(); + let tbo = Tensor::new(dev.upload(&tb(&block_out)).unwrap(), vec![H], DType::F32); + let tpo = Tensor::new(dev.upload(&tb(&post)).unwrap(), vec![NHC], DType::F32); + let tco = Tensor::new(dev.upload(&tb(&comb)).unwrap(), vec![NHC * NHC], DType::F32); + let tr = Tensor::new(dev.upload(&tb(&resid)).unwrap(), vec![NHC, H], DType::F32); + let st = dsv4_mhc_expand(dev.as_ref(), &tbo, &tpo, &tco, &tr, H as u32, NHC as u32).unwrap(); + dev.synchronize().unwrap(); + let mut sb = vec![0u8; NHC * H * 4]; + dev.download(st.buffer.as_ref(), &mut sb).unwrap(); + let gs = fb(&sb); + let mut e3 = 0.0f32; + for dst in 0..NHC { + for d in 0..H { + let mut want = block_out[d] * post[dst]; + for src in 0..NHC { + want += comb[dst * NHC + src] * resid[src * H + d]; + } + e3 = e3.max((gs[dst * H + d] - want).abs()); + } + } + assert!(e3 <= 1e-4, "expand mismatch: {e3:.3e}"); + eprintln!("✅ DSv4 mHC expand on CUDA: max|Δ|={e3:.1e}"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/f16norm_f32in.rs b/rust/crates/backends/ffai-cuda/tests/f16norm_f32in.rs new file mode 100644 index 00000000..95395d61 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/f16norm_f32in.rs @@ -0,0 +1,70 @@ +#![cfg(feature = "cuda")] +//! Isolate add_rms_norm_f16norm with REAL in-model f32 inputs (a/b/w all f32). +//! Compares residual + normed against a CPU oracle AND the working add_rms_norm. +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_ops::{add_rms_norm, add_rms_norm_f16norm}; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb32(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn f16_to_f32(bits:u16)->f32{ + let sign=((bits>>15)&1) as u32; let exp=((bits>>10)&0x1f) as u32; let mant=(bits&0x3ff) as u32; + let f=if exp==0 { if mant==0 { sign<<31 } else { // subnormal + let mut e=-1i32; let mut m=mant; while m & 0x400 ==0 { m<<=1; e-=1; } m&=0x3ff; + ((sign<<31)|(((e+127+(-14))as u32)<<23)|(m<<13)) } } + else if exp==0x1f { (sign<<31)|0x7f800000|(mant<<13) } + else { (sign<<31)|((exp+(127-15))<<23)|(mant<<13) }; + f32::from_bits(f) +} +fn fb16(b:&[u8])->Vec{b.chunks_exact(2).map(|c|f16_to_f32(u16::from_le_bytes([c[0],c[1]]))).collect()} +fn tn(d:&dyn Device,v:&[f32],sh:Vec)->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),sh,DType::F32)} + +#[test] +fn f16norm_f32_inputs_matches_oracle(){ + let Some(dev)=CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + let (rows, n) = (8usize, 4096usize); // real hidden size + let eps = 1e-5f32; + let a: Vec = (0..rows*n).map(|i| ((i % 13) as f32 * 0.07 - 0.4)).collect(); + let b: Vec = (0..rows*n).map(|i| ((i % 7) as f32 * 0.05 - 0.15)).collect(); + let w: Vec = (0..n).map(|i| 0.5 + (i % 5) as f32 * 0.1).collect(); + // CPU oracle + let mut res_ref = vec![0f32; rows*n]; let mut nrm_ref = vec![0f32; rows*n]; + for r in 0..rows { let mut ssq=0f32; for c in 0..n { let s=a[r*n+c]+b[r*n+c]; res_ref[r*n+c]=s; ssq+=s*s; } + let rms=1f32/(ssq/n as f32+eps).sqrt(); for c in 0..n { nrm_ref[r*n+c]=res_ref[r*n+c]*rms*w[c]; } } + + let at=tn(d,&a,vec![rows,n]); let bt=tn(d,&b,vec![rows,n]); let wt=tn(d,&w,vec![n]); + // f32 control + let (r32, n32) = add_rms_norm(d, &at, &bt, &wt, eps).unwrap(); + // fused f16norm + let (rf, nf) = add_rms_norm_f16norm(d, &at, &bt, &wt, eps).unwrap(); + d.synchronize().unwrap(); + assert_eq!(rf.dtype, DType::F32, "residual must be f32"); + assert_eq!(nf.dtype, DType::F16, "normed must be f16"); + + let mut g=vec![0u8;rows*n*4]; d.download(rf.buffer.as_ref(),&mut g).unwrap(); let res_got=fb32(&g); + let mut h=vec![0u8;rows*n*2]; d.download(nf.buffer.as_ref(),&mut h).unwrap(); let nrm_got=fb16(&h); + let mut c32=vec![0u8;rows*n*4]; d.download(r32.buffer.as_ref(),&mut c32).unwrap(); let res_c=fb32(&c32); + let mut cn=vec![0u8;rows*n*4]; d.download(n32.buffer.as_ref(),&mut cn).unwrap(); let nrm_c=fb32(&cn); + + // EXACT-FUSION CHECK: fused f16 normed must equal cast_f32_f16(f32wrap normed) bit-for-bit. + let nrm_c_f16 = ffai_ops::cast_f32_f16(d, &n32).unwrap(); + d.synchronize().unwrap(); + let mut hc=vec![0u8;rows*n*2]; d.download(nrm_c_f16.buffer.as_ref(),&mut hc).unwrap(); + let mut hf=vec![0u8;rows*n*2]; d.download(nf.buffer.as_ref(),&mut hf).unwrap(); + let bitdiff = hc.iter().zip(hf.iter()).filter(|(a,b)| a!=b).count(); + eprintln!("FUSED vs cast_f32_f16(f32wrap): differing f16 BYTES = {bitdiff} / {}", rows*n*2); + let res_nan=res_got.iter().any(|x|!x.is_finite()); + let nrm_nan=nrm_got.iter().any(|x|!x.is_finite()); + let mut res_err=0f32; for i in 0..rows*n { res_err=res_err.max((res_got[i]-res_ref[i]).abs()); } + let mut nrm_err=0f32; for i in 0..rows*n { let d0=(nrm_got[i]-nrm_ref[i]).abs(); let den=nrm_ref[i].abs().max(1e-3); nrm_err=nrm_err.max(d0/den); } + let mut res_vs32=0f32; for i in 0..rows*n { res_vs32=res_vs32.max((res_got[i]-res_c[i]).abs()); } + eprintln!("f16norm f32-in: res_nan={res_nan} nrm_nan={nrm_nan} res|Δ|oracle={res_err:.3e} nrm_rel|Δ|oracle={nrm_err:.3e} res|Δ|vs_f32wrap={res_vs32:.3e}"); + eprintln!(" res_got[0..4]={:?} ref={:?}", &res_got[0..4], &res_ref[0..4]); + eprintln!(" nrm_got[0..4]={:?} ref={:?}", &nrm_got[0..4], &nrm_ref[0..4]); + eprintln!(" f32wrap nrm[0..4]={:?}", &nrm_c[0..4]); + assert!(!res_nan && !nrm_nan, "NaN in fused outputs"); + assert!(res_err < 1e-3, "residual wrong vs oracle: {res_err}"); + assert!(nrm_err < 2e-2, "normed wrong vs oracle (f16 tol): {nrm_err}"); + assert!(res_vs32 < 1e-3, "residual diverges from f32 wrapper: {res_vs32}"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/ffai_model.rs b/rust/crates/backends/ffai-cuda/tests/ffai_model.rs new file mode 100644 index 00000000..63b2ceae --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/ffai_model.rs @@ -0,0 +1,277 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! A full transformer decode layer (Qwen2-0.5B geometry) assembled from the +//! shared ffai-ops, run on CUDA through the Device trait, and checked against +//! an independent CPU reference. This is the proof that a model — not just an +//! op — runs on the shared layer: one builder (ffai_models::llama) + the op +//! seam produce a correct forward on real GB10 hardware. +#![cfg(feature = "cuda")] + +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_models::llama::{LayerWeights, LlamaConfig, ModelWeights, decode_layer_self, forward_single}; + +fn to_bytes(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn from_bytes(b: &[u8]) -> Vec { + b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() +} +/// Deterministic small values in ~[-0.04, 0.04], varied by salt. +fn fill(n: usize, salt: usize) -> Vec { + (0..n).map(|i| (((i * 7 + salt * 131) % 97) as f32 - 48.0) * 0.0008).collect() +} +fn tens(dev: &dyn Device, data: &[f32], shape: Vec) -> Tensor { + Tensor::new(dev.upload(&to_bytes(data)).unwrap(), shape, DType::F32) +} +fn silu(x: f32) -> f32 { + x / (1.0 + (-x).exp()) +} +/// out[m] = Σ_k mat[m*K + k] * vec[k] +fn matvec(mat: &[f32], vec: &[f32], m: usize, k: usize) -> Vec { + (0..m).map(|r| (0..k).map(|c| mat[r * k + c] * vec[c]).sum()).collect() +} +fn rmsnorm(x: &[f32], w: &[f32], eps: f32) -> Vec { + let n = x.len(); + let ms: f32 = x.iter().map(|v| v * v).sum::() / n as f32; + let s = 1.0 / (ms + eps).sqrt(); + (0..n).map(|i| x[i] * s * w[i]).collect() +} + +#[test] +fn qwen2_decode_layer_on_cuda_matches_cpu() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + + // Qwen2-0.5B geometry. + let cfg = LlamaConfig { + hidden: 896, + n_q_heads: 14, + n_kv_heads: 2, + head_dim: 64, + intermediate: 4864, + rope_theta: 1_000_000.0, + eps: 1e-6, + qk_norm: false, + attn_bias: false, + }; + let h = cfg.hidden; + let qd = cfg.n_q_heads * cfg.head_dim; // 896 + let kd = cfg.n_kv_heads * cfg.head_dim; // 128 + let im = cfg.intermediate; + + // Weights (deterministic). + let attn_norm = fill(h, 1); + let wq = fill(qd * h, 2); + let wk = fill(kd * h, 3); + let wv = fill(kd * h, 4); + let wo = fill(h * qd, 5); + let mlp_norm = fill(h, 6); + let w_gate = fill(im * h, 7); + let w_up = fill(im * h, 8); + let w_down = fill(h * im, 9); + let x = fill(h, 10); + + let weights = LayerWeights { + attn_norm: tens(dev.as_ref(), &attn_norm, vec![h]), + wq: tens(dev.as_ref(), &wq, vec![qd, h]), + wk: tens(dev.as_ref(), &wk, vec![kd, h]), + wv: tens(dev.as_ref(), &wv, vec![kd, h]), + wo: tens(dev.as_ref(), &wo, vec![h, qd]), + bias_q: None, + bias_k: None, + bias_v: None, + q_norm: None, + k_norm: None, + mlp_norm: tens(dev.as_ref(), &mlp_norm, vec![h]), + w_gate: tens(dev.as_ref(), &w_gate, vec![im, h]), + w_up: tens(dev.as_ref(), &w_up, vec![im, h]), + w_down: tens(dev.as_ref(), &w_down, vec![h, im]), + }; + let tx = tens(dev.as_ref(), &x, vec![h]); + + // ── GPU: the shared-layer forward ──────────────────────────────── + let out = decode_layer_self(dev.as_ref(), &cfg, &weights, &tx, 0).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; h * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = from_bytes(&ob); + + // ── CPU reference (pos=0 → RoPE identity, n_kv=1 → attn=v per group) ─ + let hh = rmsnorm(&x, &attn_norm, cfg.eps); + let q = matvec(&wq, &hh, qd, h); + let _k = matvec(&wk, &hh, kd, h); + let v = matvec(&wv, &hh, kd, h); + let _ = &q; // rope identity at pos 0; q/k unchanged, attn ignores q for n_kv=1 + // attn[q_head, d] = v[kv_head, d], kv_head = q_head / heads_per_group + let hpg = cfg.n_q_heads / cfg.n_kv_heads; + let mut attn = vec![0.0f32; qd]; + for qh in 0..cfg.n_q_heads { + let kvh = qh / hpg; + for d in 0..cfg.head_dim { + attn[qh * cfg.head_dim + d] = v[kvh * cfg.head_dim + d]; + } + } + let o = matvec(&wo, &attn, h, qd); + let x1: Vec = (0..h).map(|i| x[i] + o[i]).collect(); + let h2 = rmsnorm(&x1, &mlp_norm, cfg.eps); + let gate = matvec(&w_gate, &h2, im, h); + let up = matvec(&w_up, &h2, im, h); + let act: Vec = (0..im).map(|i| silu(gate[i]) * up[i]).collect(); + let down = matvec(&w_down, &act, h, im); + let want: Vec = (0..h).map(|i| x1[i] + down[i]).collect(); + + let mut err = 0.0f32; + let mut worst = 0usize; + for i in 0..h { + let d = (got[i] - want[i]).abs(); + if d > err { + err = d; + worst = i; + } + } + eprintln!( + "transformer decode layer on CUDA vs CPU: max|Δ|={err:.3e} at [{worst}] (got {:.5}, want {:.5})", + got[worst], want[worst] + ); + assert!(err <= 5e-3, "decode layer mismatch: max|Δ|={err:.3e}"); + eprintln!("✅ Qwen2-0.5B-shaped decode layer runs on GB10 through the shared op layer, matches CPU."); +} + +/// CPU-side weights for one layer (kept in sync with the GPU LayerWeights). +struct LW { + attn_norm: Vec, + wq: Vec, + wk: Vec, + wv: Vec, + wo: Vec, + mlp_norm: Vec, + w_gate: Vec, + w_up: Vec, + w_down: Vec, +} +fn gen_layer(cfg: &LlamaConfig, s: usize) -> LW { + let (h, qd, kd, im) = + (cfg.hidden, cfg.n_q_heads * cfg.head_dim, cfg.n_kv_heads * cfg.head_dim, cfg.intermediate); + LW { + attn_norm: fill(h, s), + wq: fill(qd * h, s + 1), + wk: fill(kd * h, s + 2), + wv: fill(kd * h, s + 3), + wo: fill(h * qd, s + 4), + mlp_norm: fill(h, s + 5), + w_gate: fill(im * h, s + 6), + w_up: fill(im * h, s + 7), + w_down: fill(h * im, s + 8), + } +} +fn gpu_layer(dev: &dyn Device, cfg: &LlamaConfig, lw: &LW) -> LayerWeights { + let (h, qd, kd, im) = + (cfg.hidden, cfg.n_q_heads * cfg.head_dim, cfg.n_kv_heads * cfg.head_dim, cfg.intermediate); + LayerWeights { + attn_norm: tens(dev, &lw.attn_norm, vec![h]), + wq: tens(dev, &lw.wq, vec![qd, h]), + wk: tens(dev, &lw.wk, vec![kd, h]), + wv: tens(dev, &lw.wv, vec![kd, h]), + wo: tens(dev, &lw.wo, vec![h, qd]), + bias_q: None, + bias_k: None, + bias_v: None, + q_norm: None, + k_norm: None, + mlp_norm: tens(dev, &lw.mlp_norm, vec![h]), + w_gate: tens(dev, &lw.w_gate, vec![im, h]), + w_up: tens(dev, &lw.w_up, vec![im, h]), + w_down: tens(dev, &lw.w_down, vec![h, im]), + } +} +/// CPU reference for one decode layer (pos=0, n_kv=1). +fn cpu_layer(cfg: &LlamaConfig, x: &[f32], lw: &LW) -> Vec { + let (h, qd, kd, im) = + (cfg.hidden, cfg.n_q_heads * cfg.head_dim, cfg.n_kv_heads * cfg.head_dim, cfg.intermediate); + let hh = rmsnorm(x, &lw.attn_norm, cfg.eps); + let v = matvec(&lw.wv, &hh, kd, h); + let hpg = cfg.n_q_heads / cfg.n_kv_heads; + let mut attn = vec![0.0f32; qd]; + for qh in 0..cfg.n_q_heads { + let kvh = qh / hpg; + for d in 0..cfg.head_dim { + attn[qh * cfg.head_dim + d] = v[kvh * cfg.head_dim + d]; + } + } + let o = matvec(&lw.wo, &attn, h, qd); + let x1: Vec = (0..h).map(|i| x[i] + o[i]).collect(); + let h2 = rmsnorm(&x1, &lw.mlp_norm, cfg.eps); + let gate = matvec(&lw.w_gate, &h2, im, h); + let up = matvec(&lw.w_up, &h2, im, h); + let act: Vec = (0..im).map(|i| silu(gate[i]) * up[i]).collect(); + let down = matvec(&lw.w_down, &act, h, im); + (0..h).map(|i| x1[i] + down[i]).collect() +} + +/// The FULL model graph — embedding → N layers → final norm → lm_head → +/// logits — run on CUDA via forward_single and checked against CPU. Proves +/// the whole transformer (not just a layer) runs on the shared op layer. +#[test] +fn qwen2_full_forward_logits_on_cuda_matches_cpu() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + let cfg = LlamaConfig { + hidden: 896, + n_q_heads: 14, + n_kv_heads: 2, + head_dim: 64, + intermediate: 4864, + rope_theta: 1_000_000.0, + eps: 1e-6, + qk_norm: false, + attn_bias: false, + }; + const VOCAB: usize = 2048; + const N_LAYERS: usize = 2; + let h = cfg.hidden; + let token: u32 = 7; + + let embed = fill(VOCAB * h, 500); + let final_norm = fill(h, 501); + let lm_head = fill(VOCAB * h, 502); + let layers: Vec = (0..N_LAYERS).map(|l| gen_layer(&cfg, 600 + l * 20)).collect(); + + let mw = ModelWeights { + embed: tens(dev.as_ref(), &embed, vec![VOCAB, h]), + layers: layers.iter().map(|lw| gpu_layer(dev.as_ref(), &cfg, lw)).collect(), + final_norm: tens(dev.as_ref(), &final_norm, vec![h]), + lm_head: tens(dev.as_ref(), &lm_head, vec![VOCAB, h]), + }; + + // GPU + let logits = forward_single(dev.as_ref(), &cfg, &mw, token).unwrap(); + dev.synchronize().unwrap(); + let mut lb = vec![0u8; VOCAB * 4]; + dev.download(logits.buffer.as_ref(), &mut lb).unwrap(); + let got = from_bytes(&lb); + + // CPU + let mut x = embed[token as usize * h..(token as usize + 1) * h].to_vec(); + for lw in &layers { + x = cpu_layer(&cfg, &x, lw); + } + let xn = rmsnorm(&x, &final_norm, cfg.eps); + let want = matvec(&lm_head, &xn, VOCAB, h); + + let mut err = 0.0f32; + for i in 0..VOCAB { + err = err.max((got[i] - want[i]).abs()); + } + // argmax (the predicted token) must agree exactly. + let amax = |v: &[f32]| (0..v.len()).max_by(|&a, &b| v[a].total_cmp(&v[b])).unwrap(); + let (ga, ca) = (amax(&got), amax(&want)); + eprintln!("full forward logits on CUDA vs CPU: max|Δ|={err:.3e}, argmax gpu={ga} cpu={ca}"); + assert!(err <= 1e-2, "full forward mismatch: max|Δ|={err:.3e}"); + assert_eq!(ga, ca, "argmax (predicted token) disagrees"); + eprintln!("✅ Full {N_LAYERS}-layer Qwen2 forward → logits on GB10 through the shared op layer, matches CPU (same predicted token)."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/gather_prof.rs b/rust/crates/backends/ffai-cuda/tests/gather_prof.rs new file mode 100644 index 00000000..c9d79fd6 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/gather_prof.rs @@ -0,0 +1,31 @@ +// MoE gather bandwidth: contiguous vs scattered expert indices (isolate scatter cost). +use ffai_core::{DType, Tensor}; +use ffai_ops::{moe_gather_up_relu2, quantize_q4}; +use ffai_cuda::CudaDevice; +use std::time::Instant; +fn tbf(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn tbu(v: &[u32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +#[test] +fn gather_bw() { + let Some(dev) = CudaDevice::create().expect("cuda") else { return; }; + let d = dev.as_ref(); + let (n_exp, inter, hid, top_k) = (128usize, 1856usize, 2688usize, 6usize); + let mut s=0x33u32; let mut rng=||{s^=s<<13;s^=s>>17;s^=s<<5;(s as f32/u32::MAX as f32)-0.5}; + let w: Vec = (0..n_exp*inter*hid).map(|_| rng()).collect(); + let (qs,sc)=quantize_q4(&w, n_exp*inter, hid); + let qt=Tensor::new(d.upload(&tbu(&qs)).unwrap(), vec![qs.len()], DType::U32); + let st=Tensor::new(d.upload(&tbf(&sc)).unwrap(), vec![sc.len()], DType::F32); + let x: Vec=(0..hid).map(|_| rng()).collect(); + let xt=Tensor::new(d.upload(&tbf(&x)).unwrap(), vec![hid], DType::F32); + for (lbl, idx) in [("contiguous", vec![0u32,1,2,3,4,5]), ("scattered", vec![0u32,21,42,63,84,105])] { + let it=Tensor::new(d.upload(&tbu(&idx)).unwrap(), vec![top_k], DType::U32); + for _ in 0..5 { let _=moe_gather_up_relu2(d,&qt,&st,&xt,&it,top_k,inter,hid).unwrap(); } + d.synchronize().unwrap(); + let n=100; let t=Instant::now(); + for _ in 0..n { let _=moe_gather_up_relu2(d,&qt,&st,&xt,&it,top_k,inter,hid).unwrap(); } + d.synchronize().unwrap(); + let us=t.elapsed().as_secs_f64()*1e6/n as f64; + let bytes=(top_k*inter*hid/2) as f64 + (top_k*inter*hid/32*4) as f64; + eprintln!("gather {lbl}: {us:.1} us {:.1} GB/s", bytes/us/1e3); + } +} diff --git a/rust/crates/backends/ffai-cuda/tests/gemv_q4_prof.rs b/rust/crates/backends/ffai-cuda/tests/gemv_q4_prof.rs new file mode 100644 index 00000000..3103b17d --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/gemv_q4_prof.rs @@ -0,0 +1,30 @@ +// Q4 gemv profiling target — one big cold GEMV (lm_head shape) for ncu. +use ffai_core::{DType, Tensor}; +use ffai_ops::{gemv_q4, quantize_q4}; +use ffai_cuda::CudaDevice; +use std::time::Instant; +fn tbf(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn tbu(v: &[u32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +#[test] +fn q4_lmhead_prof() { + let Some(dev) = CudaDevice::create().expect("cuda") else { return; }; + let d = dev.as_ref(); + for (m,k,lbl) in [(131072usize,2688usize,"lmhead"),(1856,2688,"exp_up"),(2688,1856,"exp_down"),(10304,2688,"in_proj")] { run(d,m,k,lbl); } +} +fn run(d: &dyn ffai_core::Device, m: usize, k: usize, lbl: &str) { + let mut s = 0x1u32; let mut rng = || { s ^= s<<13; s^=s>>17; s^=s<<5; (s as f32/u32::MAX as f32)-0.5 }; + let w: Vec = (0..m*k).map(|_| rng()).collect(); + let x: Vec = (0..k).map(|_| rng()).collect(); + let (qs, sc) = quantize_q4(&w, m, k); + let qt = Tensor::new(d.upload(&tbu(&qs)).unwrap(), vec![qs.len()], DType::U32); + let st = Tensor::new(d.upload(&tbf(&sc)).unwrap(), vec![sc.len()], DType::F32); + let xt = Tensor::new(d.upload(&tbf(&x)).unwrap(), vec![k], DType::F32); + for _ in 0..3 { let _ = gemv_q4(d, &qt, &st, &xt, m, k, m).unwrap(); } + d.synchronize().unwrap(); + let it = 50; let t = Instant::now(); + for _ in 0..it { let _ = gemv_q4(d, &qt, &st, &xt, m, k, m).unwrap(); } + d.synchronize().unwrap(); + let us = t.elapsed().as_secs_f64()*1e6/it as f64; + let bytes = (m*k/2) as f64 + (m*k/32*4) as f64; + eprintln!("q4 {lbl}: {us:.1} us {:.1} GB/s", bytes/us/1e3); +} diff --git a/rust/crates/backends/ffai-cuda/tests/gemv_q8.rs b/rust/crates/backends/ffai-cuda/tests/gemv_q8.rs new file mode 100644 index 00000000..425d4ed7 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/gemv_q8.rs @@ -0,0 +1,54 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Q8_0 resident-weight matvec (`ffai_ops::gemv_q8`) vs a CPU dequant-dot +//! reference. Same kernel IR codegens to CUDA — this is the cheap local proof +//! of the quantized-GEMV path that the resident NemotronH decode loop rides on. +use ffai_core::{DType, Tensor}; +use ffai_ops::{gemv_q8, quantize_q8}; +use ffai_cuda::CudaDevice; + +fn tb_f32(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn tb_u32(v: &[u32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn gemv_q8_matches_cpu_dequant_dot() { + let Some(dev) = CudaDevice::create().expect("metal") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + let (m, k) = (128usize, 256usize); + let mut s = 0x1234_5678u32; + let mut rng = || { s ^= s << 13; s ^= s >> 17; s ^= s << 5; (s as f32 / u32::MAX as f32) - 0.5 }; + let w: Vec = (0..m * k).map(|_| rng()).collect(); + let x: Vec = (0..k).map(|_| rng()).collect(); + + let (qs, scales) = quantize_q8(&w, m, k); + + // CPU reference: the SAME dequant the kernel does (int8·scale), dotted with x. + let bpr = k / 32; + let mut want = vec![0f32; m]; + for r in 0..m { + let mut acc = 0f32; + for b in 0..bpr { + let dscale = scales[r * bpr + b]; + for i in 0..32 { + let packed = qs[r * bpr * 8 + b * 8 + i / 4]; + let by = ((packed >> ((i % 4) * 8)) & 0xff) as u8; + acc += dscale * (by as i8 as f32) * x[b * 32 + i]; + } + } + want[r] = acc; + } + + let qs_t = Tensor::new(d.upload(&tb_u32(&qs)).unwrap(), vec![qs.len()], DType::U32); + let sc_t = Tensor::new(d.upload(&tb_f32(&scales)).unwrap(), vec![scales.len()], DType::F32); + let x_t = Tensor::new(d.upload(&tb_f32(&x)).unwrap(), vec![k], DType::F32); + let out_t = gemv_q8(d, &qs_t, &sc_t, &x_t, m, k, m).unwrap(); // rows_per_group=m ⇒ dense + let mut ob = vec![0u8; m * 4]; + d.download(out_t.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + let maxerr = (0..m).fold(0f32, |a, r| a.max((got[r] - want[r]).abs())); + eprintln!("gemv_q8 max abs err vs CPU dequant dot = {maxerr:.6}"); + assert!(maxerr < 1e-3, "gemv_q8 mismatch: {maxerr}"); + eprintln!("✅ gemv_q8 (Q8_0 resident matvec) matches CPU dequant dot"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/gemv_q8_bench.rs b/rust/crates/backends/ffai-cuda/tests/gemv_q8_bench.rs new file mode 100644 index 00000000..235cbe16 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/gemv_q8_bench.rs @@ -0,0 +1,33 @@ +// Q8 GEMV throughput microbench — isolates kernel cost from decode orchestration. +use ffai_core::{DType, Tensor}; +use ffai_ops::{gemv_q8, quantize_q8}; +use ffai_cuda::CudaDevice; +use std::time::Instant; + +fn tbf(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn tbu(v: &[u32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } + +#[test] +fn gemv_q8_throughput() { + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + let d = dev.as_ref(); + for (m, k, label) in [(10304usize, 2688usize, "in_proj"), (1856, 2688, "expert_up"), (2688, 1856, "expert_down"), (131072, 2688, "lm_head")] { + let mut s = 0x9e37u32; + let mut rng = || { s ^= s << 13; s ^= s >> 17; s ^= s << 5; (s as f32 / u32::MAX as f32) - 0.5 }; + let w: Vec = (0..m * k).map(|_| rng()).collect(); + let x: Vec = (0..k).map(|_| rng()).collect(); + let (qs, sc) = quantize_q8(&w, m, k); + let qt = Tensor::new(d.upload(&tbu(&qs)).unwrap(), vec![qs.len()], DType::U32); + let st = Tensor::new(d.upload(&tbf(&sc)).unwrap(), vec![sc.len()], DType::F32); + let xt = Tensor::new(d.upload(&tbf(&x)).unwrap(), vec![k], DType::F32); + for _ in 0..5 { let _ = gemv_q8(d, &qt, &st, &xt, m, k, m).unwrap(); } + d.synchronize().unwrap(); + let iters = 200; + let t0 = Instant::now(); + for _ in 0..iters { let _ = gemv_q8(d, &qt, &st, &xt, m, k, m).unwrap(); } + d.synchronize().unwrap(); + let us = t0.elapsed().as_secs_f64() * 1e6 / iters as f64; + let bytes = (m * k) as f64 + (m * k / 32 * 4) as f64; // qs 1B/param + scales + eprintln!("gemv_q8 {label:12} [{m}x{k}]: {us:7.1} us/call {:6.1} GB/s", bytes / us / 1e3); + } +} diff --git a/rust/crates/backends/ffai-cuda/tests/gpt2_generate.rs b/rust/crates/backends/ffai-cuda/tests/gpt2_generate.rs new file mode 100644 index 00000000..9ad6d2fc --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/gpt2_generate.rs @@ -0,0 +1,101 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GPT-2 autoregressive **greedy decode** (prefill + 30-token generation loop) +//! verified vs HF `generate(do_sample=False)`. Proves the full generate path — +//! prefill → argmax → append → re-forward — and **coherence**: the engine must +//! emit the exact token sequence HF does. Prints the generated ids so they can +//! be detokenized to text. (Each step re-forwards the growing sequence — an +//! incremental KV cache is a runtime optimization, not needed for correctness.) +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gelu, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn gpt2_greedy_generate_vs_hf() { + let dir = std::env::var("GPT2_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + + let (hid, nh, hd, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], b: &[f32], rows: usize, cols: usize| { for r in 0..rows { for c in 0..cols { m[r * cols + c] += b[c]; } } }; + let conv_t = |w: &[f32], nin: usize, nout: usize| -> Vec { let mut o = vec![0.0f32; nin * nout]; for i in 0..nin { for j in 0..nout { o[j * nin + i] = w[i * nout + j]; } } o }; + let reorg = |m: &[f32], n: usize| -> Vec { let mut o = vec![0.0f32; nh * n * hd]; for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * hid + h * hd + dd]; } } } o }; + + // preload weights once + let wte = g("wte.weight"); let wpe = g("wpe.weight"); + let lw: Vec<_> = (0..n_layers).map(|l| { + let p = format!("h.{l}"); + (g(&format!("{p}.ln_1.weight")), g(&format!("{p}.ln_1.bias")), + conv_t(&g(&format!("{p}.attn.c_attn.weight")), hid, 3 * hid), g(&format!("{p}.attn.c_attn.bias")), + conv_t(&g(&format!("{p}.attn.c_proj.weight")), hid, hid), g(&format!("{p}.attn.c_proj.bias")), + g(&format!("{p}.ln_2.weight")), g(&format!("{p}.ln_2.bias")), + conv_t(&g(&format!("{p}.mlp.c_fc.weight")), hid, 4 * hid), g(&format!("{p}.mlp.c_fc.bias")), + conv_t(&g(&format!("{p}.mlp.c_proj.weight")), 4 * hid, hid), g(&format!("{p}.mlp.c_proj.bias"))) + }).collect(); + let lnf_w = g("ln_f.weight"); let lnf_b = g("ln_f.bias"); + + // one full forward over `ids`, returns argmax of last position + let forward = |ids: &[usize]| -> usize { + let seq = ids.len(); + let mut x = vec![0.0f32; seq * hid]; + for (i, &tok) in ids.iter().enumerate() { for e in 0..hid { x[i * hid + e] = wte[tok * hid + e] + wpe[i * hid + e]; } } + for w in &lw { + let h = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&w.0, vec![hid]), &up(&w.1, vec![hid]), eps).unwrap(), seq * hid); + let mut qkv = dl(&matmul(d, &up(&w.2, vec![3 * hid, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * 3 * hid); + add_bias(&mut qkv, &w.3, seq, 3 * hid); + let (mut q, mut k, mut v) = (vec![0.0f32; seq * hid], vec![0.0f32; seq * hid], vec![0.0f32; seq * hid]); + for t in 0..seq { + q[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid..t * 3 * hid + hid]); + k[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + hid..t * 3 * hid + 2 * hid]); + v[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + 2 * hid..t * 3 * hid + 3 * hid]); + } + let kt = up(&reorg(&k, seq), vec![nh, seq, hd]); let vt = up(&reorg(&v, seq), vec![nh, seq, hd]); + let mut attn = vec![0.0f32; seq * hid]; + for i in 0..seq { + let a = sdpa_decode(d, &up(&q[i * hid..(i + 1) * hid], vec![nh, hd]), &kt, &vt, hd, (i + 1) as u32, seq as u32, 1, scale).unwrap(); + attn[i * hid..(i + 1) * hid].copy_from_slice(&dl(&a, hid)); + } + let mut o = dl(&matmul(d, &up(&w.4, vec![hid, hid]), &up(&attn, vec![seq, hid])).unwrap(), seq * hid); + add_bias(&mut o, &w.5, seq, hid); + for i in 0..seq * hid { x[i] += o[i]; } + let h2 = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&w.6, vec![hid]), &up(&w.7, vec![hid]), eps).unwrap(), seq * hid); + let mut f = dl(&matmul(d, &up(&w.8, vec![4 * hid, hid]), &up(&h2, vec![seq, hid])).unwrap(), seq * 4 * hid); + add_bias(&mut f, &w.9, seq, 4 * hid); + let act = dl(&gelu(d, &up(&f, vec![seq * 4 * hid])).unwrap(), seq * 4 * hid); + let mut m = dl(&matmul(d, &up(&w.10, vec![hid, 4 * hid]), &up(&act, vec![seq, 4 * hid])).unwrap(), seq * hid); + add_bias(&mut m, &w.11, seq, hid); + for i in 0..seq * hid { x[i] += m[i]; } + } + let xf = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&lnf_w, vec![hid]), &up(&lnf_b, vec![hid]), eps).unwrap(), seq * hid); + let last = &xf[(seq - 1) * hid..seq * hid]; + let logits = dl(&matmul(d, &up(&wte, vec![vocab, hid]), &up(last, vec![1, hid])).unwrap(), vocab); + (0..vocab).max_by(|&a, &b| logits[a].total_cmp(&logits[b])).unwrap() + }; + + // greedy decode loop + let mut ids: Vec = vec![464, 3139, 286, 4881, 318]; // "The capital of France is" + let prompt_len = ids.len(); + for _ in 0..30 { let nxt = forward(&ids); ids.push(nxt); } + let outv = &ids[prompt_len..]; + eprintln!("GPT-2 greedy gen on CUDA: {outv:?}"); + + let hf = [262usize, 3139, 286, 262, 4141, 2066, 11, 290, 262, 3139, 286, 262, 4141, 2066, 318, 262, 3139, 286, 262, 4141, 2066, 13, 198, 198, 464, 4141, 2066, 318, 262, 3139]; + assert_eq!(outv, &hf, "GPT-2 greedy generation diverges from HF"); + eprintln!("✅ GPT-2 greedy decode (30 tokens) matches HF generate() exactly — prefill + decode + coherence on the shared engine."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--gpt2/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-cuda/tests/gpt2_kvcache.rs b/rust/crates/backends/ffai-cuda/tests/gpt2_kvcache.rs new file mode 100644 index 00000000..6df13fab --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/gpt2_kvcache.rs @@ -0,0 +1,112 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GPT-2 generation with a real **incremental KV cache** + **device-resident +//! weights** + throughput numbers. Weights are uploaded to the device ONCE; +//! prefill fills a per-layer K/V cache; each decode step forwards only the new +//! token and attends over the cache (n_kv = current length) — O(seq)/step, not +//! O(seq²). Only tiny per-step activations + the growing cache cross the bus. +//! Asserts generated tokens still match HF and prints prefill/decode tok/s. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{add, gelu, gemv, layer_norm, matmul, sdpa_decode}; +use std::time::Instant; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +struct LW { ln1w: Tensor, ln1b: Tensor, cattn: Tensor, cattn_b: Tensor, cproj: Tensor, cproj_b: Tensor, + ln2w: Tensor, ln2b: Tensor, fc: Tensor, fc_b: Tensor, proj: Tensor, proj_b: Tensor } + +#[test] +fn gpt2_kvcache_decode_throughput() { + let dir = std::env::var("GPT2_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + let plat = "CUDA"; + + let (hid, nh, hd, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let conv_t = |w: &[f32], nin: usize, nout: usize| -> Vec { let mut o = vec![0.0f32; nin * nout]; for i in 0..nin { for j in 0..nout { o[j * nin + i] = w[i * nout + j]; } } o }; + let reorg = |m: &[f32], n: usize| -> Vec { let mut o = vec![0.0f32; nh * n * hd]; for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * hid + h * hd + dd]; } } } o }; + + // ── upload all weights to the device ONCE (resident) ── + let t_load = Instant::now(); + let wte = g("wte.weight"); let wpe = g("wpe.weight"); + let wte_t = up(&wte, vec![vocab, hid]); // lm_head (tied) + let lwt: Vec = (0..n_layers).map(|l| { let p = format!("h.{l}"); LW { + ln1w: up(&g(&format!("{p}.ln_1.weight")), vec![hid]), ln1b: up(&g(&format!("{p}.ln_1.bias")), vec![hid]), + cattn: up(&conv_t(&g(&format!("{p}.attn.c_attn.weight")), hid, 3*hid), vec![3*hid, hid]), cattn_b: up(&g(&format!("{p}.attn.c_attn.bias")), vec![3*hid]), + cproj: up(&conv_t(&g(&format!("{p}.attn.c_proj.weight")), hid, hid), vec![hid, hid]), cproj_b: up(&g(&format!("{p}.attn.c_proj.bias")), vec![hid]), + ln2w: up(&g(&format!("{p}.ln_2.weight")), vec![hid]), ln2b: up(&g(&format!("{p}.ln_2.bias")), vec![hid]), + fc: up(&conv_t(&g(&format!("{p}.mlp.c_fc.weight")), hid, 4*hid), vec![4*hid, hid]), fc_b: up(&g(&format!("{p}.mlp.c_fc.bias")), vec![4*hid]), + proj: up(&conv_t(&g(&format!("{p}.mlp.c_proj.weight")), 4*hid, hid), vec![hid, 4*hid]), proj_b: up(&g(&format!("{p}.mlp.c_proj.bias")), vec![hid]), + }}).collect(); + let lnf_w = up(&g("ln_f.weight"), vec![hid]); let lnf_b = up(&g("ln_f.bias"), vec![hid]); + let load_s = t_load.elapsed().as_secs_f64(); + + let mut kc: Vec> = vec![Vec::new(); n_layers]; + let mut vc: Vec> = vec![Vec::new(); n_layers]; + let argmax = |logits: &[f32]| (0..vocab).max_by(|&a, &b| logits[a].total_cmp(&logits[b])).unwrap(); + + // forward ONE token at `pos`, extending the cache; resident weights, returns next argmax + let step = |tok: usize, pos: usize, kc: &mut Vec>, vc: &mut Vec>| -> usize { + let x0: Vec = (0..hid).map(|e| wte[tok*hid+e] + wpe[pos*hid+e]).collect(); + let mut xt = up(&x0, vec![hid]); // residual stream stays device-resident + for (l, w) in lwt.iter().enumerate() { + let h = layer_norm(d, &xt, &w.ln1w, &w.ln1b, eps).unwrap(); + let qkv_t = add(d, &gemv(d, &w.cattn, &h).unwrap(), &w.cattn_b).unwrap(); + let qkv = dl(&qkv_t, 3*hid); // the one unavoidable download — feeds the host KV cache + kc[l].extend_from_slice(&qkv[hid..2*hid]); vc[l].extend_from_slice(&qkv[2*hid..3*hid]); + let len = kc[l].len() / hid; + let kt = up(&reorg(&kc[l], len), vec![nh, len, hd]); + let vt = up(&reorg(&vc[l], len), vec![nh, len, hd]); + let a = sdpa_decode(d, &up(&qkv[0..hid], vec![nh, hd]), &kt, &vt, hd, len as u32, len as u32, 1, scale).unwrap(); + let o = add(d, &gemv(d, &w.cproj, &a.reshaped(vec![hid])).unwrap(), &w.cproj_b).unwrap(); + xt = add(d, &xt, &o).unwrap(); // residual on-device (no round-trip) + let h2 = layer_norm(d, &xt, &w.ln2w, &w.ln2b, eps).unwrap(); + let f = add(d, &gemv(d, &w.fc, &h2).unwrap(), &w.fc_b).unwrap(); + let act = gelu(d, &f).unwrap(); + let m = add(d, &gemv(d, &w.proj, &act).unwrap(), &w.proj_b).unwrap(); + xt = add(d, &xt, &m).unwrap(); // residual on-device + } + let xf = layer_norm(d, &xt, &lnf_w, &lnf_b, eps).unwrap(); + let logits = dl(&matmul(d, &wte_t, &xf.reshaped(vec![1, hid])).unwrap(), vocab); + argmax(&logits) + }; + + // warmup: first dispatch of each kernel JIT-compiles (MSL) — exclude from timings + let t_warm = Instant::now(); + { let mut wk = vec![Vec::new(); n_layers]; let mut wv = vec![Vec::new(); n_layers]; step(464, 0, &mut wk, &mut wv); } + let warm_s = t_warm.elapsed().as_secs_f64(); + + let prompt: Vec = vec![464, 3139, 286, 4881, 318]; // "The capital of France is" + let t0 = Instant::now(); + let mut next = 0usize; + for (pos, &tok) in prompt.iter().enumerate() { next = step(tok, pos, &mut kc, &mut vc); } + let prefill_s = t0.elapsed().as_secs_f64(); + + let mut outv = vec![next]; + let t1 = Instant::now(); + let mut pos = prompt.len(); + for _ in 0..29 { let tok = *outv.last().unwrap(); let nxt = step(tok, pos, &mut kc, &mut vc); outv.push(nxt); pos += 1; } + let decode_s = t1.elapsed().as_secs_f64(); + + let hf = [262usize, 3139, 286, 262, 4141, 2066, 11, 290, 262, 3139, 286, 262, 4141, 2066, 318, 262, 3139, 286, 262, 4141, 2066, 13, 198, 198, 464, 4141, 2066, 318, 262, 3139]; + eprintln!("GPT-2 KV-cache decode on {plat} (resident weights):"); + eprintln!(" weight upload (1x): {load_s:.3}s kernel JIT warmup (1x): {warm_s:.3}s"); + eprintln!(" prefill {} tok in {:.3}s = {:.1} tok/s", prompt.len(), prefill_s, prompt.len() as f64 / prefill_s); + eprintln!(" decode {} tok in {:.3}s = {:.1} tok/s ({:.1} ms/tok)", outv.len(), decode_s, outv.len() as f64 / decode_s, decode_s * 1000.0 / outv.len() as f64); + assert_eq!(&outv[..], &hf, "KV-cache generation diverges from HF"); + eprintln!("✅ Incremental KV-cache decode (resident weights) matches HF generate() exactly on {plat}."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--gpt2/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-cuda/tests/gpt2_prefill.rs b/rust/crates/backends/ffai-cuda/tests/gpt2_prefill.rs new file mode 100644 index 00000000..255eb5b7 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/gpt2_prefill.rs @@ -0,0 +1,105 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GPT-2 multi-token **causal prefill** verified vs HF — the one remaining +//! primitive (all other model tests are single-token at pos 0). Processes an +//! 8-token prompt through the full stack; each position attends causally over +//! [0, i] (via `sdpa_decode` with n_kv = i+1 against the per-head K/V cache, +//! kv_stride = seq_len). Proves causal masking + the prefill path on the shared +//! op layer. (GPT-2 uses learned position embeddings, isolating causal masking +//! from RoPE.) argmax of the last position vs HF. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gelu, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn gpt2_causal_prefill_vs_hf() { + let dir = std::env::var("GPT2_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + + let (hid, nh, hd, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + let ids = [464usize, 2068, 7586, 21831, 11687, 625, 262, 16931]; + let seq = ids.len(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], b: &[f32], rows: usize, cols: usize| { for r in 0..rows { for c in 0..cols { m[r * cols + c] += b[c]; } } }; + let conv_t = |w: &[f32], nin: usize, nout: usize| -> Vec { + let mut o = vec![0.0f32; nin * nout]; + for i in 0..nin { for j in 0..nout { o[j * nin + i] = w[i * nout + j]; } } o + }; + let reorg = |m: &[f32], n: usize| -> Vec { // [n,hid] → [nh,n,hd] + let mut o = vec![0.0f32; nh * n * hd]; + for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * hid + h * hd + dd]; } } } o + }; + + let wte = g("wte.weight"); let wpe = g("wpe.weight"); + // x[seq, hid] = token embed + learned position embed + let mut x = vec![0.0f32; seq * hid]; + for (i, &tok) in ids.iter().enumerate() { + for e in 0..hid { x[i * hid + e] = wte[tok * hid + e] + wpe[i * hid + e]; } + } + + for l in 0..n_layers { + let p = format!("h.{l}"); + // attention (pre-LN over all positions) + let h = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.ln_1.weight")), vec![hid]), &up(&g(&format!("{p}.ln_1.bias")), vec![hid]), eps).unwrap(), seq * hid); + let cattn_w = conv_t(&g(&format!("{p}.attn.c_attn.weight")), hid, 3 * hid); + let mut qkv = dl(&matmul(d, &up(&cattn_w, vec![3 * hid, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * 3 * hid); + add_bias(&mut qkv, &g(&format!("{p}.attn.c_attn.bias")), seq, 3 * hid); + // split q/k/v [seq,hid] + let mut q = vec![0.0f32; seq * hid]; let mut k = vec![0.0f32; seq * hid]; let mut v = vec![0.0f32; seq * hid]; + for t in 0..seq { + q[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid..t * 3 * hid + hid]); + k[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + hid..t * 3 * hid + 2 * hid]); + v[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + 2 * hid..t * 3 * hid + 3 * hid]); + } + let kt = up(&reorg(&k, seq), vec![nh, seq, hd]); + let vt = up(&reorg(&v, seq), vec![nh, seq, hd]); + // causal: position i attends over [0, i] → n_kv = i+1, kv_stride = seq + let mut attn = vec![0.0f32; seq * hid]; + for i in 0..seq { + let qi = up(&q[i * hid..(i + 1) * hid], vec![nh, hd]); + let a = sdpa_decode(d, &qi, &kt, &vt, hd, (i + 1) as u32, seq as u32, 1, scale).unwrap(); + attn[i * hid..(i + 1) * hid].copy_from_slice(&dl(&a, hid)); + } + let cproj_w = conv_t(&g(&format!("{p}.attn.c_proj.weight")), hid, hid); + let mut o = dl(&matmul(d, &up(&cproj_w, vec![hid, hid]), &up(&attn, vec![seq, hid])).unwrap(), seq * hid); + add_bias(&mut o, &g(&format!("{p}.attn.c_proj.bias")), seq, hid); + for i in 0..seq * hid { x[i] += o[i]; } + + // MLP (pre-LN, gelu_new = tanh) + let h2 = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.ln_2.weight")), vec![hid]), &up(&g(&format!("{p}.ln_2.bias")), vec![hid]), eps).unwrap(), seq * hid); + let fc_w = conv_t(&g(&format!("{p}.mlp.c_fc.weight")), hid, 4 * hid); + let mut f = dl(&matmul(d, &up(&fc_w, vec![4 * hid, hid]), &up(&h2, vec![seq, hid])).unwrap(), seq * 4 * hid); + add_bias(&mut f, &g(&format!("{p}.mlp.c_fc.bias")), seq, 4 * hid); + let act = dl(&gelu(d, &up(&f, vec![seq * 4 * hid])).unwrap(), seq * 4 * hid); + let proj_w = conv_t(&g(&format!("{p}.mlp.c_proj.weight")), 4 * hid, hid); + let mut m = dl(&matmul(d, &up(&proj_w, vec![hid, 4 * hid]), &up(&act, vec![seq, 4 * hid])).unwrap(), seq * hid); + add_bias(&mut m, &g(&format!("{p}.mlp.c_proj.bias")), seq, hid); + for i in 0..seq * hid { x[i] += m[i]; } + } + + // final LN; logits at LAST position (tied lm_head) + let xf = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&g("ln_f.weight"), vec![hid]), &up(&g("ln_f.bias"), vec![hid]), eps).unwrap(), seq * hid); + let last = &xf[(seq - 1) * hid..seq * hid]; + let logits = dl(&matmul(d, &up(&wte, vec![vocab, hid]), &up(last, vec![1, hid])).unwrap(), vocab); + let mut idx: Vec = (0..vocab).collect(); + idx.sort_by(|&a, &b| logits[b].total_cmp(&logits[a])); + eprintln!("GPT-2 causal prefill (seq={seq}) on CUDA: top3 = {:?} (HF = [11, 21831, 7586])", &idx[..3]); + assert_eq!(&idx[..3], &[11usize, 21831, 7586], "GPT-2 prefill top-3 != HF"); + eprintln!("✅ GPT-2 multi-token causal prefill matches HF on the shared engine (GB10 sm_121) — causal prefill path verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--gpt2/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-cuda/tests/graph_bench.rs b/rust/crates/backends/ffai-cuda/tests/graph_bench.rs new file mode 100644 index 00000000..0018f79e --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/graph_bench.rs @@ -0,0 +1,77 @@ +//! Decisive GB10 experiment: does CUDA-graph capture (the megakernel form on this +//! stack) cut per-token overhead? Times N back-to-back kernel dispatches launched +//! SEQUENTIALLY (async, one ordered stream) vs the same N captured as ONE graph and +//! REPLAYED. If graph << sequential, the inter-kernel host/launch bubble is real and +//! the all-device-token + capture build (phases 2-3) is worth it. The research could +//! not answer this for GB10 (all megakernel data was B200). +//! +//! Run: cargo test --release -p ffai-cuda --features cuda --test graph_bench -- --nocapture + +#![cfg(feature = "cuda")] + +use ffai_core::{DType, Tensor}; +use ffai_ops::add; +use std::time::Instant; + +#[test] +fn graph_vs_sequential_overhead() { + let dev = match ffai_cuda::CudaDevice::create().unwrap() { + Some(d) => d, + None => { + eprintln!("no CUDA device; skipping"); + return; + } + }; + let d = dev.as_ref(); + // Small buffers so the kernel is launch/overhead-bound, not bandwidth-bound — + // that isolates the per-dispatch bubble (decode has ~390 such dispatches/token). + let n = 2688usize; + let a = Tensor::new(d.upload(&vec![0u8; n * 4]).unwrap(), vec![n], DType::F32); + let b = Tensor::new(d.upload(&vec![0u8; n * 4]).unwrap(), vec![n], DType::F32); + + let launches = 390usize; // ~one decode token's dispatch count + // Warm up: compile the kernel + fill the buffer pool so neither sequential nor + // captured paths pay a one-time cuMemAlloc. + for _ in 0..50 { + let _ = add(d, &a, &b).unwrap(); + } + d.synchronize().unwrap(); + + // ── Sequential: a DEPENDENT chain (each add reads the prior output) — this is + // the realistic decode shape; the RAW dependency exposes the inter-kernel bubble + // (GPU idle between dependent kernels) that graph replay eliminates. ── + let iters = 20; + let t0 = Instant::now(); + for _ in 0..iters { + let mut x = add(d, &a, &b).unwrap(); + for _ in 0..launches - 1 { + x = add(d, &x, &b).unwrap(); + } + d.synchronize().unwrap(); + let _ = x; + } + let seq_ms = t0.elapsed().as_secs_f64() * 1000.0 / iters as f64; + + // ── Graph: capture the same dependent chain once, then replay ── + d.begin_capture().unwrap(); + { + let mut x = add(d, &a, &b).unwrap(); + for _ in 0..launches - 1 { + x = add(d, &x, &b).unwrap(); + } + let _ = x; + } + let exec = d.end_capture().unwrap(); + + let t1 = Instant::now(); + for _ in 0..iters { + d.graph_launch(exec).unwrap(); + } + let graph_ms = t1.elapsed().as_secs_f64() * 1000.0 / iters as f64; + + eprintln!("──── CUDA-graph vs sequential ({launches} dispatches) ────"); + eprintln!(" sequential: {seq_ms:.3} ms/token ({:.2} us/launch)", seq_ms * 1000.0 / launches as f64); + eprintln!(" graph replay: {graph_ms:.3} ms/token ({:.2} us/launch)", graph_ms * 1000.0 / launches as f64); + eprintln!(" speedup: {:.2}x (overhead removed: {:.3} ms/token)", seq_ms / graph_ms, seq_ms - graph_ms); + eprintln!(" ⇒ at 32K decode (~22 ms/token, ~3.4 ms host gap), graph could recover ~{:.1} ms ⇒ verdict on megakernel.", (seq_ms - graph_ms).min(3.4)); +} diff --git a/rust/crates/backends/ffai-cuda/tests/grouped_bcast_test.rs b/rust/crates/backends/ffai-cuda/tests/grouped_bcast_test.rs new file mode 100644 index 00000000..f049b5de --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/grouped_bcast_test.rs @@ -0,0 +1,136 @@ +#![cfg(feature = "cuda")] +//! Isolate gemm_batched (device-ptr-array, broadcast offsets) vs +//! gemm_strided_batched on the SSD G1 shape: CB = C·Bᵀ, [L,L]=C[L,ds]·B[L,ds]ᵀ, +//! plus a multi-chunk fused-vs-strided regression for the null-stream race. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; + +fn tb_f16(v: &[f32]) -> Vec { + v.iter().flat_map(|&f| half_bits(f).to_le_bytes()).collect() +} +fn half_bits(f: f32) -> u16 { + let x = f.to_bits(); + let sign = ((x >> 16) & 0x8000) as u16; + let e = ((x >> 23) & 0xff) as i32 - 112; + if e <= 0 { return sign; } + if e >= 0x1f { return sign | 0x7c00; } + let m = (x >> 13) & 0x3ff; + let round = (x >> 12) & 1; + let v = ((e as u32) << 10) | m; + sign | ((v + round) as u16) +} +fn f16_to_f32(b: u16) -> f32 { + let sign = ((b & 0x8000) as u32) << 16; + let exp = ((b >> 10) & 0x1f) as u32; + let man = (b & 0x3ff) as u32; + if exp == 0 { return f32::from_bits(sign); } + if exp == 0x1f { return f32::from_bits(sign | 0x7f800000 | (man << 13)); } + f32::from_bits(sign | ((exp + 112) << 23) | (man << 13)) +} +fn fill(n: usize, s: usize, scale: f32) -> Vec { + (0..n).map(|i| (((i * 31 + s * 977) % 251) as f32 / 251.0 - 0.5) * 2.0 * scale).collect() +} + +#[test] +fn grouped_broadcast_matches_strided() { + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + // SSD G1: L=128, ds=128, H=64, G=8, nc=4 → bhc=256, bgc=32 (multi-chunk). + let nc: usize = 4; + let (l, ds, h, g) = (128usize, 128usize, 64usize, 8usize); + let hpg = h / g; + let bhc = nc * h; + let bgc = nc * g; + let el = 2i64; + + // Per-HEAD C/B (strided reference) and per-GROUP C/B (grouped broadcast). + // Build per-group data first, then expand to per-head by broadcast. + let cg = fill(bgc * l * ds, 4, 3.0); + let bg = fill(bgc * l * ds, 3, 3.0); + // per-head expanded + let mut ch = vec![0f32; bhc * l * ds]; + let mut bh_ = vec![0f32; bhc * l * ds]; + for bh in 0..bhc { + let c = bh / h; let hh = bh % h; let grp = c * g + hh / hpg; + ch[bh*l*ds..(bh+1)*l*ds].copy_from_slice(&cg[grp*l*ds..(grp+1)*l*ds]); + bh_[bh*l*ds..(bh+1)*l*ds].copy_from_slice(&bg[grp*l*ds..(grp+1)*l*ds]); + } + + let mk = |v: &[f32]| Tensor::new(d.upload(&tb_f16(v)).unwrap(), vec![v.len()], DType::F16); + let cg_t = mk(&cg); let bg_t = mk(&bg); + let ch_t = mk(&ch); let bh_t = mk(&bh_); + let cb_strided = Tensor::new(d.upload(&vec![0u8; bhc*l*l*2]).unwrap(), vec![bhc*l*l], DType::F16); + let cb_grouped = Tensor::new(d.upload(&vec![0u8; bhc*l*l*2]).unwrap(), vec![bhc*l*l], DType::F16); + + // Strided (per-head): X=C, W=B, out=cb. m=L,n=L,k=ds. + let st_lds = (l*ds) as i64 * el; let st_ll = (l*l) as i64 * el; + d.gemm_strided_batched(ch_t.buffer.as_ref(), st_lds, bh_t.buffer.as_ref(), st_lds, + cb_strided.buffer.as_ref(), st_ll, l, l, ds, bhc, DType::F16).unwrap(); + + // Grouped (broadcast offsets into per-group buffers). + let grp_off = |bh: usize| -> usize { let c=bh/h; let hh=bh%h; (c*g+hh/hpg)*(l*ds)*(el as usize) }; + let c_offs: Vec = (0..bhc).map(grp_off).collect(); + let b_offs = c_offs.clone(); + let cb_offs: Vec = (0..bhc).map(|bh| bh*(l*l)*(el as usize)).collect(); + d.gemm_batched(cg_t.buffer.as_ref(), &c_offs, bg_t.buffer.as_ref(), &b_offs, + cb_grouped.buffer.as_ref(), &cb_offs, l, l, ds, DType::F16).unwrap(); + d.synchronize().unwrap(); + + let mut sb = vec![0u8; bhc*l*l*2]; + d.download(cb_strided.buffer.as_ref(), &mut sb).unwrap(); + let s_ref: Vec = sb.chunks_exact(2).map(|c| f16_to_f32(u16::from_le_bytes([c[0],c[1]]))).collect(); + d.download(cb_grouped.buffer.as_ref(), &mut sb).unwrap(); + let s_got: Vec = sb.chunks_exact(2).map(|c| f16_to_f32(u16::from_le_bytes([c[0],c[1]]))).collect(); + + let mut emax = 0f32; let mut refmag = 0f32; let mut nanG = 0; let mut nanR = 0; + for i in 0..s_ref.len() { + if !s_got[i].is_finite() { nanG += 1; } + if !s_ref[i].is_finite() { nanR += 1; } + emax = emax.max((s_got[i]-s_ref[i]).abs()); + refmag = refmag.max(s_ref[i].abs()); + } + eprintln!("G1 grouped-vs-strided: max|Δ|={emax:.3e} refmag={refmag:.3e} nan(grouped)={nanG} nan(strided)={nanR}"); + eprintln!(" cb_ref[0..4]={:?}", &s_ref[..4]); + eprintln!(" cb_got[0..4]={:?}", &s_got[..4]); + assert_eq!(nanG, 0, "grouped produced {nanG} non-finite values"); + assert!(emax/refmag.max(1e-6) < 1e-2, "grouped mismatch"); + eprintln!("✅ grouped broadcast matches strided"); +} + +/// Regression guard for the multi-chunk null-stream race: the fused SSD path +/// (per-group B/C + cublasGemmBatchedEx device-ptr-array) must equal the +/// strided non-fused path at nc≥4 (T=512, L=128 → 4 chunks). The bug corrupted +/// ONLY the last chunk (the recycled ptr-array buffer was overwritten by a +/// synchronous null-stream H2D before the prior GEMM read it on self.stream). +/// The fix issues that H2D on self.stream; this asserts every chunk matches. +#[test] +fn fused_vs_nonfused_multichunk() { + use ffai_ops::ssm_prefill_scan_ssd; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + let (t, h, dh, ds, ng, l) = (512usize, 64usize, 64usize, 128usize, 8usize, 128u32); + let x = fill(t*h*dh, 1, 4.0); + let a_log: Vec = (0..h).map(|i| -3.0 + 6.0*(i as f32/h as f32)).collect(); + let b = fill(t*ng*ds, 3, 3.0); let c = fill(t*ng*ds, 4, 3.0); + let dsk = fill(h, 5, 1.0); + let dt: Vec = (0..t*h).map(|i| 0.05 + 1.4*(((i*13)%11) as f32/11.0)).collect(); + let si = vec![0.0f32; h*dh*ds]; + let mkf = |v: &[f32], n: usize| Tensor::new(d.upload(&v.iter().flat_map(|x|x.to_le_bytes()).collect::>()).unwrap(), vec![n], DType::F32); + let xt=mkf(&x,t*h*dh); let at=mkf(&a_log,h); let bt=mkf(&b,t*ng*ds); let ct=mkf(&c,t*ng*ds); + let dtk=mkf(&dsk,h); let dtt=mkf(&dt,t*h); let sit=mkf(&si,h*dh*ds); + let call = || ssm_prefill_scan_ssd(d,&xt,&at,&bt,&ct,&dtk,&dtt,&sit,t as u32,dh as u32,ds as u32,h as u32,ng as u32,l,None).unwrap(); + // Reference: non-fused strided path (NEMOTRON_SSD_FUSED_OFF disables fusion). + unsafe { std::env::set_var("NEMOTRON_SSD_FUSED_OFF", "1"); } + let (_, y_ref) = call(); d.synchronize().unwrap(); + unsafe { std::env::remove_var("NEMOTRON_SSD_FUSED_OFF"); } + // Fused path (now the default). + let (_, y_got) = call(); d.synchronize().unwrap(); + let mut rb = vec![0u8; t*h*dh*4]; d.download(y_ref.buffer.as_ref(), &mut rb).unwrap(); + let yr: Vec = rb.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(); + let mut gb = vec![0u8; t*h*dh*4]; d.download(y_got.buffer.as_ref(), &mut gb).unwrap(); + let yg: Vec = gb.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(); + let nbad = (0..yr.len()).filter(|&i| (yr[i]-yg[i]).abs() > 1.0 || !yg[i].is_finite()).count(); + eprintln!("fused vs non-fused (T=512, 4 chunks): {nbad}/{} mismatches", yr.len()); + assert_eq!(nbad, 0, "fused SSD path diverged from strided at nc=4 — null-stream race regression?"); + eprintln!("✅ fused == non-fused across all 4 chunks"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/hf_model.rs b/rust/crates/backends/ffai-cuda/tests/hf_model.rs new file mode 100644 index 00000000..b544dceb --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/hf_model.rs @@ -0,0 +1,75 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Generic dense-LLM verification harness on CUDA. Mirror of the Metal +//! harness: MODEL_DIR (HF dir) → load_hf → forward_single(TOK) → argmax. +//! MODEL_DIR=/path TOK=9707 EXPECT=21806 \ +//! cargo test -p ffai-cuda --features cuda --test hf_model -- --nocapture +#![cfg(feature = "cuda")] + +use ffai_core::DType; +use ffai_cuda::CudaDevice; +use ffai_models::llama::{forward_single, load_hf}; + +fn decode(b: &[u8], dt: DType) -> Vec { + match dt { + DType::F32 => b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(), + DType::BF16 => { + b.chunks_exact(2).map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect() + } + DType::F16 => b.chunks_exact(2).map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect(), + _ => vec![], + } +} +fn half_to_f32(h: u16) -> f32 { + let sign = ((h >> 15) & 1) as u32; + let exp = ((h >> 10) & 0x1f) as u32; + let mant = (h & 0x3ff) as u32; + let bits = if exp == 0 { + if mant == 0 { sign << 31 } else { + let mut e = -1i32; let mut m = mant; + while m & 0x400 == 0 { m <<= 1; e -= 1; } + (sign << 31) | (((e + 127 - 15) as u32) << 23) | ((m & 0x3ff) << 13) + } + } else if exp == 0x1f { + (sign << 31) | (0xff << 23) | (mant << 13) + } else { + (sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13) + }; + f32::from_bits(bits) +} + +#[test] +fn hf_model_forward_on_cuda() { + let Some(dir) = std::env::var("MODEL_DIR").ok() else { + eprintln!("set MODEL_DIR — skipping"); + return; + }; + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + eprintln!("device: {} — load_hf {dir}", dev.name()); + let m = load_hf(dev.as_ref(), &dir).expect("load_hf"); + eprintln!( + "cfg: hidden={} heads={}/{} hd={} inter={} layers={} vocab={} qk_norm={} bias={}", + m.cfg.hidden, m.cfg.n_q_heads, m.cfg.n_kv_heads, m.cfg.head_dim, m.cfg.intermediate, + m.n_layers, m.vocab, m.cfg.qk_norm, m.cfg.attn_bias + ); + + let token: u32 = std::env::var("TOK").ok().and_then(|s| s.parse().ok()).unwrap_or(9707); + let logits = forward_single(dev.as_ref(), &m.cfg, &m.weights, token).expect("forward"); + dev.synchronize().unwrap(); + let dt = logits.dtype; + let mut lb = vec![0u8; m.vocab * dt.size_bytes()]; + dev.download(logits.buffer.as_ref(), &mut lb).unwrap(); + let l = decode(&lb, dt); + + let mut idx: Vec = (0..m.vocab).collect(); + idx.sort_by(|&a, &b| l[b].total_cmp(&l[a])); + eprintln!("token {token} → top-5 (CUDA): {:?}", &idx[..5]); + eprintln!("ARGMAX (CUDA) = {}", idx[0]); + if let Some(exp) = std::env::var("EXPECT").ok().and_then(|s| s.parse::().ok()) { + assert_eq!(idx[0], exp, "CUDA argmax {} != expected {exp}", idx[0]); + eprintln!("✅ argmax matches expected {exp}"); + } +} diff --git a/rust/crates/backends/ffai-cuda/tests/llama_prefill.rs b/rust/crates/backends/ffai-cuda/tests/llama_prefill.rs new file mode 100644 index 00000000..6167fe7b --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/llama_prefill.rs @@ -0,0 +1,96 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Llama-family multi-token causal prefill WITH RoPE-at-position, verified vs +//! HF — completes the prefill primitive (GPT-2 prefill proved causal masking +//! with learned positions; this proves RoPE applied per real position + GQA + +//! SwiGLU in a causal prefill). Uses SmolVLM-256M's text model (a SmolLM2-style +//! Llama: hidden 576, GQA 9q/3kv, head_dim 64, rope θ=1e5, 30 layers), run +//! text-only. This is exactly the text-half prefill a full VLM forward runs +//! after splicing image embeds — every piece now on the shared op layer. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{matmul, rms_norm, rope_llama, sdpa_decode, swiglu}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn llama_causal_prefill_rope_vs_hf() { + let dir = std::env::var("SMOLVLM_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + + let (hid, nq, nkv, hd, inter, n_layers, vocab, eps, theta) = + (576usize, 9usize, 3usize, 64usize, 1536usize, 30usize, 49280usize, 1e-5f32, 100000.0f32); + let qd = nq * hd; let kvd = nkv * hd; // 576, 192 + let scale = 1.0 / (hd as f32).sqrt(); + let ids = [1usize, 2520, 1396, 253, 8137, 275, 253]; + let seq = ids.len(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + + let tp = "model.text_model"; + let embed = g(&format!("{tp}.embed_tokens.weight")); + let mut x = vec![0.0f32; seq * hid]; + for (i, &tok) in ids.iter().enumerate() { x[i * hid..(i + 1) * hid].copy_from_slice(&embed[tok * hid..(tok + 1) * hid]); } + + for l in 0..n_layers { + let p = format!("{tp}.layers.{l}"); + // attention (pre-RMSNorm) + let h = dl(&rms_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.input_layernorm.weight")), vec![hid]), eps).unwrap(), seq * hid); + let q = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![qd, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * qd); + let k = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![kvd, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * kvd); + let v = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![kvd, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * kvd); + // RoPE at each real position + let mut qr = vec![0.0f32; seq * qd]; let mut kr = vec![0.0f32; seq * kvd]; + for t in 0..seq { + let qt = rope_llama(d, &up(&q[t * qd..(t + 1) * qd], vec![nq, hd]), t as u32, theta, 1.0, 1.0, 1.0, 1e9).unwrap(); + qr[t * qd..(t + 1) * qd].copy_from_slice(&dl(&qt, qd)); + let kt = rope_llama(d, &up(&k[t * kvd..(t + 1) * kvd], vec![nkv, hd]), t as u32, theta, 1.0, 1.0, 1.0, 1e9).unwrap(); + kr[t * kvd..(t + 1) * kvd].copy_from_slice(&dl(&kt, kvd)); + } + // K/V cache [nkv, seq, hd] + let mut kc = vec![0.0f32; nkv * seq * hd]; let mut vc = vec![0.0f32; nkv * seq * hd]; + for t in 0..seq { for h2 in 0..nkv { for dd in 0..hd { + kc[h2 * seq * hd + t * hd + dd] = kr[t * kvd + h2 * hd + dd]; + vc[h2 * seq * hd + t * hd + dd] = v[t * kvd + h2 * hd + dd]; + }}} + let kt = up(&kc, vec![nkv, seq, hd]); let vt = up(&vc, vec![nkv, seq, hd]); + // causal GQA: position i attends [0,i] + let mut attn = vec![0.0f32; seq * qd]; + for i in 0..seq { + let qi = up(&qr[i * qd..(i + 1) * qd], vec![nq, hd]); + let a = sdpa_decode(d, &qi, &kt, &vt, hd, (i + 1) as u32, seq as u32, (nq / nkv) as u32, scale).unwrap(); + attn[i * qd..(i + 1) * qd].copy_from_slice(&dl(&a, qd)); + } + let o = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.o_proj.weight")), vec![hid, qd]), &up(&attn, vec![seq, qd])).unwrap(), seq * hid); + for i in 0..seq * hid { x[i] += o[i]; } + + // SwiGLU MLP (pre-RMSNorm) + let h2 = dl(&rms_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.post_attention_layernorm.weight")), vec![hid]), eps).unwrap(), seq * hid); + let gate = matmul(d, &up(&g(&format!("{p}.mlp.gate_proj.weight")), vec![inter, hid]), &up(&h2, vec![seq, hid])).unwrap(); + let upp = matmul(d, &up(&g(&format!("{p}.mlp.up_proj.weight")), vec![inter, hid]), &up(&h2, vec![seq, hid])).unwrap(); + let act = swiglu(d, &gate, &upp).unwrap(); + let down = dl(&matmul(d, &up(&g(&format!("{p}.mlp.down_proj.weight")), vec![hid, inter]), &act.reshaped(vec![seq, inter])).unwrap(), seq * hid); + for i in 0..seq * hid { x[i] += down[i]; } + } + + // final norm; logits at last position (untied lm_head) + let xf = dl(&rms_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{tp}.norm.weight")), vec![hid]), eps).unwrap(), seq * hid); + let last = &xf[(seq - 1) * hid..seq * hid]; + let logits = dl(&matmul(d, &up(&g("lm_head.weight"), vec![vocab, hid]), &up(last, vec![1, hid])).unwrap(), vocab); + let mut idx: Vec = (0..vocab).collect(); + idx.sort_by(|&a, &b| logits[b].total_cmp(&logits[a])); + eprintln!("Llama (SmolVLM-text) RoPE causal prefill (seq={seq}) on CUDA: top3 = {:?} (HF = [12642, 4052, 216])", &idx[..3]); + assert_eq!(&idx[..3], &[12642usize, 4052, 216], "Llama prefill top-3 != HF"); + eprintln!("✅ Llama RoPE-at-position causal prefill matches HF on the shared engine (GB10 sm_121) — full prefill path verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--HuggingFaceTB--SmolVLM-256M-Instruct/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-cuda/tests/mamba_bench.rs b/rust/crates/backends/ffai-cuda/tests/mamba_bench.rs new file mode 100644 index 00000000..b148859f --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/mamba_bench.rs @@ -0,0 +1,36 @@ +// Mamba2 kernel microbench — ssm_step + conv1d at NemotronH dims. +use ffai_core::{DType, Tensor}; +use ffai_ops::{conv1d_causal_step, silu, ssm_step}; +use ffai_cuda::CudaDevice; +use std::time::Instant; +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +#[test] +fn mamba_kernels() { + let Some(dev) = CudaDevice::create().expect("cuda") else { return; }; + let d = dev.as_ref(); + let (di, m_nh, m_dh, ds, ng, kc) = (4096usize, 64usize, 64usize, 128usize, 8usize, 4usize); + let conv_dim = di + 2 * ng * ds; + let up = |n: usize| Tensor::new(d.upload(&tb(&vec![0.1f32; n])).unwrap(), vec![n], DType::F32); + let (x, al, bm, cm, dk, dt, stt) = (up(di), up(m_nh), up(ng*ds), up(ng*ds), up(m_nh), up(m_nh), up(m_nh*m_dh*ds)); + let (cw, cb, cs, xbc) = (up(kc*conv_dim), up(conv_dim), up((kc-1)*conv_dim), up(conv_dim)); + for _ in 0..20 { + let _ = ssm_step(d, &x, &al, &bm, &cm, &dk, &dt, &stt, m_dh as u32, ds as u32, m_nh as u32, (m_nh/ng) as u32).unwrap(); + let _ = conv1d_causal_step(d, &xbc, &cw, &cb, &cs, conv_dim as u32, kc as u32).unwrap(); + let _ = silu(d, &x).unwrap(); + } + d.synchronize().unwrap(); + let it = 200; + let t = Instant::now(); + for _ in 0..it { let _ = ssm_step(d, &x, &al, &bm, &cm, &dk, &dt, &stt, m_dh as u32, ds as u32, m_nh as u32, (m_nh/ng) as u32).unwrap(); } + d.synchronize().unwrap(); + eprintln!("ssm_step: {:.1} us/call", t.elapsed().as_secs_f64() * 1e6 / it as f64); + d.synchronize().unwrap(); + let t = Instant::now(); + for _ in 0..it { let _ = conv1d_causal_step(d, &xbc, &cw, &cb, &cs, conv_dim as u32, kc as u32).unwrap(); } + d.synchronize().unwrap(); + eprintln!("conv1d_causal_step: {:.1} us/call", t.elapsed().as_secs_f64() * 1e6 / it as f64); + let t = Instant::now(); + for _ in 0..it { let _ = silu(d, &x).unwrap(); } + d.synchronize().unwrap(); + eprintln!("silu[4096]: {:.1} us/call", t.elapsed().as_secs_f64() * 1e6 / it as f64); +} diff --git a/rust/crates/backends/ffai-cuda/tests/moe_grouped_mma_test.rs b/rust/crates/backends/ffai-cuda/tests/moe_grouped_mma_test.rs new file mode 100644 index 00000000..6454afd6 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/moe_grouped_mma_test.rs @@ -0,0 +1,346 @@ +// Copyright 2026 Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Correctness for the grouped MoE GEMM (mma.sync register-O): +//! out[t,n] = Σ_k A[t,k]·W[eid(t)][n,k], sorted tokens, contiguous expert slab. +//! Reference computed on host (f32 accumulate of the same f16 inputs). + +use ffai_cuda::CudaDevice; +use ffai_core::{DType, Device, Tensor}; +use ffai_ops::{moe_grouped_gemm_mma, moe_grouped_gemm_cutlass, cast_f32_f16, cast_f16_f32, lt_fp4_quant_g, lt_fp4_quant_g_46, dec_ue4m3, + moe_grouped_gemm_w4a8, w4a8_actquant_grouped, w4a8_packw, w4a8_sfa_offsets, + moe_grouped_gemm_w8a8, w8a8_actquant_grouped, w8a8_packw, + lt_fp4_quant_slab, lt_fp4_quant_grouped, moe_grouped_gemm_cutlass_fp4}; + +fn rng(n: usize, seed: u64) -> Vec { + let mut v = vec![0f32; n]; + let mut s = seed; + for x in v.iter_mut() { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *x = ((s >> 33) as f32 / (1u64 << 31) as f32 - 1.0) * 0.2; + } + v +} +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } +fn dl(d: &dyn Device, t: &Tensor) -> Vec { + let n = t.elem_count(); let mut b = vec![0u8; n*4]; + d.download(t.buffer.as_ref(), &mut b).unwrap(); + b.chunks(4).map(|c| f32::from_le_bytes([c[0],c[1],c[2],c[3]])).collect() +} +fn run(d: &dyn Device, n_exp: usize, k: usize, n: usize, groups: &[(usize, usize)]) { + // groups: (expert_id, rows) + let mut g_starts = vec![0usize]; + let mut expert_ids = vec![]; + for &(e, r) in groups { expert_ids.push(e); g_starts.push(g_starts.last().unwrap() + r); } + let mt = *g_starts.last().unwrap(); + + let a_f = rng(mt*k, 11); + let w_f = rng(n_exp*n*k, 22); + let (a16, w16) = (&a_f, &w_f); // f16 rounding folded into the 3e-2 tol + + let up = |v: &[f32], sh: Vec| -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32).reshaped(sh) }; + let a_dev = cast_f32_f16(d, &up(&a_f, vec![mt, k])).unwrap().reshaped(vec![mt, k]); + let w_dev = cast_f32_f16(d, &up(&w_f, vec![n_exp, n, k])).unwrap().reshaped(vec![n_exp, n, k]); + + let out = moe_grouped_gemm_mma(d, &a_dev, &w_dev, &g_starts, &expert_ids, n, k).unwrap(); + let got = dl(d, &cast_f16_f32(d, &out).unwrap()); + + // host reference + let mut exp = vec![0f32; mt*n]; + for g in 0..groups.len() { + let (eid, _) = groups[g]; + for t in g_starts[g]..g_starts[g+1] { + for nn in 0..n { + let mut acc = 0f32; + for kk in 0..k { acc += a16[t*k+kk] * w16[(eid*n+nn)*k+kk]; } + exp[t*n+nn] = acc; + } + } + } + let (mut maxa, mut maxr) = (0f32, 0f32); + for (r, t) in exp.iter().zip(got.iter()) { + let a = (r-t).abs(); let rel = a / r.abs().max(1e-3); + if a>maxa {maxa=a;} if rel>maxr {maxr=rel;} + } + eprintln!("moe_grouped_mma mt={mt} N={n} K={k}: max_abs={maxa:.2e} max_rel={maxr:.2e}"); + assert!(maxr < 3e-2, "moe_grouped_mma: max_rel {maxr:.2e} > 3e-2 (max_abs {maxa:.2e})"); + eprintln!("moe_grouped_mma mt={mt} N={n} K={k}: PASS ✓"); +} + +fn run_cutlass(d: &dyn Device, n_exp: usize, k: usize, n: usize, groups: &[(usize, usize)]) { + let mut g_starts = vec![0usize]; let mut expert_ids = vec![]; + for &(e, r) in groups { expert_ids.push(e); g_starts.push(g_starts.last().unwrap() + r); } + let mt = *g_starts.last().unwrap(); + let a_f = rng(mt*k, 11); let w_f = rng(n_exp*n*k, 22); + let (a16, w16) = (&a_f, &w_f); + let up = |v: &[f32], sh: Vec| -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32).reshaped(sh) }; + let a_dev = cast_f32_f16(d, &up(&a_f, vec![mt, k])).unwrap().reshaped(vec![mt, k]); + let w_dev = cast_f32_f16(d, &up(&w_f, vec![n_exp, n, k])).unwrap().reshaped(vec![n_exp, n, k]); + let out = match moe_grouped_gemm_cutlass(d, &a_dev, &w_dev, &g_starts, &expert_ids, n, k) { + Ok(o) => o, + Err(e) => { eprintln!("cutlass unavailable ({e:?}) — skip"); return; } + }; + let got = dl(d, &cast_f16_f32(d, &out).unwrap()); + let mut exp = vec![0f32; mt*n]; + for g in 0..groups.len() { let (eid,_) = groups[g]; + for t in g_starts[g]..g_starts[g+1] { for nn in 0..n { + let mut acc=0f32; for kk in 0..k { acc += a16[t*k+kk]*w16[(eid*n+nn)*k+kk]; } + exp[t*n+nn]=acc; } } } + let mut maxr=0f32; for (r,t) in exp.iter().zip(got.iter()) { let rel=(r-t).abs()/r.abs().max(1e-2); if rel>maxr {maxr=rel;} } + eprintln!("moe_grouped_CUTLASS mt={mt} N={n} K={k}: max_rel={maxr:.2e}"); + assert!(maxr < 5e-2, "cutlass max_rel {maxr:.2e}"); + eprintln!("moe_grouped_CUTLASS mt={mt} N={n} K={k}: PASS ✓"); +} + +#[test] +fn moe_grouped_cutlass_nemotron() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run_cutlass(d.as_ref(), 8, 2688, 1856, &[(0,96),(3,40),(5,128),(7,17)]); +} + +fn run_w4a8(d: &dyn Device, n_exp: usize, k: usize, n: usize, groups: &[(usize, usize)]) { + let mut g_starts = vec![0usize]; let mut expert_ids = vec![]; + for &(e, r) in groups { expert_ids.push(e); g_starts.push(g_starts.last().unwrap() + r); } + let mt = *g_starts.last().unwrap(); + let a_f = rng(mt*k, 11); let w_f = rng(n_exp*n*k, 22); + let up = |v: &[f32], sh: Vec| -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32).reshaped(sh) }; + let a_dev = cast_f32_f16(d, &up(&a_f, vec![mt, k])).unwrap().reshaped(vec![mt, k]); + let w_dev = cast_f32_f16(d, &up(&w_f, vec![n_exp, n, k])).unwrap().reshaped(vec![n_exp, n, k]); + + // W4A8: fp8 e4m3 acts (group-aware per-32 SFA) x mxfp4 weights (per-32 SFB). + let (sfa_off, sfa_bytes) = w4a8_sfa_offsets(&g_starts, k); + let (a_q, a_sf) = match w4a8_actquant_grouped(d, &a_dev, &g_starts.iter().zip(g_starts.iter().skip(1)).map(|(s,e)|(e-s) as i32).collect::>(), &sfa_off, sfa_bytes, mt, k) { + Ok(o) => o, Err(e) => { eprintln!("w4a8 actquant unavailable ({e:?}) — skip"); return; } }; + let (w_pack, w_sf, sfb_eb) = match w4a8_packw(d, &w_dev, n_exp, n, k) { + Ok(o) => o, Err(e) => { eprintln!("w4a8 packw unavailable ({e:?}) — skip"); return; } }; + let out = match moe_grouped_gemm_w4a8(d, &a_q, &a_sf, &sfa_off, &w_pack, &w_sf, sfb_eb, &g_starts, &expert_ids, n, k) { + Ok(o) => o, Err(e) => { eprintln!("w4a8 gemm unavailable ({e:?}) — skip"); return; } }; + let got = dl(d, &cast_f16_f32(d, &out).unwrap()); + + // host f32 reference + cosine + let mut exp = vec![0f32; mt*n]; + for g in 0..groups.len() { let (eid,_) = groups[g]; + for t in g_starts[g]..g_starts[g+1] { for nn in 0..n { + let mut acc=0f32; for kk in 0..k { acc += a_f[t*k+kk]*w_f[(eid*n+nn)*k+kk]; } + exp[t*n+nn]=acc; } } } + let cosf = |g: &[f32]| -> f64 { + let (mut dot, mut na, mut nb) = (0f64, 0f64, 0f64); + for (r,t) in exp.iter().zip(g.iter()) { dot+=(*r as f64)*(*t as f64); na+=(*r as f64).powi(2); nb+=(*t as f64).powi(2); } + dot/(na.sqrt()*nb.sqrt()+1e-12) }; + let cos = cosf(&got); + let rms = |v: &[f32]| (v.iter().map(|x|(*x as f64)*(*x as f64)).sum::()/v.len() as f64).sqrt(); + eprintln!(" MAG: ref_rms={:.5} w4a8_rms={:.5} ratio={:.4}", rms(&exp), rms(&got), rms(&got)/rms(&exp).max(1e-30)); + // W4A4 NVFP4 (per-16 ue4m3 weights + global, fp4 acts) on the SAME inputs. + let (wp4, wsf4, gw4) = lt_fp4_quant_slab(d, &w_dev, n_exp, n, k).unwrap(); + let (ap4, asf4, aoff4, ags4) = lt_fp4_quant_grouped(d, &a_dev, mt, k, &g_starts).unwrap(); + let out4 = moe_grouped_gemm_cutlass_fp4(d, &ap4, &asf4, &aoff4, &ags4, &wp4, &wsf4, &gw4, &g_starts, &expert_ids, n, k).unwrap(); + let got4 = dl(d, &cast_f16_f32(d, &out4).unwrap()); + let cos4 = cosf(&got4); + eprintln!("moe_grouped_W4A8 mt={mt} N={n} K={k}: cosine={cos:.6} (W4A4 nvfp4 same-input cosine={cos4:.6})"); + assert!(cos >= cos4 - 1e-4, "w4a8 cosine {cos:.6} worse than W4A4 {cos4:.6}"); + eprintln!("moe_grouped_W4A8 mt={mt} N={n} K={k}: PASS ✓"); +} + +fn run_w8a8(d: &dyn Device, n_exp: usize, k: usize, n: usize, groups: &[(usize, usize)]) { + let mut g_starts = vec![0usize]; let mut expert_ids = vec![]; + for &(e, r) in groups { expert_ids.push(e); g_starts.push(g_starts.last().unwrap() + r); } + let mt = *g_starts.last().unwrap(); + let a_f = rng(mt*k, 11); let w_f = rng(n_exp*n*k, 22); + let up = |v: &[f32], sh: Vec| -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32).reshaped(sh) }; + let a_dev = cast_f32_f16(d, &up(&a_f, vec![mt, k])).unwrap().reshaped(vec![mt, k]); + let w_dev = cast_f32_f16(d, &up(&w_f, vec![n_exp, n, k])).unwrap().reshaped(vec![n_exp, n, k]); + + // W4A8: fp8 e4m3 acts (group-aware per-32 SFA) x mxfp4 weights (per-32 SFB). + let (sfa_off, sfa_bytes) = w4a8_sfa_offsets(&g_starts, k); + let (a_q, a_sf) = match w8a8_actquant_grouped(d, &a_dev, &g_starts.iter().zip(g_starts.iter().skip(1)).map(|(s,e)|(e-s) as i32).collect::>(), &sfa_off, sfa_bytes, mt, k) { + Ok(o) => o, Err(e) => { eprintln!("w8a8 actquant unavailable ({e:?}) — skip"); return; } }; + let (w_pack, w_sf, sfb_eb) = match w8a8_packw(d, &w_dev, n_exp, n, k) { + Ok(o) => o, Err(e) => { eprintln!("w8a8 packw unavailable ({e:?}) — skip"); return; } }; + let out = match moe_grouped_gemm_w8a8(d, &a_q, &a_sf, &sfa_off, &w_pack, &w_sf, sfb_eb, &g_starts, &expert_ids, n, k) { + Ok(o) => o, Err(e) => { eprintln!("w8a8 gemm unavailable ({e:?}) — skip"); return; } }; + let got = dl(d, &cast_f16_f32(d, &out).unwrap()); + + // host f32 reference + cosine + let mut exp = vec![0f32; mt*n]; + for g in 0..groups.len() { let (eid,_) = groups[g]; + for t in g_starts[g]..g_starts[g+1] { for nn in 0..n { + let mut acc=0f32; for kk in 0..k { acc += a_f[t*k+kk]*w_f[(eid*n+nn)*k+kk]; } + exp[t*n+nn]=acc; } } } + let cosf = |g: &[f32]| -> f64 { + let (mut dot, mut na, mut nb) = (0f64, 0f64, 0f64); + for (r,t) in exp.iter().zip(g.iter()) { dot+=(*r as f64)*(*t as f64); na+=(*r as f64).powi(2); nb+=(*t as f64).powi(2); } + dot/(na.sqrt()*nb.sqrt()+1e-12) }; + let cos = cosf(&got); + let rms = |v: &[f32]| (v.iter().map(|x|(*x as f64)*(*x as f64)).sum::()/v.len() as f64).sqrt(); + eprintln!(" MAG: ref_rms={:.5} w8a8_rms={:.5} ratio={:.4}", rms(&exp), rms(&got), rms(&got)/rms(&exp).max(1e-30)); + // W4A4 NVFP4 (per-16 ue4m3 weights + global, fp4 acts) on the SAME inputs. + let (wp4, wsf4, gw4) = lt_fp4_quant_slab(d, &w_dev, n_exp, n, k).unwrap(); + let (ap4, asf4, aoff4, ags4) = lt_fp4_quant_grouped(d, &a_dev, mt, k, &g_starts).unwrap(); + let out4 = moe_grouped_gemm_cutlass_fp4(d, &ap4, &asf4, &aoff4, &ags4, &wp4, &wsf4, &gw4, &g_starts, &expert_ids, n, k).unwrap(); + let got4 = dl(d, &cast_f16_f32(d, &out4).unwrap()); + let cos4 = cosf(&got4); + eprintln!("moe_grouped_W8A8 mt={mt} N={n} K={k}: cosine={cos:.6} (W4A4 nvfp4 same-input cosine={cos4:.6})"); + assert!(cos >= cos4 - 1e-4, "w8a8 cosine {cos:.6} worse than W4A4 {cos4:.6}"); + eprintln!("moe_grouped_W8A8 mt={mt} N={n} K={k}: PASS ✓"); +} + + +#[test] +fn moe_grouped_w8a8_nemotron() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run_w8a8(d.as_ref(), 8, 2688, 1856, &[(0,96),(3,40),(5,128),(7,17)]); +} +#[test] +fn moe_grouped_w8a8_down() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run_w8a8(d.as_ref(), 8, 1856, 2688, &[(0,96),(3,40),(5,128),(7,17)]); +} + +fn run_w4a8_const(d: &dyn Device, k: usize, n: usize, av: f32, wv: f32) { + let n_exp = 1; let groups = [(0usize, 128usize)]; + let mut g_starts = vec![0usize]; let mut expert_ids = vec![]; + for &(e, r) in groups.iter() { expert_ids.push(e); g_starts.push(g_starts.last().unwrap() + r); } + let mt = *g_starts.last().unwrap(); + let a_f = vec![av; mt*k]; let w_f = vec![wv; n_exp*n*k]; + let up = |v: &[f32], sh: Vec| -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32).reshaped(sh) }; + let a_dev = cast_f32_f16(d, &up(&a_f, vec![mt, k])).unwrap().reshaped(vec![mt, k]); + let w_dev = cast_f32_f16(d, &up(&w_f, vec![n_exp, n, k])).unwrap().reshaped(vec![n_exp, n, k]); + let (sfa_off, sfa_bytes) = w4a8_sfa_offsets(&g_starts, k); + let grows: Vec = g_starts.windows(2).map(|w|(w[1]-w[0]) as i32).collect(); + let (a_q, a_sf) = w4a8_actquant_grouped(d, &a_dev, &grows, &sfa_off, sfa_bytes, mt, k).unwrap(); + let (w_pack, w_sf, eb) = w4a8_packw(d, &w_dev, n_exp, n, k).unwrap(); + let out = moe_grouped_gemm_w4a8(d, &a_q, &a_sf, &sfa_off, &w_pack, &w_sf, eb, &g_starts, &expert_ids, n, k).unwrap(); + let got = dl(d, &cast_f16_f32(d, &out).unwrap()); + let expv = k as f32 * av * wv; + let gm = got.iter().sum::()/got.len() as f32; + eprintln!(" CONST k={k} a={av} w={wv}: expect={expv:.4} got_mean={gm:.4} ratio={:.5}", gm/expv); +} + +#[test] +fn moe_grouped_w4a8_const() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run_w4a8_const(d.as_ref(), 2688, 1856, 0.1, 0.1); + run_w4a8_const(d.as_ref(), 2688, 1856, 0.05, 0.05); + run_w4a8_const(d.as_ref(), 2688, 1856, 0.2, 0.05); +} + +#[test] +fn moe_grouped_w4a8_nemotron() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run_w4a8(d.as_ref(), 8, 2688, 1856, &[(0,96),(3,40),(5,128),(7,17)]); +} + +#[test] +fn moe_grouped_w4a8_stress() { + // 30B-like: many active experts, varied group sizes (incl m=1), up shape. + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + let sizes = [1usize,2,7,13,31,64,96,128,200,256,17,40,55,88,3,9]; + let groups: Vec<(usize,usize)> = sizes.iter().enumerate().map(|(e,&r)| (e, r)).collect(); + run_w4a8(d.as_ref(), 16, 2688, 1856, &groups); +} + +#[test] +fn moe_grouped_w4a8_stress_down() { + // 30B-like DOWN shape (K=inter padded to 1920), varied groups. + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + let sizes = [1usize,2,7,13,31,64,96,128,200,256,17,40,55,88,3,9]; + let groups: Vec<(usize,usize)> = sizes.iter().enumerate().map(|(e,&r)| (e, r)).collect(); + run_w4a8(d.as_ref(), 16, 1920, 2688, &groups); +} + +#[test] +fn moe_grouped_w4a8_down() { + // DOWN-proj shape: K=inter=1856 (NOT a multiple of MX atom-K=128), N=hid=2688. + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run_w4a8(d.as_ref(), 8, 1856, 2688, &[(0,96),(3,40),(5,128),(7,17)]); +} + +#[test] +fn moe_grouped_w4a8_down_pad() { + // DOWN-proj K padded to 1920 (=15*128) — verifies K%128 is the constraint. + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run_w4a8(d.as_ref(), 8, 1920, 2688, &[(0,96),(3,40),(5,128),(7,17)]); +} + + +#[test] +fn moe_grouped_mma_small() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run(d.as_ref(), 4, 64, 128, &[(0,20),(2,16),(3,8)]); +} +#[test] +fn moe_grouped_mma_nemotron_shape() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + // Nemotron up-proj shape: K=hid=2688, N=inter=1856, a few experts w/ ~96 rows. + run(d.as_ref(), 8, 2688, 1856, &[(0,96),(3,40),(5,128),(7,17)]); +} + +#[test] +fn moe_grouped_mma_bench() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + let dev = d.as_ref(); + // Full S=2048 MoE up-proj: 128 experts × 96 rows, N=inter=1856, K=hid=2688. + let (n_exp, n, k, rows) = (128usize, 1856usize, 2688usize, 96usize); + let mut g_starts = vec![0usize]; let mut eids = vec![]; + for e in 0..n_exp { eids.push(e); g_starts.push(g_starts.last().unwrap() + rows); } + let mt = *g_starts.last().unwrap(); + let a = cast_f32_f16(dev, &Tensor::new(dev.upload(&tb(&rng(mt*k, 1))).unwrap(), vec![mt*k], DType::F32)).unwrap().reshaped(vec![mt, k]); + let w = cast_f32_f16(dev, &Tensor::new(dev.upload(&tb(&rng(n_exp*n*k, 2))).unwrap(), vec![n_exp*n*k], DType::F32)).unwrap().reshaped(vec![n_exp, n, k]); + // warm + let _ = moe_grouped_gemm_mma(dev, &a, &w, &g_starts, &eids, n, k).unwrap(); + dev.synchronize().ok(); + let iters = 30; + let t = std::time::Instant::now(); + for _ in 0..iters { let _ = moe_grouped_gemm_mma(dev, &a, &w, &g_starts, &eids, n, k).unwrap(); } + dev.synchronize().ok(); + let ms = t.elapsed().as_secs_f64() * 1000.0 / iters as f64; + let tflops = 2.0 * mt as f64 * n as f64 * k as f64 / (ms * 1e-3) / 1e12; + eprintln!("moe_grouped_mma_bench: mt={mt} N={n} K={k} → {ms:.3} ms/call, {tflops:.1} TFLOP/s (cuBLAS per-expert ref: 15.7)"); +} + +const E2M1L: [f32;8] = [0.0,0.5,1.0,1.5,2.0,3.0,4.0,6.0]; +// host dequant of the lt_fp4_quant_g(_46) swizzled NVFP4 layout: v = e2m1 * dec_ue4m3(sf[soff]) * global. +fn dequant_ltg(d: &dyn Device, pack: &Tensor, sf: &Tensor, gs: &Tensor, r: usize, k: usize) -> Vec { + let kb_=k/16; let kb4_=(kb_+3)/4; let sf_bytes=r.div_ceil(128)*512*kb4_; + let pb = { let mut b=vec![0u8;(r*k)/2]; d.download(pack.buffer.as_ref(),&mut b).unwrap(); b }; + let sfb = { let mut b=vec![0u8;sf_bytes]; d.download(sf.buffer.as_ref(),&mut b).unwrap(); b }; + let gsb = { let mut b=vec![0u8;8]; d.download(gs.buffer.as_ref(),&mut b).unwrap(); b }; + let global = f32::from_le_bytes([gsb[0],gsb[1],gsb[2],gsb[3]]); + let kb = k/16; let kb4 = (kb+3)/4; + let mut out = vec![0f32; r*k]; + for rr in 0..r { for b in 0..kb { + let soff = (rr>>7)*(512*kb4)+(b>>2)*512+(rr&31)*16+((rr>>5)&3)*4+(b&3); + let bs = dec_ue4m3(sfb[soff]) * global; + for i in 0..16 { + let elem = b*16+i; let byte = (rr*k+elem)/2; let lonib = (elem%2)==0; + let code = if lonib { pb[byte] & 0xf } else { (pb[byte]>>4) & 0xf }; + let sign = if code & 8 != 0 { -1.0 } else { 1.0 }; + out[rr*k+elem] = sign * E2M1L[(code&7) as usize] * bs; + } + }} + out +} +fn cos(a: &[f32], b: &[f32]) -> f64 { + let (mut dt,mut na,mut nb)=(0f64,0f64,0f64); + for (x,y) in a.iter().zip(b.iter()){ dt+=(*x as f64)*(*y as f64); na+=(*x as f64).powi(2); nb+=(*y as f64).powi(2); } + dt/(na.sqrt()*nb.sqrt()+1e-30) +} +#[test] +fn lt_fp4_46_unit() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + let (r,k)=(256usize,2688usize); + let xf = rng(r*k, 7); + let x = cast_f32_f16(d.as_ref(), &Tensor::new(d.upload(&tb(&xf)).unwrap(), vec![r*k], DType::F32).reshaped(vec![r,k])).unwrap().reshaped(vec![r,k]); + let (p0,sf0,g0) = lt_fp4_quant_g(d.as_ref(), &x, r, k).unwrap(); + let (p6,sf6,g6) = lt_fp4_quant_g_46(d.as_ref(), &x, r, k).unwrap(); + let rec0 = dequant_ltg(d.as_ref(), &p0,&sf0,&g0, r,k); + let rec6 = dequant_ltg(d.as_ref(), &p6,&sf6,&g6, r,k); + eprintln!(" lt_fp4 baseline cosine-vs-input = {:.6}", cos(&xf,&rec0)); + eprintln!(" lt_fp4 _46(4/6) cosine-vs-input = {:.6}", cos(&xf,&rec6)); + let rms=|v:&[f32]|(v.iter().map(|x|(*x as f64)*(*x as f64)).sum::()/v.len() as f64).sqrt(); + eprintln!(" rms: input={:.4} base={:.4} _46={:.4}", rms(&xf), rms(&rec0), rms(&rec6)); +} diff --git a/rust/crates/backends/ffai-cuda/tests/moe_route_sort_test.rs b/rust/crates/backends/ffai-cuda/tests/moe_route_sort_test.rs new file mode 100644 index 00000000..23a480cd --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/moe_route_sort_test.rs @@ -0,0 +1,80 @@ +// Copyright 2026 Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! On-device MoE route+counting-sort (moe_route_sort_device) vs host reference. +//! Validates: per-expert group counts (offsets) + the (token,weight) multiset per +//! expert match the host sigmoid+bias+topk+stable-sort. + +use ffai_cuda::CudaDevice; +use ffai_core::{DType, Device, Tensor}; +use ffai_ops::moe_route_sort_device; + +fn rng(n: usize, seed: u64) -> Vec { + let mut v = vec![0f32; n]; let mut s = seed; + for x in v.iter_mut() { s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *x = ((s >> 33) as f32 / (1u64 << 31) as f32 - 1.0) * 2.0; } + v +} +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } +fn dl_u32(d: &dyn Device, t: &Tensor) -> Vec { + let n = t.elem_count(); let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); + b.chunks(4).map(|c| u32::from_le_bytes([c[0],c[1],c[2],c[3]])).collect() +} +fn dl_f32(d: &dyn Device, t: &Tensor) -> Vec { + let n = t.elem_count(); let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); + b.chunks(4).map(|c| f32::from_le_bytes([c[0],c[1],c[2],c[3]])).collect() +} + +fn run(d: &dyn Device, s: usize, n_exp: usize, top_k: usize) { + let scale = 1.5f32; + let logits = rng(s*n_exp, 7); + let bias = rng(n_exp, 99).iter().map(|x| x*0.3).collect::>(); + let up = |v: &[f32]| Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32); + let lg = up(&logits).reshaped(vec![s, n_exp]); + let bi = up(&bias); + let (st, sw, off) = moe_route_sort_device(d, &lg, &bi, n_exp, top_k, scale).unwrap(); + let stok = dl_u32(d, &st); let swt = dl_f32(d, &sw); let offs = dl_u32(d, &off); + + // host reference + let mut host: Vec> = vec![Vec::new(); n_exp]; // per expert: (token, weight) + for ti in 0..s { + let l = &logits[ti*n_exp..(ti+1)*n_exp]; + let sig: Vec = l.iter().map(|&z| 1.0/(1.0+(-z).exp())).collect(); + let b: Vec = (0..n_exp).map(|i| sig[i]+bias[i]).collect(); + let mut idx: Vec = (0..n_exp).collect(); + idx.sort_by(|&a,&c| b[c].partial_cmp(&b[a]).unwrap()); + let chosen = &idx[..top_k]; + let wsum: f32 = chosen.iter().map(|&e| sig[e]).sum::() + 1e-20; + for &e in chosen { host[e].push((ti as u32, sig[e]/wsum*scale)); } + } + // 1. counts/offsets match + for e in 0..n_exp { + let dev_cnt = (offs[e+1]-offs[e]) as usize; + assert_eq!(dev_cnt, host[e].len(), "expert {e}: dev count {dev_cnt} != host {}", host[e].len()); + } + assert_eq!(*offs.last().unwrap() as usize, s*top_k, "total mt mismatch"); + // 2. per-expert (token,weight) multiset matches (order within group may differ) + let mut maxw = 0f32; + for e in 0..n_exp { + let (lo, hi) = (offs[e] as usize, offs[e+1] as usize); + let mut dev_grp: Vec<(u32,f32)> = (lo..hi).map(|r| (stok[r], swt[r])).collect(); + let mut h = host[e].clone(); + dev_grp.sort_by_key(|x| x.0); h.sort_by_key(|x| x.0); + for ((dt,dw),(ht,hw)) in dev_grp.iter().zip(h.iter()) { + assert_eq!(dt, ht, "expert {e}: token mismatch"); + let we = (dw-hw).abs(); if we>maxw {maxw=we;} + } + } + eprintln!("moe_route_sort s={s} n_exp={n_exp} top_k={top_k}: counts+tokens MATCH, max|Δweight|={maxw:.2e} PASS ✓"); + assert!(maxw < 1e-4, "weight mismatch {maxw:.2e}"); +} + +#[test] +fn moe_route_sort_small() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run(d.as_ref(), 64, 16, 4); +} +#[test] +fn moe_route_sort_nemotron() { + let Some(d) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA"); return; }; + run(d.as_ref(), 2048, 128, 6); // Nemotron: s=2048, 128 experts, top-6 +} diff --git a/rust/crates/backends/ffai-cuda/tests/moe_test.rs b/rust/crates/backends/ffai-cuda/tests/moe_test.rs new file mode 100644 index 00000000..c4cfea1d --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/moe_test.rs @@ -0,0 +1,379 @@ +#![cfg(feature = "cuda")] +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! +//! Also includes: `moe_w4a16_marlin_vs_standard` — validates that the +//! Marlin-coalesced W4A16 kernel produces bit-close output to the standard +//! scattered-nibble kernel on a synthetic small problem. +//! MoE feed-forward (router → top-k → per-expert SwiGLU → weighted sum) on +//! Metal vs a CPU reference. Proves the MoE compute path — the exotic family +//! covering DeepSeek-V4 / GPT-OSS / Granite4 / Qwen-MoE — runs correctly on +//! the shared op layer. (Real MoE-model-vs-HF verification follows once the +//! large expert weights are staged.) + +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_models::moe::{ExpertWeights, MoeMlp, moe_mlp}; + +fn tb(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn fill(n: usize, s: usize) -> Vec { + (0..n).map(|i| (((i * 7 + s * 131) % 97) as f32 - 48.0) * 0.01).collect() +} +fn tens(d: &dyn Device, v: &[f32], shape: Vec) -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), shape, DType::F32) +} +fn silu(x: f32) -> f32 { + x / (1.0 + (-x).exp()) +} +fn mv(m: &[f32], v: &[f32], rows: usize, k: usize) -> Vec { + (0..rows).map(|r| (0..k).map(|c| m[r * k + c] * v[c]).sum()).collect() +} + +#[test] +fn moe_mlp_on_cuda_matches_cpu() { + let Some(dev) = CudaDevice::create().expect("metal init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + const H: usize = 256; + const INTER: usize = 512; + const NE: usize = 8; + const TOPK: usize = 2; + + let router = fill(NE * H, 1); + let h = fill(H, 99); + let experts: Vec<(Vec, Vec, Vec)> = (0..NE) + .map(|e| (fill(INTER * H, 10 + e), fill(INTER * H, 100 + e), fill(H * INTER, 200 + e))) + .collect(); + + let w = MoeMlp { + router: tens(dev.as_ref(), &router, vec![NE, H]), + experts: experts + .iter() + .map(|(g, u, d)| ExpertWeights { + gate: tens(dev.as_ref(), g, vec![INTER, H]), + up: tens(dev.as_ref(), u, vec![INTER, H]), + down: tens(dev.as_ref(), d, vec![H, INTER]), + }) + .collect(), + top_k: TOPK, + norm_topk: true, + }; + let th = tens(dev.as_ref(), &h, vec![H]); + + let out = moe_mlp(dev.as_ref(), &w, &th).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; H * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got: Vec = ob.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(); + + // CPU reference. + let logits = mv(&router, &h, NE, H); + let mut order: Vec = (0..NE).collect(); + order.sort_by(|&a, &b| logits[b].total_cmp(&logits[a])); + let top: Vec = order.into_iter().take(TOPK).collect(); + let m = top.iter().map(|&i| logits[i]).fold(f32::MIN, f32::max); + let e: Vec = top.iter().map(|&i| (logits[i] - m).exp()).collect(); + let s: f32 = e.iter().sum(); + let wts: Vec = e.iter().map(|x| x / s).collect(); + let mut want = vec![0.0f32; H]; + for (&ei, &gw) in top.iter().zip(&wts) { + let (g, u, d) = &experts[ei]; + let gate = mv(g, &h, INTER, H); + let up = mv(u, &h, INTER, H); + let act: Vec = (0..INTER).map(|i| silu(gate[i]) * up[i]).collect(); + let out = mv(d, &act, H, INTER); + for i in 0..H { + want[i] += gw * out[i]; + } + } + + let mut err = 0.0f32; + for i in 0..H { + err = err.max((got[i] - want[i]).abs()); + } + eprintln!("MoE MLP on CUDA vs CPU: max|Δ|={err:.3e} (top experts {top:?})"); + assert!(err <= 5e-3, "moe mismatch: {err:.3e}"); + eprintln!("MoE feed-forward runs on CUDA through the shared op layer, matches CPU."); +} + +/// Validate that `moe_w4a16_marlin` (Marlin coalesced layout) produces output +/// within 1e-2 of `moe_w4a16` (standard scattered layout) on a small problem. +/// +/// Both kernels compute the same matrix product; the only difference is how +/// the Q4 nibbles are stored in memory. This test proves the permutation + +/// dequant are bit-faithful (same signed nibble → same dequant value → same output). +#[test] +fn moe_w4a16_marlin_vs_standard() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping moe_w4a16_marlin_vs_standard"); + return; + }; + use ffai_core::{DType, Tensor}; + use ffai_ops::{moe_w4a16, moe_w4a16_marlin, permute_q4_to_marlin, quantize_q4}; + + // Problem dimensions: must be multiples of 64/32. + // Use 4 experts, m_total=64, n_out=128, k_in=64. + const N_EXP: usize = 4; + const M: usize = 64; // m_total (tokens sorted by expert, 16 per expert) + const N: usize = 128; // n_out (output features) + const K: usize = 64; // k_in (input features) + + // Deterministic weight data. + let wv: Vec = (0..N_EXP * N * K) + .map(|i| (i as f32 * 0.019 - 0.3).sin() * 0.9) + .collect(); + // Activation matrix: sorted by expert (16 rows per expert). + let xv: Vec = (0..M * K) + .map(|i| (i as f32 * 0.013 - 0.4).cos() * 1.1) + .collect(); + // Expert index for each row (16 rows × expert 0, 1, 2, 3). + let idx: Vec = (0..M).map(|r| (r / 16) as u32).collect(); + + // Quantize → standard layout. + let (qs_std, scales_f32) = quantize_q4(&wv, N_EXP * N, K); + + // Convert scales and x to f16 bytes using manual IEEE 754 conversion. + // f32→f16: use the truncate-to-f16 bit trick (loses mantissa bits, OK for scales). + let f32_to_f16_bits = |x: f32| -> u16 { + // Simple f32→f16 via u32 bit manipulation (round-to-zero). + let u = x.to_bits(); + let sign = (u >> 16) & 0x8000; + let exp = ((u >> 23) & 0xFF) as i32 - 127 + 15; + let mant = (u >> 13) & 0x3FF; + if exp <= 0 { sign as u16 } + else if exp >= 31 { (sign | 0x7C00) as u16 } + else { (sign | ((exp as u32) << 10) | mant) as u16 } + }; + let f16_to_f32 = |bits: u16| -> f32 { + let sign = ((bits >> 15) & 1) as u32; + let exp = ((bits >> 10) & 0x1F) as u32; + let mant = (bits & 0x3FF) as u32; + if exp == 0 { 0.0f32 } + else if exp == 31 { f32::INFINITY * if sign == 1 { -1.0 } else { 1.0 } } + else { f32::from_bits((sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13)) } + }; + + let scales_f16: Vec = scales_f32.iter().map(|&s| f32_to_f16_bits(s)).collect(); + let xv_f16: Vec = xv.iter().map(|&x| f32_to_f16_bits(x)).collect(); + + // Permute to Marlin layout. + let qs_mar = permute_q4_to_marlin(&qs_std, N_EXP, N, K); + assert_eq!(qs_std.len(), qs_mar.len(), "Marlin layout must have same u32 count"); + + // Upload everything to GPU. + let to_u8_u32 = |v: &[u32]| -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() }; + let to_u8_u16 = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() }; + let d = dev.as_ref(); + + let qs_std_dev = Tensor::new(d.upload(&to_u8_u32(&qs_std)).unwrap(), vec![qs_std.len()], DType::U32); + let qs_mar_dev = Tensor::new(d.upload(&to_u8_u32(&qs_mar)).unwrap(), vec![qs_mar.len()], DType::U32); + let sc_dev = Tensor::new(d.upload(&to_u8_u16(&scales_f16)).unwrap(), vec![scales_f16.len()], DType::F16); + let x_f16_dev = Tensor::new(d.upload(&to_u8_u16(&xv_f16)).unwrap(), vec![M, K], DType::F16); + let idx_dev = Tensor::new(d.upload(&to_u8_u32(&idx)).unwrap(), vec![M], DType::U32); + + // Run both kernels. + let out_std = moe_w4a16(d, &x_f16_dev, &qs_std_dev, &sc_dev, &idx_dev, M, N, K).unwrap(); + let out_mar = moe_w4a16_marlin(d, &x_f16_dev, &qs_mar_dev, &sc_dev, &idx_dev, M, N, K).unwrap(); + d.synchronize().unwrap(); + + // Download and compare (output is f16). + let dl_f16 = |t: &Tensor| -> Vec { + let mut buf = vec![0u8; t.elem_count() * 2]; + d.download(t.buffer.as_ref(), &mut buf).unwrap(); + buf.chunks_exact(2).map(|b| f16_to_f32(u16::from_le_bytes([b[0], b[1]]))).collect() + }; + let got_std = dl_f16(&out_std); + let got_mar = dl_f16(&out_mar); + + let mut max_err = 0.0f32; + let mut max_rel = 0.0f32; + for i in 0..M * N { + let ae = (got_std[i] - got_mar[i]).abs(); + let re = if got_std[i].abs() > 1e-6 { ae / got_std[i].abs() } else { ae }; + max_err = max_err.max(ae); + max_rel = max_rel.max(re); + } + eprintln!("moe_w4a16_marlin vs standard: max_abs={max_err:.3e} max_rel={max_rel:.3e}"); + assert!(max_err < 1e-2, + "Marlin kernel output deviates from standard: max|Δ|={max_err:.3e} (tol 1e-2)"); + eprintln!("moe_w4a16_marlin correctness: PASS (max_abs={max_err:.3e})"); +} + +// ── f16 helpers shared by the straddle test ───────────────────────────────── +fn f32_to_f16_bits(x: f32) -> u16 { + let u = x.to_bits(); + let sign = (u >> 16) & 0x8000; + let exp = ((u >> 23) & 0xFF) as i32 - 127 + 15; + let mant = (u >> 13) & 0x3FF; + if exp <= 0 { + sign as u16 + } else if exp >= 31 { + (sign | 0x7C00) as u16 + } else { + (sign | ((exp as u32) << 10) | mant) as u16 + } +} +fn f16_to_f32(bits: u16) -> f32 { + let sign = ((bits >> 15) & 1) as u32; + let exp = ((bits >> 10) & 0x1F) as u32; + let mant = (bits & 0x3FF) as u32; + if exp == 0 { + 0.0f32 + } else if exp == 31 { + f32::INFINITY * if sign == 1 { -1.0 } else { 1.0 } + } else { + f32::from_bits((sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13)) + } +} + +/// Reproduce the e2e divergence (argmax 3260 vs correct 1104) at the kernel +/// level on SMALL shapes. The original `moe_w4a16_marlin_vs_standard` test uses +/// M=64 (single tile), N=128 (÷128), 16 rows/expert — which misses two boundary +/// cases the real Nemotron shape (m≈12288, n_out=1856, n_exp=128, top_k=6) hits: +/// +/// (A) an expert sub-run that STRADDLES a 64-row M-tile boundary, and +/// (B) n_out NOT a multiple of 128 (1856 is ÷64 but not ÷128). +/// +/// Here: M=128 (two M-tiles), N=192 (÷64 but NOT ÷128), ragged per-expert row +/// counts (50 / 40 / 38) so expert 1 spans rows 50..89 → straddles the tile +/// boundary at row 64. Compares standard `moe_w4a16` AND `moe_bgemm_q4_bm64` +/// against the (correct) Marlin path on IDENTICAL weights/scales/indices. +#[test] +fn moe_w4a16_straddle_vs_marlin() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping moe_w4a16_straddle_vs_marlin"); + return; + }; + use ffai_core::{DType, Tensor}; + use ffai_ops::{moe_bgemm_q4_bm64, moe_w4a16, moe_w4a16_marlin, permute_q4_to_marlin, quantize_q4}; + + const N_EXP: usize = 3; + const M: usize = 128; // two 64-row M-tiles + const N: usize = 192; // ÷64 but NOT ÷128 ← suspect (B) + const K: usize = 64; + + // Ragged per-expert row counts → expert 1 straddles the tile boundary (64). + // expert 0: rows 0..50 (50 rows, inside tile 0) + // expert 1: rows 50..90 (40 rows, STRADDLES 64) ← suspect (A) + // expert 2: rows 90..128 (38 rows, inside tile 1) + let counts = [50usize, 40, 38]; + let mut idx: Vec = Vec::with_capacity(M); + for (e, &c) in counts.iter().enumerate() { + for _ in 0..c { + idx.push(e as u32); + } + } + assert_eq!(idx.len(), M); + + let wv: Vec = (0..N_EXP * N * K) + .map(|i| (i as f32 * 0.019 - 0.3).sin() * 0.9) + .collect(); + let xv: Vec = (0..M * K) + .map(|i| (i as f32 * 0.013 - 0.4).cos() * 1.1) + .collect(); + + let (qs_std, scales_f32) = quantize_q4(&wv, N_EXP * N, K); + let scales_f16: Vec = scales_f32.iter().map(|&s| f32_to_f16_bits(s)).collect(); + let xv_f16: Vec = xv.iter().map(|&x| f32_to_f16_bits(x)).collect(); + + let qs_mar = permute_q4_to_marlin(&qs_std, N_EXP, N, K); + + let to_u8_u32 = |v: &[u32]| -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() }; + let to_u8_u16 = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() }; + let d = dev.as_ref(); + + let qs_std_dev = Tensor::new(d.upload(&to_u8_u32(&qs_std)).unwrap(), vec![qs_std.len()], DType::U32); + let qs_mar_dev = Tensor::new(d.upload(&to_u8_u32(&qs_mar)).unwrap(), vec![qs_mar.len()], DType::U32); + let sc_dev = Tensor::new(d.upload(&to_u8_u16(&scales_f16)).unwrap(), vec![scales_f16.len()], DType::F16); + let x_f16_dev = Tensor::new(d.upload(&to_u8_u16(&xv_f16)).unwrap(), vec![M, K], DType::F16); + let idx_dev = Tensor::new(d.upload(&to_u8_u32(&idx)).unwrap(), vec![M], DType::U32); + + let out_std = moe_w4a16(d, &x_f16_dev, &qs_std_dev, &sc_dev, &idx_dev, M, N, K).unwrap(); + let out_bg = moe_bgemm_q4_bm64(d, &x_f16_dev, &qs_std_dev, &sc_dev, &idx_dev, M, N, K).unwrap(); + let out_mar = moe_w4a16_marlin(d, &x_f16_dev, &qs_mar_dev, &sc_dev, &idx_dev, M, N, K).unwrap(); + d.synchronize().unwrap(); + + let dl_f16 = |t: &Tensor| -> Vec { + let mut buf = vec![0u8; t.elem_count() * 2]; + d.download(t.buffer.as_ref(), &mut buf).unwrap(); + buf.chunks_exact(2).map(|b| f16_to_f32(u16::from_le_bytes([b[0], b[1]]))).collect() + }; + let got_std = dl_f16(&out_std); + let got_bg = dl_f16(&out_bg); + let got_mar = dl_f16(&out_mar); + + // Per-row worst error vs the (correct) Marlin reference, to localize which + // rows diverge (expect: rows in the straddling run and/or last n-cols). + let row_err = |got: &[f32]| -> (f32, usize, usize) { + let mut max_ae = 0.0f32; + let mut max_row = 0; + let mut max_col = 0; + for r in 0..M { + for c in 0..N { + let ae = (got[r * N + c] - got_mar[r * N + c]).abs(); + if ae > max_ae { + max_ae = ae; + max_row = r; + max_col = c; + } + } + } + (max_ae, max_row, max_col) + }; + let (std_ae, std_r, std_c) = row_err(&got_std); + let (bg_ae, bg_r, bg_c) = row_err(&got_bg); + eprintln!( + "straddle: moe_w4a16 max|Δ|={std_ae:.3e} @ row {std_r} (exp {}) col {std_c}", + idx[std_r] + ); + eprintln!( + "straddle: moe_bgemm max|Δ|={bg_ae:.3e} @ row {bg_r} (exp {}) col {bg_c} marlin={} bgemm={}", + idx[bg_r], + got_mar[bg_r * N + bg_c], + got_bg[bg_r * N + bg_c] + ); + // Histogram: how many bgemm cells exceed thresholds (broad precision vs a + // few structurally-wrong cells), and which n-tile they fall in. + let mut n_gt_1e3 = 0usize; + let mut n_gt_1e2 = 0usize; + let mut ntile_hits = [0usize; 4]; + for r in 0..M { + for c in 0..N { + let ae = (got_bg[r * N + c] - got_mar[r * N + c]).abs(); + if ae > 1e-3 { + n_gt_1e3 += 1; + ntile_hits[c / 64] += 1; + } + if ae > 1e-2 { + n_gt_1e2 += 1; + } + } + } + eprintln!( + "straddle: moe_bgemm cells |Δ|>1e-3: {n_gt_1e3} / {} ; >1e-2: {n_gt_1e2} ; by n-tile(64): {:?}", + M * N, + ntile_hits + ); + + // `moe_w4a16` (standard scattered WMMA) is BIT-EXACT with Marlin here + // (same 16×16×16 fragment reduction order) → strict absolute bound. + assert!( + std_ae < 1e-2, + "moe_w4a16 (standard) diverges from Marlin on straddle/N=192: max|Δ|={std_ae:.3e} @ row {std_r} col {std_c}" + ); + // `moe_bgemm_q4_bm64` uses a different MMA tiling (coop_tile 32×32×32) and so + // accumulates f16 products in a different order → up to ~1 f16 ULP of drift + // on large-magnitude outputs (observed: 1.56e-2 == one ULP at |out|≈30, on + // 6/24576 cells). That is precision noise, NOT a structural index bug — a + // straddle/n-tile indexing bug would corrupt whole rows/columns. Guard with + // a magnitude-relative bound (catches structural errors, tolerates ULP). + let bg_rel = bg_ae / got_mar[bg_r * N + bg_c].abs().max(1.0); + assert!( + bg_rel < 1e-3, + "moe_bgemm_q4_bm64 diverges from Marlin on straddle/N=192: max|Δ|={bg_ae:.3e} (rel {bg_rel:.3e}) @ row {bg_r} col {bg_c}" + ); + eprintln!("moe_w4a16_straddle_vs_marlin: PASS (std bit-exact; bgemm within 1 f16 ULP)"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/nemotron.rs b/rust/crates/backends/ffai-cuda/tests/nemotron.rs new file mode 100644 index 00000000..6e113152 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/nemotron.rs @@ -0,0 +1,18 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! NemotronH-Nano-Omni-30B-A3B text backbone on CUDA (spark-only; 62GB BF16). +//! Not in run_all — Metal can't hold this model. NEMOTRON_DIR overrides path, +//! NEMOTRON_ARGMAX= turns the print into an assert. +use ffai_cuda::CudaDevice; +#[test] +fn nemotron_text_forward() { + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + ffai_modeltests::verify_nemotron(dev.as_ref(), "GB10 sm_121"); +} + +/// Resident-Q8 decode throughput (quantize+upload once, time steady-state tok/s). +#[test] +fn nemotron_decode_bench() { + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + ffai_modeltests::bench_nemotron(dev.as_ref(), "GB10 sm_121"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/proj_fp8_bench.rs b/rust/crates/backends/ffai-cuda/tests/proj_fp8_bench.rs new file mode 100644 index 00000000..1fc70b01 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/proj_fp8_bench.rs @@ -0,0 +1,105 @@ +// FP8 W8A8 dense-proj GEMM micro-bench (synthetic, GEMM-level). +// Times f16 cuBLAS (gemm_cublas_f32out) vs W8A8 e4m3 (per-tensor gemm_fp8, +// per-channel gemm_fp8_perchan) at the real Nemotron-30B dense-proj shapes. +// Isolates: does the W8A8 path realize the ~1.8x GB10 roofline at the GEMM +// level, and does it stay numerically close (cosine vs f16) per-tensor vs +// per-channel? Seconds per run — no 30B reload. +use ffai_cuda::CudaDevice; +use ffai_core::{DType, Device, Tensor}; +use ffai_ops::{cast_f32_f16, fp8_quant, fp8_quant_perrow, gemm_cublas_f32out, gemm_fp8, gemm_fp8_perchan}; +use std::time::Instant; + +fn rng(n: usize, seed: u64) -> Vec { + let mut v = vec![0f32; n]; + let mut s: u64 = seed; + for x in v.iter_mut() { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *x = (s >> 33) as f32 / (1u64 << 31) as f32 * 0.2 - 0.1; // [-0.1,0.1] + } + v +} +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } + +fn download_f32(dev: &dyn Device, t: &Tensor, n: usize) -> Vec { + let mut bytes = vec![0u8; n * 4]; + dev.download(t.buffer.as_ref(), &mut bytes).unwrap(); + bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect() +} + +fn cosine(a: &[f32], b: &[f32]) -> f64 { + let mut dot = 0f64; let mut na = 0f64; let mut nb = 0f64; + for i in 0..a.len() { dot += a[i] as f64 * b[i] as f64; na += (a[i] as f64).powi(2); nb += (b[i] as f64).powi(2); } + dot / (na.sqrt() * nb.sqrt() + 1e-30) +} + +// m=rows(seq), n=out_features, k=in_features. out[m,n] = x[m,k] · w[n,k]^T +fn bench_shape(dev: &dyn Device, label: &str, m: usize, n: usize, k: usize, n_warm: usize, n_iter: usize) { + let xf = rng(m * k, 11); + let wf = rng(n * k, 22); + let x32 = Tensor::new(dev.upload(&tb(&xf)).unwrap(), vec![m * k], DType::F32).reshaped(vec![m, k]); + let w32 = Tensor::new(dev.upload(&tb(&wf)).unwrap(), vec![n * k], DType::F32).reshaped(vec![n, k]); + let xh = cast_f32_f16(dev, &x32).unwrap(); // [m,k] f16 + let wh = cast_f32_f16(dev, &w32).unwrap(); // [n,k] f16 + let flops = 2.0 * m as f64 * n as f64 * k as f64; + + // ---- f16 cuBLAS reference (the current proj path) ---- + let f16_ref = gemm_cublas_f32out(dev, &xh, &wh, m, n, k).unwrap(); // [m,n] f32 + for _ in 0..n_warm { let _ = gemm_cublas_f32out(dev, &xh, &wh, m, n, k).unwrap(); } + dev.synchronize().ok(); + let t0 = Instant::now(); + for _ in 0..n_iter { let _ = gemm_cublas_f32out(dev, &xh, &wh, m, n, k).unwrap(); } + dev.synchronize().ok(); + let t_f16 = t0.elapsed().as_secs_f64() / n_iter as f64; + let ref_v = download_f32(dev, &f16_ref, m * n); + + // ---- W8A8 per-TENSOR e4m3 (gemm_fp8) — GEMM-only timing (quant excluded) ---- + // Pre-quantize once (weights fixed; per-tensor act scale recomputed per call + // in the real engine, but here we time the GEMM core in isolation). + let (wq_pt, wsc_pt) = fp8_quant(dev, &wh, n * k).unwrap(); + let (xq_pt, xsc_pt) = fp8_quant(dev, &xh, m * k).unwrap(); + let pt_ref = gemm_fp8(dev, &xq_pt, &xsc_pt, &wq_pt, &wsc_pt, m, n, k, true).unwrap(); + for _ in 0..n_warm { let _ = gemm_fp8(dev, &xq_pt, &xsc_pt, &wq_pt, &wsc_pt, m, n, k, true).unwrap(); } + dev.synchronize().ok(); + let t1 = Instant::now(); + for _ in 0..n_iter { let _ = gemm_fp8(dev, &xq_pt, &xsc_pt, &wq_pt, &wsc_pt, m, n, k, true).unwrap(); } + dev.synchronize().ok(); + let t_pt = t1.elapsed().as_secs_f64() / n_iter as f64; + let cos_pt = cosine(&ref_v, &download_f32(dev, &pt_ref, m * n)); + + // ---- W8A8 per-CHANNEL e4m3 (gemm_fp8_perchan) — GEMM + rowcol-scale post-pass ---- + let (wq_pc, wsc_pc) = fp8_quant_perrow(dev, &wh, n, k).unwrap(); + let (xq_pc, xsc_pc) = fp8_quant_perrow(dev, &xh, m, k).unwrap(); + let pc_ref = gemm_fp8_perchan(dev, &xq_pc, &xsc_pc, &wq_pc, &wsc_pc, m, n, k).unwrap(); + for _ in 0..n_warm { let _ = gemm_fp8_perchan(dev, &xq_pc, &xsc_pc, &wq_pc, &wsc_pc, m, n, k).unwrap(); } + dev.synchronize().ok(); + let t2 = Instant::now(); + for _ in 0..n_iter { let _ = gemm_fp8_perchan(dev, &xq_pc, &xsc_pc, &wq_pc, &wsc_pc, m, n, k).unwrap(); } + dev.synchronize().ok(); + let t_pc = t2.elapsed().as_secs_f64() / n_iter as f64; + let cos_pc = cosine(&ref_v, &download_f32(dev, &pc_ref, m * n)); + + let tf = |t: f64| flops / t / 1e12; + println!( + "{label:22} m={m:5} n={n:5} k={k:5} | f16 {f16ms:7.3}ms {f16tf:6.1}TF | W8A8-PT {ptms:7.3}ms {pttf:6.1}TF {ptx:4.2}x cos {cospt:.5} | W8A8-PC {pcms:7.3}ms {pctf:6.1}TF {pcx:4.2}x cos {cospc:.5}", + f16ms = t_f16 * 1e3, f16tf = tf(t_f16), + ptms = t_pt * 1e3, pttf = tf(t_pt), ptx = t_f16 / t_pt, cospt = cos_pt, + pcms = t_pc * 1e3, pctf = tf(t_pc), pcx = t_f16 / t_pc, cospc = cos_pc, + ); +} + +#[test] +fn proj_fp8_bench() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA -- skip"); return; }; + let s: usize = std::env::var("PROJ_S").ok().and_then(|v| v.parse().ok()).unwrap_or(2048); + let (nw, ni) = (5usize, 30usize); + println!("\n==== FP8 W8A8 dense-proj GEMM micro-bench (S={s}) — Nemotron-30B shapes ===="); + println!("(f16 = current cuBLAS proj path; W8A8-PT = per-tensor gemm_fp8; W8A8-PC = per-channel gemm_fp8_perchan)\n"); + // m = seq tokens (S); n = out_features; k = in_features. + bench_shape(dev.as_ref(), "mamba.in_proj", s, 10304, 2688, nw, ni); + bench_shape(dev.as_ref(), "mamba.out_proj", s, 2688, 4096, nw, ni); + bench_shape(dev.as_ref(), "attn.q_proj", s, 4096, 2688, nw, ni); + bench_shape(dev.as_ref(), "attn.o_proj", s, 2688, 4096, nw, ni); + bench_shape(dev.as_ref(), "shared.up", s, 3712, 2688, nw, ni); + bench_shape(dev.as_ref(), "shared.down", s, 2688, 3712, nw, ni); + println!("\n==== done ====\n"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/qwen3_real.rs b/rust/crates/backends/ffai-cuda/tests/qwen3_real.rs new file mode 100644 index 00000000..143b1752 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/qwen3_real.rs @@ -0,0 +1,77 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Run a REAL model — Qwen3-0.6B, BF16 weights from disk — through the +//! shared Rust engine on CUDA, and print the next-token prediction. A +//! single-token forward (the token attends to itself at pos 0) is exactly +//! HF's 1-token forward, so the argmax here is directly comparable to +//! `transformers` for `input_ids=[token]`. +//! +//! Run: QWEN3_PATH=~/Qwen3-0.6B-hf/model.safetensors \ +//! cargo test -p ffai-cuda --features cuda --test qwen3_real -- --nocapture +#![cfg(feature = "cuda")] + +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_models::llama::{LlamaConfig, forward_single, load_qwen3}; + +/// BF16 little-endian bytes → f32. +fn bf16_to_f32(b: &[u8]) -> Vec { + b.chunks_exact(2) + .map(|c| { + let bits = u16::from_le_bytes([c[0], c[1]]); + f32::from_bits((bits as u32) << 16) + }) + .collect() +} + +#[test] +fn qwen3_0_6b_real_forward_on_cuda() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { + eprintln!("no CUDA device — skipping"); + return; + }; + let path = std::env::var("QWEN3_PATH") + .unwrap_or_else(|_| "/home/pidtom/Qwen3-0.6B-hf/model.safetensors".to_string()); + eprintln!("loading {path}"); + let st = SafeTensors::open(&path).expect("open safetensors"); + + let cfg = LlamaConfig { + hidden: 1024, + n_q_heads: 16, + n_kv_heads: 8, + head_dim: 128, + intermediate: 3072, + rope_theta: 1_000_000.0, + eps: 1e-6, + qk_norm: true, + attn_bias: false, + }; + const N_LAYERS: usize = 28; + const VOCAB: usize = 151936; + + let mw = load_qwen3(dev.as_ref(), &st, &cfg, N_LAYERS).expect("load qwen3"); + eprintln!("model loaded ({N_LAYERS} layers, vocab {VOCAB})"); + + // Token to condition on (override with TOK=…). 9707 = "Hello" in Qwen. + let token: u32 = std::env::var("TOK").ok().and_then(|s| s.parse().ok()).unwrap_or(9707); + + let logits = forward_single(dev.as_ref(), &cfg, &mw, token).expect("forward"); + dev.synchronize().unwrap(); + + // Output dtype = lm_head dtype = BF16. + let mut lb = vec![0u8; VOCAB * 2]; + dev.download(logits.buffer.as_ref(), &mut lb).unwrap(); + let l = bf16_to_f32(&lb); + assert_eq!(l.len(), VOCAB); + assert!(l.iter().all(|x| x.is_finite()), "non-finite logits"); + + // Top-5 predicted tokens. + let mut idx: Vec = (0..VOCAB).collect(); + idx.sort_by(|&a, &b| l[b].total_cmp(&l[a])); + eprintln!("input token {token} → top-5 next-token logits:"); + for &i in idx.iter().take(5) { + eprintln!(" id {i:>6} logit {:.4}", l[i]); + } + eprintln!("ARGMAX next token = {} (logit {:.4})", idx[0], l[idx[0]]); + eprintln!("✅ Qwen3-0.6B (real BF16 weights) ran through the shared Rust engine on GB10."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/qwen_moe.rs b/rust/crates/backends/ffai-cuda/tests/qwen_moe.rs new file mode 100644 index 00000000..677a03ed --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/qwen_moe.rs @@ -0,0 +1,76 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Real MoE feed-forward vs HF: load a real Qwen2-MoE block's weights and +//! run the MoE forward (softmax→top-k routing, SwiGLU experts, sigmoid-gated +//! shared expert) on the shared op layer, comparing to HF transformers' +//! Qwen2MoeSparseMoeBlock output for the same input. Turns MoE from +//! "compute-verified-vs-CPU" into "real-weights-verified-vs-HF". +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gemv, swiglu}; + +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} + +#[test] +fn qwen2_moe_block_on_metal_matches_hf() { + let path = std::env::var("QWENMOE_DIR").ok() + .map(|d| format!("{d}/model.safetensors")) + .unwrap_or_else(|| std::fs::read_to_string("/tmp/qwenmoe_path.txt").map(|s| format!("{}/model.safetensors", s.trim())).unwrap_or_default()); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + + let (h, _moe_i, ne, tk) = (32usize, 44usize, 8usize, 4usize); + let t = |name: &str| -> Tensor { + let (bytes, dt, shape) = st.tensor(name).unwrap(); + assert_eq!(dt, DType::F32); + Tensor::new(dev.upload(bytes).unwrap(), shape.to_vec(), DType::F32) + }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b=vec![0u8;n*4]; dev.download(t.buffer.as_ref(),&mut b).unwrap(); fb(&b) }; + + let x: Vec = (0..h).map(|i| i as f32 * 0.03 - 0.5).collect(); + let tx = Tensor::new(dev.upload(&tb(&x)).unwrap(), vec![h], DType::F32); + let p = "model.layers.0.mlp"; + + // Router: softmax over all, top-k (norm_topk_prob=false → raw probs). + let logits_t = gemv(dev.as_ref(), &t(&format!("{p}.gate.weight")), &tx).unwrap(); + dev.synchronize().unwrap(); + let logits = dl(&logits_t, ne); + let m = logits.iter().cloned().fold(f32::MIN, f32::max); + let exps: Vec = logits.iter().map(|v| (v - m).exp()).collect(); + let s: f32 = exps.iter().sum(); + let probs: Vec = exps.iter().map(|e| e / s).collect(); + let mut order: Vec = (0..ne).collect(); + order.sort_by(|&a, &b| probs[b].total_cmp(&probs[a])); + let top: Vec = order.into_iter().take(tk).collect(); + + let mut acc = vec![0.0f32; h]; + for &e in &top { + let g = gemv(dev.as_ref(), &t(&format!("{p}.experts.{e}.gate_proj.weight")), &tx).unwrap(); + let u = gemv(dev.as_ref(), &t(&format!("{p}.experts.{e}.up_proj.weight")), &tx).unwrap(); + let act = swiglu(dev.as_ref(), &g, &u).unwrap(); + let o = gemv(dev.as_ref(), &t(&format!("{p}.experts.{e}.down_proj.weight")), &act).unwrap(); + dev.synchronize().unwrap(); + let ov = dl(&o, h); + for i in 0..h { acc[i] += probs[e] * ov[i]; } + } + // Shared expert, sigmoid-gated. + let slog = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert_gate.weight")), &tx).unwrap(); + dev.synchronize().unwrap(); + let sg_val = 1.0 / (1.0 + (-dl(&slog, 1)[0]).exp()); + let sg = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert.gate_proj.weight")), &tx).unwrap(); + let su = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert.up_proj.weight")), &tx).unwrap(); + let sact = swiglu(dev.as_ref(), &sg, &su).unwrap(); + let so = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert.down_proj.weight")), &sact).unwrap(); + dev.synchronize().unwrap(); + let sov = dl(&so, h); + for i in 0..h { acc[i] += sg_val * sov[i]; } + + let hf = [-2.6e-05f32,-2.5e-05,4e-06,-7.1e-05,1.3e-05,3.5e-05,2.5e-05,1e-06,-2.7e-05,-2e-05,3.2e-05,-2.2e-05,3.2e-05,2.8e-05,-2.5e-05,9e-06,-2.5e-05,0.0,6e-05,2e-06,-1.9e-05,1e-05,-1.6e-05,3.2e-05,3.1e-05,2.2e-05,-3.2e-05,2.2e-05,9e-06,2.5e-05,1e-05,-2.2e-05]; + let mut e = 0.0f32; for i in 0..h { e = e.max((acc[i]-hf[i]).abs()); } + eprintln!("Qwen2-MoE block on Metal vs HF: max|Δ|={e:.2e} (top experts {top:?})"); + eprintln!("rust[..6]={:?}", &acc[..6]); + assert!(e <= 3e-6, "qwen moe vs HF mismatch: {e:.2e}"); + eprintln!("✅ Real Qwen2-MoE feed-forward matches HF on the shared op layer (GB10 sm_121)."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/raw_gemv.rs b/rust/crates/backends/ffai-cuda/tests/raw_gemv.rs new file mode 100644 index 00000000..b7b90b98 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/raw_gemv.rs @@ -0,0 +1,112 @@ +//! Raw-NVRTC GEMV microbench: does an EXPLICIT uint4 weight load (impossible in +//! the #[kernel] DSL — its auto-vectorizer won't emit it) beat the DSL's scalar +//! strided load? Both kernels hand-written CUDA-C, compiled via the same NVRTC +//! path, timed in one process on identical buffers. If uint4 ≫ scalar, raw +//! kernels are the real path past the DSL plateau. +//! +//! cargo test --release -p ffai-cuda --features cuda --test raw_gemv -- --nocapture + +#![cfg(feature = "cuda")] + +use metaltile_runtime::CudaDevice; +use std::os::raw::c_void; +use std::time::Instant; + +const SCALAR: &str = r#" +extern "C" __global__ void rawscalar(const unsigned* qs,const float* d,const float* x,float* out,int k_in,int rpg){ + int row=blockIdx.x;int lane=threadIdx.x;int bpr=k_in>>5;int nwords=bpr*4; + const unsigned* qrow=qs+(size_t)row*nwords;const float* drow=d+(size_t)row*bpr; + const float* xrow=x+(size_t)(row/rpg)*k_in;float dot=0.f; + for(int j=lane;j>2;int sub=j&3;unsigned p=qrow[j];float sc=drow[blk]; + const float* xb=xrow+(blk<<5)+(sub<<3);float a=0.f; + #pragma unroll + for(int i=0;i<8;i++){int nb=(p>>(i*4))&0xf;a+=(float)(nb>7?nb-16:nb)*xb[i];} + dot+=sc*a;} + #pragma unroll + for(int o=16;o;o>>=1)dot+=__shfl_down_sync(0xffffffffu,dot,o); + if(lane==0)out[row]=dot; +} +"#; + +const UINT4: &str = r#" +extern "C" __global__ void rawuint4(const unsigned* qs,const float* d,const float* x,float* out,int k_in,int rpg){ + int row=blockIdx.x;int lane=threadIdx.x;int bpr=k_in>>5; + const unsigned* qrow=qs+(size_t)row*bpr*4;const float* drow=d+(size_t)row*bpr; + const float* xrow=x+(size_t)(row/rpg)*k_in;float dot=0.f; + for(int b=lane;b(qrow+b*4); // explicit 128-bit coalesced load + float sc=drow[b];const float* xb=xrow+(b<<5); + unsigned ws[4]={w.x,w.y,w.z,w.w};float a=0.f; + #pragma unroll + for(int wi=0;wi<4;wi++){unsigned p=ws[wi]; + #pragma unroll + for(int i=0;i<8;i++){int nb=(p>>(i*4))&0xf;a+=(float)(nb>7?nb-16:nb)*xb[wi*8+i];}} + dot+=sc*a;} + #pragma unroll + for(int o=16;o;o>>=1)dot+=__shfl_down_sync(0xffffffffu,dot,o); + if(lane==0)out[row]=dot; +} +"#; + +const F16SCALE: &str = r#" +#include +extern "C" __global__ void rawf16s(const unsigned* qs,const __half* d,const float* x,float* out,int k_in,int rpg){ + int row=blockIdx.x;int lane=threadIdx.x;int bpr=k_in>>5;int nwords=bpr*4; + const unsigned* qrow=qs+(size_t)row*nwords;const __half* drow=d+(size_t)row*bpr; + const float* xrow=x+(size_t)(row/rpg)*k_in;float dot=0.f; + for(int j=lane;j>2;int sub=j&3;unsigned p=qrow[j];float sc=__half2float(drow[blk]); + const float* xb=xrow+(blk<<5)+(sub<<3);float a=0.f; + #pragma unroll + for(int i=0;i<8;i++){int nb=(p>>(i*4))&0xf;a+=(float)(nb>7?nb-16:nb)*xb[i];} + dot+=sc*a;} + #pragma unroll + for(int o=16;o;o>>=1)dot+=__shfl_down_sync(0xffffffffu,dot,o); + if(lane==0)out[row]=dot; +} +"#; + +fn bench(dev: &CudaDevice, name: &str) { + // Representative decode GEMVs (m_out, k_in). + for &(m, k, lbl) in &[(10304usize, 2688usize, "in_proj"), (131072usize, 2688usize, "lm_head"), (2688usize, 2688usize, "o_proj")] { + let bpr = k / 32; + let nwords = bpr * 4; + let scale_sz = if name == "f16scale" { 2 } else { 4 }; + let qbytes = vec![0u8; m * nwords * 4]; + let dbytes = vec![0u8; m * bpr * scale_sz]; + let xbytes = vec![1u8; k * 4]; + let qp = dev.alloc_raw(qbytes.len()).unwrap(); dev.htod(qp, &qbytes).unwrap(); + let dp = dev.alloc_raw(dbytes.len()).unwrap(); dev.htod(dp, &dbytes).unwrap(); + let xp = dev.alloc_raw(xbytes.len()).unwrap(); dev.htod(xp, &xbytes).unwrap(); + let op = dev.alloc_raw(m * 4).unwrap(); + + let (src, fname) = if name == "uint4" { (UINT4, "rawuint4") } else if name == "f16scale" { (F16SCALE, "rawf16s") } else { (SCALAR, "rawscalar") }; + let module = dev.compile(src, "raw.cu").unwrap(); + let func = module.function(fname).unwrap(); + + let (mut a_q, mut a_d, mut a_x, mut a_o) = (qp, dp, xp, op); + let (mut a_k, mut a_r) = (k as i32, m as i32); + let mut args: [*mut c_void; 6] = [ + &mut a_q as *mut _ as *mut c_void, &mut a_d as *mut _ as *mut c_void, + &mut a_x as *mut _ as *mut c_void, &mut a_o as *mut _ as *mut c_void, + &mut a_k as *mut _ as *mut c_void, &mut a_r as *mut _ as *mut c_void, + ]; + for _ in 0..5 { dev.launch_async(func, [m as u32,1,1], [32,1,1], 0, &mut args).unwrap(); } + dev.synchronize().unwrap(); + let it = 100; + let t = Instant::now(); + for _ in 0..it { dev.launch_async(func, [m as u32,1,1], [32,1,1], 0, &mut args).unwrap(); } + dev.synchronize().unwrap(); + let us = t.elapsed().as_secs_f64() * 1e6 / it as f64; + let gb = (m * nwords * 4) as f64 / 1e9; + eprintln!(" [{name}] {lbl} ({m}x{k}): {us:.1} us/call, {:.0} GB/s", gb / (us / 1e6)); + } +} + +#[test] +fn raw_scalar_vs_uint4() { + let dev = match CudaDevice::create().unwrap() { Some(d) => d, None => { eprintln!("no cuda"); return; } }; + eprintln!("──── raw GEMV: scalar (DSL-equivalent) vs explicit uint4 ────"); + bench(&dev, "scalar"); + bench(&dev, "f16scale"); + bench(&dev, "uint4"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/sdpa_flash_test.rs b/rust/crates/backends/ffai-cuda/tests/sdpa_flash_test.rs new file mode 100644 index 00000000..476314f7 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/sdpa_flash_test.rs @@ -0,0 +1,195 @@ +// Copyright 2026 Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Correctness test for the FUSED single-kernel FlashAttention `sdpa_flash_fused` +//! vs the scalar `sdpa_multi` ground truth (GQA: nq=32, nkv=2, hd=128, causal). +//! Threshold: max |rel_err| ≤ 1e-2 (f16-input vs f32 tolerance). + +use ffai_cuda::CudaDevice; +use ffai_core::{DType, Device, Tensor}; +use ffai_ops::{sdpa_multi, sdpa_flash_fused, sdpa_flash_wmma, sdpa_flash_mma}; + +fn rng(n: usize, seed: u64) -> Vec { + let mut v = vec![0f32; n]; + let mut s: u64 = seed; + for x in v.iter_mut() { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let f = (s >> 33) as f32 / (1u64 << 31) as f32 - 1.0; + *x = f * 0.2; + } + v +} + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } +fn dl_f32(d: &dyn Device, t: &Tensor) -> Vec { + let n = t.elem_count(); + let mut b = vec![0u8; n * 4]; + d.download(t.buffer.as_ref(), &mut b).unwrap(); + b.chunks(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect() +} + +fn run(dev: &dyn Device, s: usize, in_f16: bool) { + let (nq, nkv, hd) = (32usize, 2usize, 128usize); + let hpg = nq / nkv; + let base_kv = 0usize; + let n_kv = base_kv + s; + let scale = 1.0f32 / (hd as f32).sqrt(); + + let q_vals = rng(s * nq * hd, 1234); + let k_vals = rng(nkv * n_kv * hd, 5678); + let v_vals = rng(nkv * n_kv * hd, 9012); + + let up = |v: &[f32]| Tensor::new(dev.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32); + let q = up(&q_vals).reshaped(vec![s, nq, hd]); + let k = up(&k_vals).reshaped(vec![nkv, n_kv, hd]); + let v = up(&v_vals).reshaped(vec![nkv, n_kv, hd]); + + // Ground truth = scalar sdpa_multi (always f32). + let ref_out = sdpa_multi(dev, &q, &k, &v, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + // Optionally cast inputs to f16 to exercise the f16 path. + let (qf, kf, vf) = if in_f16 { + (ffai_ops::cast_f32_f16(dev, &q).unwrap().reshaped(vec![s, nq, hd]), + ffai_ops::cast_f32_f16(dev, &k).unwrap().reshaped(vec![nkv, n_kv, hd]), + ffai_ops::cast_f32_f16(dev, &v).unwrap().reshaped(vec![nkv, n_kv, hd])) + } else { (q.clone(), k.clone(), v.clone()) }; + + let fused = sdpa_flash_fused(dev, &qf, &kf, &vf, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + // fused output matches input dtype; widen f16 → f32 for comparison/download. + let fused = if in_f16 { ffai_ops::cast_f16_f32(dev, &fused).unwrap() } else { fused }; + + let a = dl_f32(dev, &ref_out); + let b = dl_f32(dev, &fused); + assert_eq!(a.len(), b.len()); + let (mut max_abs, mut max_rel) = (0f32, 0f32); + for (r, t) in a.iter().zip(b.iter()) { + let abs = (r - t).abs(); + let rel = abs / r.abs().max(1e-6); + if abs > max_abs { max_abs = abs; } + if rel > max_rel { max_rel = rel; } + } + let tag = if in_f16 { "f16" } else { "f32" }; + eprintln!("sdpa_flash_fused S={s} [{tag}]: max_abs={max_abs:.2e} max_rel={max_rel:.2e}"); + let tol = if in_f16 { 2e-2 } else { 1e-3 }; + assert!(max_rel < tol, "sdpa_flash_fused S={s} [{tag}]: max_rel={max_rel:.2e} > {tol:.0e}"); + eprintln!("sdpa_flash_fused S={s} [{tag}]: PASS ✓"); +} + +fn run_wmma(dev: &dyn Device, s: usize) { + let (nq, nkv, hd) = (32usize, 2usize, 128usize); + let hpg = nq / nkv; + let base_kv = 0usize; + let n_kv = base_kv + s; + let scale = 1.0f32 / (hd as f32).sqrt(); + let q_vals = rng(s * nq * hd, 1234); + let k_vals = rng(nkv * n_kv * hd, 5678); + let v_vals = rng(nkv * n_kv * hd, 9012); + let up = |v: &[f32]| Tensor::new(dev.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32); + let q = up(&q_vals).reshaped(vec![s, nq, hd]); + let k = up(&k_vals).reshaped(vec![nkv, n_kv, hd]); + let v = up(&v_vals).reshaped(vec![nkv, n_kv, hd]); + let ref_out = sdpa_multi(dev, &q, &k, &v, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + // f16 inputs (wmma needs tensor cores). + let qf = ffai_ops::cast_f32_f16(dev, &q).unwrap().reshaped(vec![s, nq, hd]); + let kf = ffai_ops::cast_f32_f16(dev, &k).unwrap().reshaped(vec![nkv, n_kv, hd]); + let vf = ffai_ops::cast_f32_f16(dev, &v).unwrap().reshaped(vec![nkv, n_kv, hd]); + let w = sdpa_flash_wmma(dev, &qf, &kf, &vf, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + let w = ffai_ops::cast_f16_f32(dev, &w).unwrap(); + let a = dl_f32(dev, &ref_out); + let b = dl_f32(dev, &w); + let (mut max_abs, mut max_rel) = (0f32, 0f32); + for (r, t) in a.iter().zip(b.iter()) { + let abs = (r - t).abs(); + let rel = abs / r.abs().max(1e-6); + if abs > max_abs { max_abs = abs; } + if rel > max_rel { max_rel = rel; } + } + eprintln!("sdpa_flash_wmma S={s}: max_abs={max_abs:.2e} max_rel={max_rel:.2e}"); + assert!(max_rel < 3e-2, "sdpa_flash_wmma S={s}: max_rel={max_rel:.2e} > 3e-2"); + eprintln!("sdpa_flash_wmma S={s}: PASS ✓"); +} + +fn run_mma(dev: &dyn Device, s: usize) { + let (nq, nkv, hd) = (32usize, 2usize, 128usize); + let hpg = nq / nkv; + let base_kv = 0usize; + let n_kv = base_kv + s; + let scale = 1.0f32 / (hd as f32).sqrt(); + let q_vals = rng(s * nq * hd, 1234); + let k_vals = rng(nkv * n_kv * hd, 5678); + let v_vals = rng(nkv * n_kv * hd, 9012); + let up = |v: &[f32]| Tensor::new(dev.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32); + let q = up(&q_vals).reshaped(vec![s, nq, hd]); + let k = up(&k_vals).reshaped(vec![nkv, n_kv, hd]); + let v = up(&v_vals).reshaped(vec![nkv, n_kv, hd]); + let ref_out = sdpa_multi(dev, &q, &k, &v, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + let qf = ffai_ops::cast_f32_f16(dev, &q).unwrap().reshaped(vec![s, nq, hd]); + let kf = ffai_ops::cast_f32_f16(dev, &k).unwrap().reshaped(vec![nkv, n_kv, hd]); + let vf = ffai_ops::cast_f32_f16(dev, &v).unwrap().reshaped(vec![nkv, n_kv, hd]); + let w = sdpa_flash_mma(dev, &qf, &kf, &vf, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + let w = ffai_ops::cast_f16_f32(dev, &w).unwrap(); + let a = dl_f32(dev, &ref_out); + let b = dl_f32(dev, &w); + let (mut max_abs, mut max_rel) = (0f32, 0f32); + for (r, t) in a.iter().zip(b.iter()) { + let abs = (r - t).abs(); + let rel = abs / r.abs().max(1e-6); + if abs > max_abs { max_abs = abs; } + if rel > max_rel { max_rel = rel; } + } + eprintln!("sdpa_flash_mma S={s}: max_abs={max_abs:.2e} max_rel={max_rel:.2e}"); + assert!(max_rel < 3e-2, "sdpa_flash_mma S={s}: max_rel={max_rel:.2e} > 3e-2"); + eprintln!("sdpa_flash_mma S={s}: PASS ✓"); +} + +#[test] +fn sdpa_flash_mma_s128() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_mma(dev.as_ref(), 128); +} +#[test] +fn sdpa_flash_mma_s512() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_mma(dev.as_ref(), 512); +} +#[test] +fn sdpa_flash_mma_s2048() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_mma(dev.as_ref(), 2048); +} + +#[test] +fn sdpa_flash_wmma_s128() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_wmma(dev.as_ref(), 128); +} +#[test] +fn sdpa_flash_wmma_s512() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_wmma(dev.as_ref(), 512); +} +#[test] +fn sdpa_flash_wmma_s2048() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_wmma(dev.as_ref(), 2048); +} + +#[test] +fn sdpa_flash_fused_s128_f32() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run(dev.as_ref(), 128, false); +} +#[test] +fn sdpa_flash_fused_s512_f16() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run(dev.as_ref(), 512, true); +} +#[test] +fn sdpa_flash_fused_s2048_f16() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run(dev.as_ref(), 2048, true); +} diff --git a/rust/crates/backends/ffai-cuda/tests/sdpa_tc_bench.rs b/rust/crates/backends/ffai-cuda/tests/sdpa_tc_bench.rs new file mode 100644 index 00000000..a723077a --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/sdpa_tc_bench.rs @@ -0,0 +1,93 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! sdpa_multi vs sdpa_multi_tc throughput benchmark. +//! S=512/2048/4096/8192, nq=32 nkv=2 hd=128 causal=true (Nemotron dims). +use ffai_cuda::CudaDevice; +use ffai_core::{DType, Device, Tensor}; +use ffai_ops::{sdpa_multi, sdpa_multi_tc}; +use std::time::Instant; + +fn rng(n: usize, seed: u64) -> Vec { + let mut v = vec![0f32; n]; + let mut s: u64 = seed; + for x in v.iter_mut() { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + *x = (s >> 33) as f32 / (1u64 << 31) as f32 * 0.2 - 0.1; + } + v +} +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } + +fn bench_one(dev: &dyn Device, s: usize, n_warm: usize, n_iter: usize) -> (f64, f64) { + let (nq, nkv, hd) = (32usize, 2usize, 128usize); + let hpg = nq / nkv; + let n_kv = s; + let scale = 1.0f32 / (hd as f32).sqrt(); + let up = |v: &[f32]| Tensor::new(dev.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32); + let q = up(&rng(s * nq * hd, 1)).reshaped(vec![s, nq, hd]); + let k = up(&rng(nkv * n_kv * hd, 2)).reshaped(vec![nkv, n_kv, hd]); + let v = up(&rng(nkv * n_kv * hd, 3)).reshaped(vec![nkv, n_kv, hd]); + + // Scalar warm-up. + for _ in 0..n_warm { + sdpa_multi(dev, &q, &k, &v, hd, nq as u32, 0, s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + } + dev.synchronize().ok(); + let t0 = Instant::now(); + for _ in 0..n_iter { + sdpa_multi(dev, &q, &k, &v, hd, nq as u32, 0, s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + } + dev.synchronize().ok(); + let elapsed_scalar = t0.elapsed().as_secs_f64() / n_iter as f64; + + // TC warm-up. + for _ in 0..n_warm { + sdpa_multi_tc(dev, &q, &k, &v, hd, nq as u32, 0, s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + } + dev.synchronize().ok(); + let t1 = Instant::now(); + for _ in 0..n_iter { + sdpa_multi_tc(dev, &q, &k, &v, hd, nq as u32, 0, s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + } + dev.synchronize().ok(); + let elapsed_tc = t1.elapsed().as_secs_f64() / n_iter as f64; + + (elapsed_scalar, elapsed_tc) +} + +#[test] +fn sdpa_tc_bench() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA -- skip"); return; }; + let (nq, hd) = (32usize, 128usize); + let mut log = String::new(); + log.push_str("\n=== sdpa_multi vs sdpa_multi_tc (nq=32, nkv=2, hd=128, causal, base_kv=0) ===\n"); + + // S -> (n_warm, n_iter) + let sizes: &[(usize, usize, usize)] = &[ + (512, 3, 5), + (2048, 2, 3), + (4096, 1, 2), + (8192, 1, 2), + ]; + + for &(s, n_warm, n_iter) in sizes { + let (t_scalar, t_tc) = bench_one(dev.as_ref(), s, n_warm, n_iter); + let avg_kv = s as f64 / 2.0; + let flops = 4.0 * nq as f64 * hd as f64 * avg_kv * s as f64; + let tflops_scalar = flops / t_scalar / 1e12; + let tflops_tc = flops / t_tc / 1e12; + let speedup = t_scalar / t_tc; + let line = format!( + "S={s:5}: scalar {:.1}ms / {:.3} TFLOP/s | TC {:.1}ms / {:.3} TFLOP/s | speedup {:.2}x", + t_scalar * 1e3, tflops_scalar, + t_tc * 1e3, tflops_tc, + speedup, + ); + eprintln!("{line}"); + log.push_str(&line); + log.push('\n'); + } + + let _ = std::fs::write("/home/pidtom/prefill_overnight.log", &log); + eprintln!("{log}"); +} diff --git a/rust/crates/backends/ffai-cuda/tests/sdpa_tc_test.rs b/rust/crates/backends/ffai-cuda/tests/sdpa_tc_test.rs new file mode 100644 index 00000000..6b641c8c --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/sdpa_tc_test.rs @@ -0,0 +1,95 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Direct correctness test for `sdpa_multi_tc` vs `sdpa_multi`. +//! Validates the TC path against the scalar kernel on same Q/K/V inputs +//! at S=128, S=512, S=2048 (GQA: nq=32, nkv=2, hd=128, causal=true). +//! Threshold: max |rel_err| ≤ 1e-2 (1% — typical f16 vs f32 tolerance). + +use ffai_cuda::CudaDevice; +use ffai_core::{DType, Device, Tensor}; +use ffai_ops::{sdpa_multi, sdpa_multi_tc}; + +fn rng(n: usize, seed: u64) -> Vec { + let mut v = vec![0f32; n]; + let mut s: u64 = seed; + for x in v.iter_mut() { + s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let f = (s >> 33) as f32 / (1u64 << 31) as f32 - 1.0; // [-1, 1) + *x = f * 0.2; // small values to avoid f16 saturation + } + v +} + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|f| f.to_le_bytes()).collect() } +fn dl_f32(d: &dyn Device, t: &Tensor) -> Vec { + let n = t.elem_count(); + let mut b = vec![0u8; n * 4]; + d.download(t.buffer.as_ref(), &mut b).unwrap(); + b.chunks(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect() +} + +fn run_sdpa_tc_test(dev: &dyn Device, s: usize) { + let (nq, nkv, hd) = (32usize, 2usize, 128usize); + let hpg = nq / nkv; + let base_kv = 0usize; // no prefix + let n_kv = base_kv + s; + let scale = 1.0f32 / (hd as f32).sqrt(); + + let q_vals = rng(s * nq * hd, 1234); + let k_vals = rng(nkv * n_kv * hd, 5678); + let v_vals = rng(nkv * n_kv * hd, 9012); + + let up = |v: &[f32]| Tensor::new( + dev.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32); + let q = up(&q_vals).reshaped(vec![s, nq, hd]); + let k = up(&k_vals).reshaped(vec![nkv, n_kv, hd]); + let v = up(&v_vals).reshaped(vec![nkv, n_kv, hd]); + + // Scalar reference. + let ref_out = sdpa_multi(dev, &q, &k, &v, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + // TC path. + let tc_out = sdpa_multi_tc(dev, &q, &k, &v, hd, nq as u32, base_kv as u32, + s as u32, n_kv as u32, hpg as u32, true, scale).unwrap(); + + let ref_data = dl_f32(dev, &ref_out); + let tc_data = dl_f32(dev, &tc_out); + assert_eq!(ref_data.len(), tc_data.len()); + + let mut max_abs = 0f32; + let mut max_rel = 0f32; + for (r, t) in ref_data.iter().zip(tc_data.iter()) { + let abs = (r - t).abs(); + let rel = abs / (r.abs().max(1e-6)); + if abs > max_abs { max_abs = abs; } + if rel > max_rel { max_rel = rel; } + } + eprintln!("sdpa_multi_tc S={s}: max_abs={max_abs:.2e} max_rel={max_rel:.2e}"); + assert!(max_rel < 1e-2, + "sdpa_multi_tc S={s}: max_rel={max_rel:.2e} exceeds 1e-2 threshold (max_abs={max_abs:.2e})"); + eprintln!("sdpa_multi_tc S={s}: PASS ✓"); +} + +#[test] +fn sdpa_multi_tc_correctness_s128() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_sdpa_tc_test(dev.as_ref(), 128); +} + +#[test] +fn sdpa_multi_tc_correctness_s512() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_sdpa_tc_test(dev.as_ref(), 512); +} + +#[test] +fn sdpa_multi_tc_correctness_s2048() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA — skip"); return; }; + run_sdpa_tc_test(dev.as_ref(), 2048); +} + +#[test] +fn sdpa_multi_tc_correctness_s4096() { + let Some(dev) = CudaDevice::create().expect("cuda init") else { eprintln!("no CUDA --- skip"); return; }; + run_sdpa_tc_test(dev.as_ref(), 4096); +} diff --git a/rust/crates/backends/ffai-cuda/tests/siglip.rs b/rust/crates/backends/ffai-cuda/tests/siglip.rs new file mode 100644 index 00000000..935a4da4 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/siglip.rs @@ -0,0 +1,133 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full real VLM **vision tower** (SigLIP-base-patch16-224) forward on the +//! shared engine, verified vs HF transformers. This is the VLM family's new +//! half — vision encoder = conv/patch-embed (as a matmul) + bidirectional +//! transformer (LayerNorm + full self-attention + GELU-MLP). The LLM half is +//! already covered (dense Llama family); a VLM is vision-tower → projector → +//! LLM. Heavy projections run on the device `matmul`; attention on the device +//! `sdpa_decode` (full/bidirectional = attend over all n_kv patches, looped +//! per query); LayerNorm + GELU(tanh) on device. Bias/residual/head-reorg are +//! trivial host elementwise. Input is a deterministic synthetic pixel tensor +//! (same `sin(0.01·i)` formula as the HF reference). +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gelu, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn siglip_vision_tower_vs_hf() { + let dir = std::env::var("SIGLIP_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + + // config (SigLIP-base-patch16-224 vision) + let (hid, n_layers, nh, hd, inter, img, patch, eps) = + (768usize, 12usize, 12usize, 64usize, 3072usize, 224usize, 16usize, 1e-6f32); + let grid = img / patch; // 14 + let np = grid * grid; // 196 patches + let scale = 1.0 / (hd as f32).sqrt(); // 0.125 + + let g = |name: &str| -> Vec { let (b, dt, _s) = st.tensor(name).unwrap(); assert_eq!(dt, DType::F32, "{name}"); fb(b) }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + // add a per-column bias to a [rows, cols] host buffer (broadcast over rows) + let add_bias = |m: &mut [f32], bias: &[f32], rows: usize, cols: usize| { + for r in 0..rows { for c in 0..cols { m[r * cols + c] += bias[c]; } } + }; + + // ── patch embedding (conv2d stride=patch ≡ matmul over flattened patches) ── + let n_pix = 3 * img * img; + let pv: Vec = (0..n_pix).map(|i| (0.01 * i as f32).sin()).collect(); + // patch matrix [np, 3*patch*patch], inner order (c, kh, kw) to match conv weight flatten + let pdim = 3 * patch * patch; // 768 + let mut patches = vec![0.0f32; np * pdim]; + for gh in 0..grid { for gw in 0..grid { + let p = gh * grid + gw; + for c in 0..3 { for kh in 0..patch { for kw in 0..patch { + let h = gh * patch + kh; let w = gw * patch + kw; + patches[p * pdim + c * patch * patch + kh * patch + kw] = pv[c * img * img + h * img + w]; + }}} + }} + let cw = g("vision_model.embeddings.patch_embedding.weight"); // [768, 3,16,16] = [768, 768] + let emb = dl(&matmul(d, &up(&cw, vec![hid, pdim]), &up(&patches, vec![np, pdim])).unwrap(), np * hid); + let cb = g("vision_model.embeddings.patch_embedding.bias"); + let pos = g("vision_model.embeddings.position_embedding.weight"); // [196, 768] + let mut x = emb.clone(); + add_bias(&mut x, &cb, np, hid); + for i in 0..np * hid { x[i] += pos[i]; } + + // ── encoder ── + for l in 0..n_layers { + let p = format!("vision_model.encoder.layers.{l}"); + // self-attention + let ln1 = layer_norm(d, &up(&x, vec![np, hid]), + &up(&g(&format!("{p}.layer_norm1.weight")), vec![hid]), + &up(&g(&format!("{p}.layer_norm1.bias")), vec![hid]), eps).unwrap(); + let mut q = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![hid, hid]), &ln1).unwrap(), np * hid); + let mut k = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![hid, hid]), &ln1).unwrap(), np * hid); + let mut v = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![hid, hid]), &ln1).unwrap(), np * hid); + add_bias(&mut q, &g(&format!("{p}.self_attn.q_proj.bias")), np, hid); + add_bias(&mut k, &g(&format!("{p}.self_attn.k_proj.bias")), np, hid); + add_bias(&mut v, &g(&format!("{p}.self_attn.v_proj.bias")), np, hid); + // reorg to per-head KV cache [nh, np, hd] for sdpa_decode (kv_stride = np) + let mut kb = vec![0.0f32; nh * np * hd]; + let mut vb = vec![0.0f32; nh * np * hd]; + for t in 0..np { for h in 0..nh { for dd in 0..hd { + kb[h * np * hd + t * hd + dd] = k[t * hid + h * hd + dd]; + vb[h * np * hd + t * hd + dd] = v[t * hid + h * hd + dd]; + }}} + let kt = up(&kb, vec![nh, np, hd]); + let vt = up(&vb, vec![nh, np, hd]); + // full bidirectional attention: each query patch attends over all np patches + let mut attn = vec![0.0f32; np * hid]; + for t in 0..np { + let qt = up(&q[t * hid..(t + 1) * hid], vec![nh, hd]); + let a = sdpa_decode(d, &qt, &kt, &vt, hd, np as u32, np as u32, 1, scale).unwrap(); + let ad = dl(&a, hid); + attn[t * hid..(t + 1) * hid].copy_from_slice(&ad); + } + let mut o = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.out_proj.weight")), vec![hid, hid]), &up(&attn, vec![np, hid])).unwrap(), np * hid); + add_bias(&mut o, &g(&format!("{p}.self_attn.out_proj.bias")), np, hid); + for i in 0..np * hid { x[i] += o[i]; } // residual + + // GELU-MLP + let ln2 = layer_norm(d, &up(&x, vec![np, hid]), + &up(&g(&format!("{p}.layer_norm2.weight")), vec![hid]), + &up(&g(&format!("{p}.layer_norm2.bias")), vec![hid]), eps).unwrap(); + let mut h1 = dl(&matmul(d, &up(&g(&format!("{p}.mlp.fc1.weight")), vec![inter, hid]), &ln2).unwrap(), np * inter); + add_bias(&mut h1, &g(&format!("{p}.mlp.fc1.bias")), np, inter); + let act = gelu(d, &up(&h1, vec![np, inter])).unwrap(); + let mut h2 = dl(&matmul(d, &up(&g(&format!("{p}.mlp.fc2.weight")), vec![hid, inter]), &act).unwrap(), np * hid); + add_bias(&mut h2, &g(&format!("{p}.mlp.fc2.bias")), np, hid); + for i in 0..np * hid { x[i] += h2[i]; } // residual + } + + // ── post layernorm = last_hidden_state ── + let lhs = dl(&layer_norm(d, &up(&x, vec![np, hid]), + &up(&g("vision_model.post_layernorm.weight"), vec![hid]), + &up(&g("vision_model.post_layernorm.bias"), vec![hid]), eps).unwrap(), np * hid); + + // HF reference (deterministic sin(0.01·i) pixel input) + let want0 = [-0.05489f32, -0.43045, 0.37643, 0.09968, -1.22139]; + let want100 = [0.03598f32, 1.52808, 0.41827, -0.1857, -1.84941]; + let mut e = 0.0f32; + for i in 0..5 { e = e.max((lhs[i] - want0[i]).abs()); } + for i in 0..5 { e = e.max((lhs[100 * hid + i] - want100[i]).abs()); } + let sum: f32 = lhs.iter().sum(); + eprintln!("SigLIP vision tower on CUDA: LHS[0,:5]={:?}", &lhs[..5]); + eprintln!(" LHS[100,:5]={:?} sum={sum:.3} (HF sum=-792.795) max|Δ| first-rows={e:.3e}", &lhs[100 * hid..100 * hid + 5]); + assert!(e < 2e-2, "SigLIP last_hidden_state mismatch vs HF: max|Δ|={e:.3e}"); + assert!((sum + 792.795).abs() < 5.0, "SigLIP LHS sum off: {sum}"); + eprintln!("✅ Full real SigLIP vision tower matches HF on the shared engine (GB10 sm_121) — VLM vision half verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--google--siglip-base-patch16-224/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-cuda/tests/slice_f16.rs b/rust/crates/backends/ffai-cuda/tests/slice_f16.rs new file mode 100644 index 00000000..9a522f88 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/slice_f16.rs @@ -0,0 +1,42 @@ +#![cfg(feature = "cuda")] +//! Isolate slice() on f16 with a large element offset (the lm_head last-token slice). +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_ops::{slice, cast_f32_f16, cast_f16_f32}; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb32(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn tn(d:&dyn Device,v:&[f32])->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),vec![v.len()],DType::F32)} + +#[test] +fn slice_f16_large_offset(){ + let Some(dev)=CudaDevice::create().expect("cuda") else { return; }; + let d=dev.as_ref(); + let (s,hid)=(2048usize,4096usize); + let full:Vec=(0..s*hid).map(|i| ((i%101) as f32*0.013-0.5)).collect(); + let f32t=tn(d,&full); + let f16t=cast_f32_f16(d,&f32t).unwrap(); + d.synchronize().unwrap(); + let off=(s-1)*hid; + // slice on f32 (baseline path) + let sl32=slice(d,&f32t,off,hid).unwrap(); + // slice on f16 then cast (fused path) + let sl16=slice(d,&f16t,off,hid).unwrap(); + let sl16_f32=cast_f16_f32(d,&sl16).unwrap(); + d.synchronize().unwrap(); + // reference path: cast full f16->f32 FIRST then slice (the working fix) + let f16_full_f32=cast_f16_f32(d,&f16t).unwrap(); + let sl_ref=slice(d,&f16_full_f32,off,hid).unwrap(); + d.synchronize().unwrap(); + let mut a=vec![0u8;hid*4]; d.download(sl32.buffer.as_ref(),&mut a).unwrap(); let g32=fb32(&a); + let mut b=vec![0u8;hid*4]; d.download(sl16_f32.buffer.as_ref(),&mut b).unwrap(); let g16=fb32(&b); + let mut c=vec![0u8;hid*4]; d.download(sl_ref.buffer.as_ref(),&mut c).unwrap(); let gr=fb32(&c); + let mut e_sl16=0f32; for i in 0..hid { e_sl16=e_sl16.max((g16[i]-full[off+i]).abs()); } + let mut e_ref=0f32; for i in 0..hid { e_ref=e_ref.max((gr[i]-full[off+i]).abs()); } + let mut e_32=0f32; for i in 0..hid { e_32=e_32.max((g32[i]-full[off+i]).abs()); } + eprintln!("slice@off={off} hid={hid}: f32|Δ|={e_32:.3e} slice-f16-then-cast|Δ|={e_sl16:.3e} cast-then-slice|Δ|={e_ref:.3e}"); + eprintln!(" f32 [0..4]={:?}", &g32[0..4]); + eprintln!(" sl16 [0..4]={:?}", &g16[0..4]); + eprintln!(" ref [0..4]={:?}", &gr[0..4]); + eprintln!(" truth [0..4]={:?}", &full[off..off+4]); +} diff --git a/rust/crates/backends/ffai-cuda/tests/smolvlm.rs b/rust/crates/backends/ffai-cuda/tests/smolvlm.rs new file mode 100644 index 00000000..84f46c5d --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/smolvlm.rs @@ -0,0 +1,73 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! SmolVLM (Idefics3) vision→text **connector** verified vs HF. This is the one +//! VLM-stitch component not already covered: the SigLIP vision tower and the +//! dense-Llama text model are each verified separately, so the connector — +//! pixel-shuffle (gather a `scale_factor`×`scale_factor` block of patches into +//! one token's channels) + a `modality_projection` linear into the text hidden +//! dim — is the remaining new piece. Pixel-shuffle is exact index math; the +//! projection runs on the verified `matmul`. A full SmolVLM forward then = +//! [verified SigLIP tower] → [this connector] → splice into text → [verified +//! causal Llama], all on the shared op layer. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::matmul; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn smolvlm_connector_vs_hf() { + let dir = std::env::var("SMOLVLM_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + + let (vdim, sf, txt) = (768usize, 4usize, 576usize); // vision hid, scale_factor, text hid + let np = 1024usize; let grid = 32usize; // 32×32 patches + let og = grid / sf; // 8 → 64 output tokens + let n_tok = og * og; // 64 + let cin = vdim * sf * sf; // 12288 + + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + + // deterministic vision-tower output [1024, 768] + let x: Vec = (0..np * vdim).map(|i| (0.01 * i as f32).sin()).collect(); + + // pixel-shuffle: out[tok=h4*og+w2][e4], e4 → h_sub=e4/(vdim*sf), w_sub=(e4 % (vdim*sf))/vdim, e=e4%vdim + // source patch (h=h4*sf+h_sub, w=w2*sf+w_sub): x[(h*grid + w)*vdim + e] + let mut shuf = vec![0.0f32; n_tok * cin]; + for h4 in 0..og { for w2 in 0..og { + let tok = h4 * og + w2; + for e4 in 0..cin { + let h_sub = e4 / (vdim * sf); + let w_sub = (e4 % (vdim * sf)) / vdim; + let e = e4 % vdim; + let h = h4 * sf + h_sub; let w = w2 * sf + w_sub; + shuf[tok * cin + e4] = x[(h * grid + w) * vdim + e]; + } + }} + + // modality_projection (Linear, no bias): [txt, cin] @ shuf[n_tok, cin] → [n_tok, txt] + let proj = st.tensor_f32("model.connector.modality_projection.proj.weight").unwrap().0; + let out = dl(&matmul(d, &up(&proj, vec![txt, cin]), &up(&shuf, vec![n_tok, cin])).unwrap(), n_tok * txt); + + let want0 = [1.56379f32, 0.48131, -0.04509, -1.15257, 1.72148]; + let want63 = [-0.73231f32, -0.52461, 1.12685, 1.35081, -2.54256]; + let mut e = 0.0f32; + for i in 0..5 { e = e.max((out[i] - want0[i]).abs()); } + for i in 0..5 { e = e.max((out[63 * txt + i] - want63[i]).abs()); } + let sum: f32 = out.iter().sum(); + eprintln!("SmolVLM connector on CUDA: out[0,:5]={:?}", &out[..5]); + eprintln!(" out[63,:5]={:?} sum={sum:.3} (HF=79.852) max|Δ|={e:.3e}", &out[63 * txt..63 * txt + 5]); + assert!(e < 2e-3, "SmolVLM connector mismatch vs HF: {e:.3e}"); + assert!((sum - 79.852).abs() < 0.5, "connector sum off: {sum}"); + eprintln!("✅ SmolVLM connector (pixel-shuffle + modality projection) matches HF on the shared engine (GB10 sm_121) — VLM stitch component verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--HuggingFaceTB--SmolVLM-256M-Instruct/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-cuda/tests/ssd_scan_test.rs b/rust/crates/backends/ffai-cuda/tests/ssd_scan_test.rs new file mode 100644 index 00000000..6468b6ec --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/ssd_scan_test.rs @@ -0,0 +1,223 @@ +#![cfg(feature = "cuda")] +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Mamba2 SSD chunked-MATMUL prefill scan (`ssm_prefill_scan_ssd`) on CUDA vs +//! the sequential `ssm_prefill_scan` reference, for the NemotronH cell +//! (dh=64, ds=128, H=64, G=8). Gate: max|Δy| < 1e-3 (fp16 GEMM path). +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_ops::{ssm_prefill_scan, ssm_prefill_scan_ssd}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +// small, smooth pseudo-random fill in a sane range (keeps decay finite). +fn fill(n: usize, s: usize, scale: f32) -> Vec { + (0..n).map(|i| (((i * 31 + s * 977) % 251) as f32 / 251.0 - 0.5) * 2.0 * scale).collect() +} +fn tn(d: &dyn Device, v: &[f32], sh: Vec) -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) +} + +fn run_for_len(dev: &dyn Device, t: usize, l: u32) { + let (h, dh, ds, ng) = (64usize, 64usize, 128usize, 8usize); + // Use REALISTIC Mamba2 magnitudes to flush out precision/structural bugs the + // benign synthetic ranges hide: bigger x/B/C, a wide A spread, and dt up to + // ~1.5 (post-softplus) so decay spans a large dynamic range. + let x = fill(t * h * dh, 1, 4.0); + // a_log spread so A=-exp(a_log) ranges ~[-0.05, -30] across heads. + let a_log: Vec = (0..h).map(|i| -3.0 + 6.0 * (i as f32 / h as f32)).collect(); + let b = fill(t * ng * ds, 3, 3.0); + let c = fill(t * ng * ds, 4, 3.0); + let dsk = fill(h, 5, 1.0); + let dt: Vec = (0..t * h).map(|i| 0.05 + 1.4 * (((i * 13) % 11) as f32 / 11.0)).collect(); + let si = vec![0.0f32; h * dh * ds]; // prefill starts at zero state + + let xt = tn(dev, &x, vec![t * h * dh]); + let at = tn(dev, &a_log, vec![h]); + let bt = tn(dev, &b, vec![t * ng * ds]); + let ct = tn(dev, &c, vec![t * ng * ds]); + let dt_t = tn(dev, &dsk, vec![h]); + let dtt = tn(dev, &dt, vec![t * h]); + let sit = tn(dev, &si, vec![h * dh * ds]); + + // Sequential reference (on device). + let (so_seq, y_seq) = ssm_prefill_scan( + dev, &xt, &at, &bt, &ct, &dt_t, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32, + ).unwrap(); + // SSD matmul path (on device). + let (so_ssd, y_ssd) = ssm_prefill_scan_ssd( + dev, &xt, &at, &bt, &ct, &dt_t, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32, l, None, + ).unwrap(); + dev.synchronize().unwrap(); + + let mut yb = vec![0u8; t * h * dh * 4]; + dev.download(y_seq.buffer.as_ref(), &mut yb).unwrap(); + let y_ref = fb(&yb); + dev.download(y_ssd.buffer.as_ref(), &mut yb).unwrap(); + let y_got = fb(&yb); + let mut sb = vec![0u8; h * dh * ds * 4]; + dev.download(so_seq.buffer.as_ref(), &mut sb).unwrap(); + let s_ref = fb(&sb); + dev.download(so_ssd.buffer.as_ref(), &mut sb).unwrap(); + let s_got = fb(&sb); + + let mut ey = 0.0f32; + let mut yref_mag = 0.0f32; + for i in 0..y_ref.len() { + ey = ey.max((y_got[i] - y_ref[i]).abs()); + yref_mag = yref_mag.max(y_ref[i].abs()); + } + let mut es = 0.0f32; + let mut sref_mag = 0.0f32; + for i in 0..s_ref.len() { + es = es.max((s_got[i] - s_ref[i]).abs()); + sref_mag = sref_mag.max(s_ref[i].abs()); + } + // cosine similarity on y (robust to magnitude). + let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64); + for i in 0..y_ref.len() { + dot += y_ref[i] as f64 * y_got[i] as f64; + na += (y_ref[i] as f64).powi(2); + nb += (y_got[i] as f64).powi(2); + } + let cos = dot / (na.sqrt() * nb.sqrt()).max(1e-12); + let rel = ey / yref_mag.max(1e-6); + let rel_s = es / sref_mag.max(1e-6); + eprintln!( + "SSD vs sequential T={t} L={l}: rel|Δy|={rel:.3e} cos={cos:.6} rel|Δstate|={rel_s:.3e} (|y|max={yref_mag:.3e})" + ); + // fp16 GEMM (f32 accumulate) → ~1e-3 relative is the floor; gate on relative + cosine. + assert!(rel < 3e-3, "y relative mismatch too large at T={t} L={l}: rel={rel:.3e} (abs={ey:.3e})"); + assert!(cos > 0.9999, "y cosine too low at T={t} L={l}: {cos:.6}"); + assert!(rel_s < 3e-3, "state relative mismatch too large at T={t} L={l}: rel={rel_s:.3e}"); +} + +#[test] +fn ssd_matmul_matches_sequential() { + let Some(dev) = CudaDevice::create().expect("cuda") else { + eprintln!("no CUDA — skip"); + return; + }; + // exact multiples of L, and a non-multiple (tail-padded chunk). + run_for_len(dev.as_ref(), 256, 128); + run_for_len(dev.as_ref(), 512, 128); + run_for_len(dev.as_ref(), 512, 256); + run_for_len(dev.as_ref(), 300, 128); // 300 = 2*128 + 44 → tail chunk + eprintln!("✅ SSD chunked-matmul scan matches sequential scan to <1e-3 (fp16)."); +} + +// ════════════════════════════════════════════════════════════════════════════ +// FAST SSD-lever A/B harness (FUSED off-vs-on, no model load). +// +// The fused SSD scan (`ssm_prefill_scan_ssd`, NEMOTRON_SSD_FUSED default-on, +// escape NEMOTRON_SSD_FUSED_OFF=1) is HARD-GATED to the Nemotron Mamba2 cell +// (dh=64, ds=128, n_heads=64, n_groups=8) — see the shape guard at the top of +// `ssm_prefill_scan_ssd_strided`. So the right fast proxy for THIS lever is a +// synthetic problem AT THAT EXACT CELL, sized at a prefill-realistic S. It +// loads nothing → runs in milliseconds, and the per-path GPU time gives the +// fused kernel's RELATIVE speedup (the absolute differs from the 62GB 30B run +// because the 30B interleaves 23 SSD layers with MoE/attn, but the SSD-kernel +// speedup direction + magnitude transfer 1:1 since it is the SAME kernel on the +// SAME cell). Correctness gate = the same rel|Δ| < 3e-3 band-safe bound. +// +// This is the FAST TIER for the SSD lever. Final e2e tok/s + 16/16 quality +// still run on the 30B (`--test nemotron nemotron_decode_bench`). +// +// Invocation: +// cargo test --release -p ffai-cuda --features cuda \ +// --test ssd_scan ssd_lever_ab_bench -- --exact --nocapture +// env: SSD_BENCH_S="512,1024,2048" (default), SSD_BENCH_L=128, SSD_BENCH_ITERS=20 +// ════════════════════════════════════════════════════════════════════════════ +fn time_path (Tensor, Tensor)>(dev: &dyn Device, iters: usize, f: F) -> f64 { + // warmup (kernel compile / cache) then time `iters` device-synced runs. + let _ = f(); + dev.synchronize().unwrap(); + let t0 = std::time::Instant::now(); + for _ in 0..iters { + let _r = f(); + } + dev.synchronize().unwrap(); + t0.elapsed().as_secs_f64() * 1e3 / iters as f64 // ms/run +} + +fn ab_for_len(dev: &dyn Device, t: usize, l: u32, iters: usize) { + let (h, dh, ds, ng) = (64usize, 64usize, 128usize, 8usize); // Nemotron cell (hard-gated) + let x = fill(t * h * dh, 1, 4.0); + let a_log: Vec = (0..h).map(|i| -3.0 + 6.0 * (i as f32 / h as f32)).collect(); + let b = fill(t * ng * ds, 3, 3.0); + let c = fill(t * ng * ds, 4, 3.0); + let dsk = fill(h, 5, 1.0); + let dt: Vec = (0..t * h).map(|i| 0.05 + 1.4 * (((i * 13) % 11) as f32 / 11.0)).collect(); + let si = vec![0.0f32; h * dh * ds]; + + let xt = tn(dev, &x, vec![t * h * dh]); + let at = tn(dev, &a_log, vec![h]); + let bt = tn(dev, &b, vec![t * ng * ds]); + let ct = tn(dev, &c, vec![t * ng * ds]); + let dt_t = tn(dev, &dsk, vec![h]); + let dtt = tn(dev, &dt, vec![t * h]); + let sit = tn(dev, &si, vec![h * dh * ds]); + + // OFF = sequential reference scan; ON = fused chunked-matmul SSD scan. + let off = || { + ssm_prefill_scan( + dev, &xt, &at, &bt, &ct, &dt_t, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32, + ).unwrap() + }; + let on = || { + ssm_prefill_scan_ssd( + dev, &xt, &at, &bt, &ct, &dt_t, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32, l, None, + ).unwrap() + }; + + // correctness (rel|Δ| on y) — fused must match sequential within the band. + let (_, y_off) = off(); + let (_, y_on) = on(); + dev.synchronize().unwrap(); + let mut yb = vec![0u8; t * h * dh * 4]; + dev.download(y_off.buffer.as_ref(), &mut yb).unwrap(); + let yr = fb(&yb); + dev.download(y_on.buffer.as_ref(), &mut yb).unwrap(); + let yg = fb(&yb); + let (mut e, mut mag) = (0.0f32, 0.0f32); + for i in 0..yr.len() { + e = e.max((yg[i] - yr[i]).abs()); + mag = mag.max(yr[i].abs()); + } + let rel = e / mag.max(1e-6); + + let off_ms = time_path(dev, iters, off); + let on_ms = time_path(dev, iters, on); + let speedup = off_ms / on_ms.max(1e-9); + eprintln!( + " S={t:<5} L={l:<4} | OFF(seq)={off_ms:8.3} ms ON(fused)={on_ms:8.3} ms speedup={speedup:5.2}x | rel|Δy|={rel:.2e} {}", + if rel < 3e-3 { "OK" } else { "FAIL" } + ); + assert!(rel < 3e-3, "SSD fused mismatch at S={t} L={l}: rel={rel:.2e}"); +} + +#[test] +fn ssd_lever_ab_bench() { + let Some(dev) = CudaDevice::create().expect("cuda") else { + eprintln!("no CUDA — skip"); + return; + }; + let s_list: Vec = std::env::var("SSD_BENCH_S") + .unwrap_or_else(|_| "512,1024,2048".into()) + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + let l: u32 = std::env::var("SSD_BENCH_L").ok().and_then(|v| v.parse().ok()).unwrap_or(128); + let iters: usize = std::env::var("SSD_BENCH_ITERS").ok().and_then(|v| v.parse().ok()).unwrap_or(20); + eprintln!("──────── SSD lever A/B (Nemotron cell 64/128/64/8, synthetic, no model) ────────"); + eprintln!(" OFF = ssm_prefill_scan (sequential) ON = ssm_prefill_scan_ssd (fused chunked-matmul)"); + for &s in &s_list { + ab_for_len(dev.as_ref(), s, l, iters); + } + eprintln!("────────────────────────────────────────────────────────────────────────────────"); + eprintln!("✅ SSD fused matches sequential (<3e-3); speedup is the relative kernel win."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/ssm_test.rs b/rust/crates/backends/ffai-cuda/tests/ssm_test.rs new file mode 100644 index 00000000..362c0ea8 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/ssm_test.rs @@ -0,0 +1,49 @@ +#![cfg(feature = "cuda")] +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Mamba2 SSD selective-scan decode step (mt_ssm_step) on CUDA vs CPU — +//! the core SSM-family op (Mamba2/Jamba/FalconH1/LFM2). +use ffai_core::{DType, Device, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_ops::ssm_step; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn fill(n:usize,s:usize)->Vec{(0..n).map(|i|(((i*7+s*131)%89) as f32-44.0)*0.02).collect()} +fn tn(d:&dyn Device,v:&[f32],sh:Vec)->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),sh,DType::F32)} + +#[test] +fn mt_ssm_step_on_cuda_matches_cpu(){ + let Some(dev)=CudaDevice::create().expect("metal") else { eprintln!("no CUDA — skip"); return; }; + let (nh, dh, ds, hpg) = (4usize, 8usize, 32usize, 2usize); + let ng = nh/hpg; + let x=fill(nh*dh,1); let a_log=fill(nh,2); let b=fill(ng*ds,3); let c=fill(ng*ds,4); let dsk=fill(nh,5); let dt:Vec=(0..nh).map(|i|0.3+0.1*i as f32).collect(); let si=fill(nh*dh*ds,7); + + let (so_t, out_t) = ssm_step(dev.as_ref(), + &tn(dev.as_ref(),&x,vec![nh*dh]), &tn(dev.as_ref(),&a_log,vec![nh]), + &tn(dev.as_ref(),&b,vec![ng*ds]), &tn(dev.as_ref(),&c,vec![ng*ds]), + &tn(dev.as_ref(),&dsk,vec![nh]), &tn(dev.as_ref(),&dt,vec![nh]), + &tn(dev.as_ref(),&si,vec![nh*dh*ds]), dh as u32, ds as u32, nh as u32, hpg as u32).unwrap(); + dev.synchronize().unwrap(); + let mut sb=vec![0u8;nh*dh*ds*4]; dev.download(so_t.buffer.as_ref(),&mut sb).unwrap(); let so=fb(&sb); + let mut ob=vec![0u8;nh*dh*4]; dev.download(out_t.buffer.as_ref(),&mut ob).unwrap(); let out=fb(&ob); + + // CPU ref + let mut so_ref=vec![0.0f32;nh*dh*ds]; let mut out_ref=vec![0.0f32;nh*dh]; + for n in 0..nh { + let g=n/hpg; let da=(-(a_log[n].exp())*dt[n]).exp(); + for d in 0..dh { + let xv=x[n*dh+d]; let mut acc=0.0f32; + for s in 0..ds { + let ns=da*si[n*dh*ds+d*ds+s]+xv*dt[n]*b[g*ds+s]; + so_ref[n*dh*ds+d*ds+s]=ns; acc+=ns*c[g*ds+s]; + } + out_ref[n*dh+d]=acc+xv*dsk[n]; + } + } + let mut es=0.0f32; for i in 0..nh*dh*ds { es=es.max((so[i]-so_ref[i]).abs()); } + let mut eo=0.0f32; for i in 0..nh*dh { eo=eo.max((out[i]-out_ref[i]).abs()); } + eprintln!("mt_ssm_step on CUDA vs CPU: state max|Δ|={es:.3e} out max|Δ|={eo:.3e}"); + assert!(es<=1e-4 && eo<=1e-4, "ssm mismatch state={es:.3e} out={eo:.3e}"); + eprintln!("✅ Mamba2 SSD selective-scan step runs on CUDA through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/vit_ops.rs b/rust/crates/backends/ffai-cuda/tests/vit_ops.rs new file mode 100644 index 00000000..ee8a5d6c --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/vit_ops.rs @@ -0,0 +1,64 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! LayerNorm + GELU(tanh) ops vs CPU reference — the two new primitives the +//! VLM vision tower (ViT / SigLIP / CLIP) needs beyond the LLM op set. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_ops::{gelu, layer_norm, matmul}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn gelu_tanh(x: f32) -> f32 { 0.5 * x * (1.0 + (0.7978845608 * (x + 0.044715 * x * x * x)).tanh()) } + +#[test] +fn layernorm_and_gelu_vs_cpu() { + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + let up = |v: &[f32], sh: Vec| Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32); + let dl = |t: &Tensor, n: usize| { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + + // LayerNorm over n=768 (SigLIP-base width), 2 rows. + let (rows, n, eps) = (2usize, 768usize, 1e-6f32); + let x: Vec = (0..rows * n).map(|i| ((i * 37 % 101) as f32 - 50.0) * 0.03).collect(); + let w: Vec = (0..n).map(|i| 1.0 + ((i % 11) as f32 - 5.0) * 0.02).collect(); + let b: Vec = (0..n).map(|i| ((i % 7) as f32 - 3.0) * 0.05).collect(); + let got = dl(&layer_norm(d, &up(&x, vec![rows, n]), &up(&w, vec![n]), &up(&b, vec![n]), eps).unwrap(), rows * n); + let mut e = 0.0f32; + for r in 0..rows { + let row = &x[r * n..(r + 1) * n]; + let mean = row.iter().sum::() / n as f32; + let var = row.iter().map(|v| (v - mean) * (v - mean)).sum::() / n as f32; + let is = 1.0 / (var + eps).sqrt(); + for i in 0..n { + let want = (row[i] - mean) * is * w[i] + b[i]; + e = e.max((got[r * n + i] - want).abs()); + } + } + eprintln!("LayerNorm max|Δ| = {e:.2e}"); + assert!(e < 1e-4, "layer_norm mismatch {e}"); + + // GELU(tanh) + let xv: Vec = (0..512).map(|i| (i as f32 - 256.0) * 0.05).collect(); + let g = dl(&gelu(d, &up(&xv, vec![xv.len()])).unwrap(), xv.len()); + let mut eg = 0.0f32; + for i in 0..xv.len() { eg = eg.max((g[i] - gelu_tanh(xv[i])).abs()); } + eprintln!("GELU(tanh) max|Δ| = {eg:.2e}"); + assert!(eg < 1e-4, "gelu mismatch {eg}"); + + // matmul (prefill linear): out[r,:] = weight · input[r,:], weight[out,in], input[rows,in] + let (rows2, in_d, out_d) = (196usize, 768usize, 1024usize); // rows not mult of 32 (edge) + let wt: Vec = (0..out_d * in_d).map(|i| ((i * 13 % 97) as f32 - 48.0) * 0.01).collect(); + let inp: Vec = (0..rows2 * in_d).map(|i| ((i * 7 % 89) as f32 - 44.0) * 0.01).collect(); + let mm = dl(&matmul(d, &up(&wt, vec![out_d, in_d]), &up(&inp, vec![rows2, in_d])).unwrap(), rows2 * out_d); + let mut em = 0.0f32; + for r in [0usize, 95, 195] { + for o in [0usize, 500, 1023] { + let mut acc = 0.0f32; + for kk in 0..in_d { acc += wt[o * in_d + kk] * inp[r * in_d + kk]; } + em = em.max((mm[r * out_d + o] - acc).abs()); + } + } + eprintln!("matmul max|Δ| = {em:.2e}"); + assert!(em < 2e-3, "matmul mismatch {em}"); + eprintln!("✅ LayerNorm + GELU(tanh) + matmul match CPU on GB10 sm_121 — ViT primitives ready."); +} diff --git a/rust/crates/backends/ffai-cuda/tests/whisper.rs b/rust/crates/backends/ffai-cuda/tests/whisper.rs new file mode 100644 index 00000000..a100977f --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/whisper.rs @@ -0,0 +1,144 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full real **audio encoder** (Whisper-tiny) forward on the shared engine, +//! verified vs HF transformers. The audio family's encoder = a conv front-end +//! (two Conv1d, done as im2col + `matmul`) + sinusoidal position embed + a +//! bidirectional transformer (LayerNorm + full self-attention + GELU-MLP) — +//! the same shared op set as the VLM tower. Whisper uses exact-erf GELU +//! (`activation_function="gelu"`), applied host-side here (the device +//! GELU-tanh op is verified separately for the VLM tower). Heavy projections +//! on the device `matmul`, attention on `sdpa_decode` (full/bidirectional, +//! looped per query), LayerNorm on device. Deterministic synthetic mel input. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +// erf via Abramowitz-Stegun 7.1.26 (~1.5e-7), then exact GELU = 0.5x(1+erf(x/√2)). +fn erf(x: f32) -> f32 { + let s = x.signum(); let x = x.abs(); + let t = 1.0 / (1.0 + 0.3275911 * x); + let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * (-x * x).exp(); + s * y +} +fn gelu_erf(x: f32) -> f32 { 0.5 * x * (1.0 + erf(x * std::f32::consts::FRAC_1_SQRT_2)) } + +#[test] +fn whisper_tiny_encoder_vs_hf() { + let dir = std::env::var("WHISPER_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + + // config (whisper-tiny) + let (mel, t_in, dm, n_layers, nh, hd, ffn, eps) = + (80usize, 3000usize, 384usize, 4usize, 6usize, 64usize, 1536usize, 1e-5f32); + let t_out = t_in / 2; // 1500 after conv2 stride-2 + let scale = 1.0 / (hd as f32).sqrt(); + + let g = |name: &str| -> Vec { let (b, dt, _s) = st.tensor(name).unwrap(); assert_eq!(dt, DType::F32, "{name}"); fb(b) }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], bias: &[f32], rows: usize, cols: usize| { + for r in 0..rows { for c in 0..cols { m[r * cols + c] += bias[c]; } } + }; + + // mel input [mel, t_in], deterministic + let feat: Vec = (0..mel * t_in).map(|i| (0.01 * i as f32).sin()).collect(); + + // ── conv1: Conv1d(80,384,k=3,pad=1,stride=1) → [t_in, 384] (im2col + matmul) ── + let k = 3usize; + let mut col1 = vec![0.0f32; t_in * (mel * k)]; + for t in 0..t_in { for c in 0..mel { for kk in 0..k { + let pos = t as isize - 1 + kk as isize; // pad=1 + if pos >= 0 && (pos as usize) < t_in { col1[t * (mel * k) + c * k + kk] = feat[c * t_in + pos as usize]; } + }}} + let w1 = g("model.encoder.conv1.weight"); // [384,80,3] = [384, 240] + let mut c1 = dl(&matmul(d, &up(&w1, vec![dm, mel * k]), &up(&col1, vec![t_in, mel * k])).unwrap(), t_in * dm); + add_bias(&mut c1, &g("model.encoder.conv1.bias"), t_in, dm); + for v in c1.iter_mut() { *v = gelu_erf(*v); } // c1: [t_in, 384], access [t][c] + + // ── conv2: Conv1d(384,384,k=3,pad=1,stride=2) → [t_out, 384] ── + let mut col2 = vec![0.0f32; t_out * (dm * k)]; + for j in 0..t_out { for c in 0..dm { for kk in 0..k { + let pos = 2 * j as isize - 1 + kk as isize; // stride 2, pad 1 + if pos >= 0 && (pos as usize) < t_in { col2[j * (dm * k) + c * k + kk] = c1[pos as usize * dm + c]; } + }}} + let w2 = g("model.encoder.conv2.weight"); // [384,384,3] = [384, 1152] + let mut c2 = dl(&matmul(d, &up(&w2, vec![dm, dm * k]), &up(&col2, vec![t_out, dm * k])).unwrap(), t_out * dm); + add_bias(&mut c2, &g("model.encoder.conv2.bias"), t_out, dm); + for v in c2.iter_mut() { *v = gelu_erf(*v); } + + // + sinusoidal position embedding + let pos = g("model.encoder.embed_positions.weight"); // [1500,384] + let mut x = c2; + for i in 0..t_out * dm { x[i] += pos[i]; } + + // ── encoder layers (pre-norm) ── + let np = t_out; + for l in 0..n_layers { + let p = format!("model.encoder.layers.{l}"); + // self-attention (k_proj has NO bias in Whisper) + let ln1 = layer_norm(d, &up(&x, vec![np, dm]), + &up(&g(&format!("{p}.self_attn_layer_norm.weight")), vec![dm]), + &up(&g(&format!("{p}.self_attn_layer_norm.bias")), vec![dm]), eps).unwrap(); + let mut q = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![dm, dm]), &ln1).unwrap(), np * dm); + let k_h = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![dm, dm]), &ln1).unwrap(), np * dm); + let mut v = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![dm, dm]), &ln1).unwrap(), np * dm); + add_bias(&mut q, &g(&format!("{p}.self_attn.q_proj.bias")), np, dm); + add_bias(&mut v, &g(&format!("{p}.self_attn.v_proj.bias")), np, dm); + let mut kb = vec![0.0f32; nh * np * hd]; + let mut vb = vec![0.0f32; nh * np * hd]; + for t in 0..np { for h in 0..nh { for dd in 0..hd { + kb[h * np * hd + t * hd + dd] = k_h[t * dm + h * hd + dd]; + vb[h * np * hd + t * hd + dd] = v[t * dm + h * hd + dd]; + }}} + let kt = up(&kb, vec![nh, np, hd]); + let vt = up(&vb, vec![nh, np, hd]); + let mut attn = vec![0.0f32; np * dm]; + for t in 0..np { + let qt = up(&q[t * dm..(t + 1) * dm], vec![nh, hd]); + let a = sdpa_decode(d, &qt, &kt, &vt, hd, np as u32, np as u32, 1, scale).unwrap(); + attn[t * dm..(t + 1) * dm].copy_from_slice(&dl(&a, dm)); + } + let mut o = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.out_proj.weight")), vec![dm, dm]), &up(&attn, vec![np, dm])).unwrap(), np * dm); + add_bias(&mut o, &g(&format!("{p}.self_attn.out_proj.bias")), np, dm); + for i in 0..np * dm { x[i] += o[i]; } + + // GELU-MLP (exact-erf) + let ln2 = layer_norm(d, &up(&x, vec![np, dm]), + &up(&g(&format!("{p}.final_layer_norm.weight")), vec![dm]), + &up(&g(&format!("{p}.final_layer_norm.bias")), vec![dm]), eps).unwrap(); + let mut h1 = dl(&matmul(d, &up(&g(&format!("{p}.fc1.weight")), vec![ffn, dm]), &ln2).unwrap(), np * ffn); + add_bias(&mut h1, &g(&format!("{p}.fc1.bias")), np, ffn); + for vv in h1.iter_mut() { *vv = gelu_erf(*vv); } + let mut h2 = dl(&matmul(d, &up(&g(&format!("{p}.fc2.weight")), vec![dm, ffn]), &up(&h1, vec![np, ffn])).unwrap(), np * dm); + add_bias(&mut h2, &g(&format!("{p}.fc2.bias")), np, dm); + for i in 0..np * dm { x[i] += h2[i]; } + } + + // final encoder layer_norm = last_hidden_state + let lhs = dl(&layer_norm(d, &up(&x, vec![np, dm]), + &up(&g("model.encoder.layer_norm.weight"), vec![dm]), + &up(&g("model.encoder.layer_norm.bias"), vec![dm]), eps).unwrap(), np * dm); + + let want0 = [0.07884f32, 0.0372, 0.39875, 0.4143, -1.18014]; + let want750 = [1.36551f32, -1.5299, 1.39363, 1.85176, -1.30956]; + let mut e = 0.0f32; + for i in 0..5 { e = e.max((lhs[i] - want0[i]).abs()); } + for i in 0..5 { e = e.max((lhs[750 * dm + i] - want750[i]).abs()); } + let sum: f32 = lhs.iter().sum(); + eprintln!("Whisper-tiny encoder on CUDA: ENC[0,:5]={:?}", &lhs[..5]); + eprintln!(" ENC[750,:5]={:?} sum={sum:.2} (HF=12390.46) max|Δ| first-rows={e:.3e}", &lhs[750 * dm..750 * dm + 5]); + assert!(e < 3e-2, "Whisper encoder mismatch vs HF: max|Δ|={e:.3e}"); + assert!((sum - 12390.46).abs() < 50.0, "Whisper encoder sum off: {sum}"); + eprintln!("✅ Full real Whisper-tiny audio encoder matches HF on the shared engine (GB10 sm_121) — audio half verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--openai--whisper-tiny/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-cuda/tests/whisper_base.rs b/rust/crates/backends/ffai-cuda/tests/whisper_base.rs new file mode 100644 index 00000000..28e85a00 --- /dev/null +++ b/rust/crates/backends/ffai-cuda/tests/whisper_base.rs @@ -0,0 +1,154 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full real Whisper-base **encoder→decoder** STT forward (first decoder step), +//! verified vs HF. Adds the **cross-attention** mechanism: the decoder's query +//! attends over the encoder's 1500 output states (K/V from the encoder, per +//! decoder layer) — the same pattern every encoder-decoder and many VLMs use. +//! Encoder is the same conv-front-end + bidirectional transformer verified for +//! whisper-tiny (here d512/6L/8H). Decoder layer = causal self-attn (n_kv=1 at +//! pos 0) + cross-attn (n_kv=1500) + exact-erf GELU MLP, pre-norm, tied lm_head. +//! All on the shared op layer; exact-erf GELU host-side. +use ffai_core::{DType, Tensor}; +use ffai_cuda::CudaDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{add, gemv, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn erf(x: f32) -> f32 { + let s = x.signum(); let x = x.abs(); + let t = 1.0 / (1.0 + 0.3275911 * x); + let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * (-x * x).exp(); + s * y +} +fn gelu_erf(x: f32) -> f32 { 0.5 * x * (1.0 + erf(x * std::f32::consts::FRAC_1_SQRT_2)) } + +#[test] +fn whisper_base_stt_vs_hf() { + let dir = std::env::var("WHISPER_BASE_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = CudaDevice::create().expect("cuda") else { eprintln!("no CUDA — skip"); return; }; + let d = dev.as_ref(); + + let (mel, t_in, dm, nh, hd, ffn, eps) = (80usize, 3000usize, 512usize, 8usize, 64usize, 2048usize, 1e-5f32); + let (enc_layers, dec_layers, vocab) = (6usize, 6usize, 51865usize); + let t_out = t_in / 2; // 1500 + let scale = 1.0 / (hd as f32).sqrt(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], b: &[f32], rows: usize, cols: usize| { for r in 0..rows { for c in 0..cols { m[r * cols + c] += b[c]; } } }; + // reorg [n, dm] → per-head KV cache [nh, n, hd] + let reorg = |m: &[f32], n: usize| -> Vec { + let mut o = vec![0.0f32; nh * n * hd]; + for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * dm + h * hd + dd]; } } } + o + }; + + // ════ ENCODER ════ + let feat: Vec = (0..mel * t_in).map(|i| (0.01 * i as f32).sin()).collect(); + let k = 3usize; + // conv1 (80→512, k3 pad1 stride1) + let mut col1 = vec![0.0f32; t_in * (mel * k)]; + for t in 0..t_in { for c in 0..mel { for kk in 0..k { + let pos = t as isize - 1 + kk as isize; + if pos >= 0 && (pos as usize) < t_in { col1[t * (mel * k) + c * k + kk] = feat[c * t_in + pos as usize]; } + }}} + let mut c1 = dl(&matmul(d, &up(&g("model.encoder.conv1.weight"), vec![dm, mel * k]), &up(&col1, vec![t_in, mel * k])).unwrap(), t_in * dm); + add_bias(&mut c1, &g("model.encoder.conv1.bias"), t_in, dm); + for v in c1.iter_mut() { *v = gelu_erf(*v); } + // conv2 (512→512, k3 pad1 stride2) + let mut col2 = vec![0.0f32; t_out * (dm * k)]; + for j in 0..t_out { for c in 0..dm { for kk in 0..k { + let pos = 2 * j as isize - 1 + kk as isize; + if pos >= 0 && (pos as usize) < t_in { col2[j * (dm * k) + c * k + kk] = c1[pos as usize * dm + c]; } + }}} + let mut enc = dl(&matmul(d, &up(&g("model.encoder.conv2.weight"), vec![dm, dm * k]), &up(&col2, vec![t_out, dm * k])).unwrap(), t_out * dm); + add_bias(&mut enc, &g("model.encoder.conv2.bias"), t_out, dm); + for v in enc.iter_mut() { *v = gelu_erf(*v); } + let epos = g("model.encoder.embed_positions.weight"); + for i in 0..t_out * dm { enc[i] += epos[i]; } + // full self-attention over `src` [n,dm] (normed); returns attn output [n*dm] + let self_attn = |normed: &[f32], pfx: &str, n: usize| -> Vec { + let mut q = dl(&matmul(d, &up(&g(&format!("{pfx}.q_proj.weight")), vec![dm, dm]), &up(normed, vec![n, dm])).unwrap(), n * dm); + add_bias(&mut q, &g(&format!("{pfx}.q_proj.bias")), n, dm); + let kk = dl(&matmul(d, &up(&g(&format!("{pfx}.k_proj.weight")), vec![dm, dm]), &up(normed, vec![n, dm])).unwrap(), n * dm); // no bias + let mut vv = dl(&matmul(d, &up(&g(&format!("{pfx}.v_proj.weight")), vec![dm, dm]), &up(normed, vec![n, dm])).unwrap(), n * dm); + add_bias(&mut vv, &g(&format!("{pfx}.v_proj.bias")), n, dm); + let kt = up(&reorg(&kk, n), vec![nh, n, hd]); let vt = up(&reorg(&vv, n), vec![nh, n, hd]); + let mut out = vec![0.0f32; n * dm]; + for t in 0..n { + let qt = up(&q[t * dm..(t + 1) * dm], vec![nh, hd]); + let a = sdpa_decode(d, &qt, &kt, &vt, hd, n as u32, n as u32, 1, scale).unwrap(); + out[t * dm..(t + 1) * dm].copy_from_slice(&dl(&a, dm)); + } + let mut o = dl(&matmul(d, &up(&g(&format!("{pfx}.out_proj.weight")), vec![dm, dm]), &up(&out, vec![n, dm])).unwrap(), n * dm); + add_bias(&mut o, &g(&format!("{pfx}.out_proj.bias")), n, dm); + o + }; + for l in 0..enc_layers { + let p = format!("model.encoder.layers.{l}"); + let ln = dl(&layer_norm(d, &up(&enc, vec![t_out, dm]), &up(&g(&format!("{p}.self_attn_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.self_attn_layer_norm.bias")), vec![dm]), eps).unwrap(), t_out * dm); + let o = self_attn(&ln, &format!("{p}.self_attn"), t_out); + for i in 0..t_out * dm { enc[i] += o[i]; } + let ln2 = dl(&layer_norm(d, &up(&enc, vec![t_out, dm]), &up(&g(&format!("{p}.final_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.final_layer_norm.bias")), vec![dm]), eps).unwrap(), t_out * dm); + let mut h1 = dl(&matmul(d, &up(&g(&format!("{p}.fc1.weight")), vec![ffn, dm]), &up(&ln2, vec![t_out, dm])).unwrap(), t_out * ffn); + add_bias(&mut h1, &g(&format!("{p}.fc1.bias")), t_out, ffn); + for v in h1.iter_mut() { *v = gelu_erf(*v); } + let mut h2 = dl(&matmul(d, &up(&g(&format!("{p}.fc2.weight")), vec![dm, ffn]), &up(&h1, vec![t_out, ffn])).unwrap(), t_out * dm); + add_bias(&mut h2, &g(&format!("{p}.fc2.bias")), t_out, dm); + for i in 0..t_out * dm { enc[i] += h2[i]; } + } + let enc_out = dl(&layer_norm(d, &up(&enc, vec![t_out, dm]), &up(&g("model.encoder.layer_norm.weight"), vec![dm]), &up(&g("model.encoder.layer_norm.bias"), vec![dm]), eps).unwrap(), t_out * dm); + + // ════ DECODER (single SOT token at pos 0) ════ + let sot = 50258usize; + let demb = g("model.decoder.embed_tokens.weight"); + let dpos = g("model.decoder.embed_positions.weight"); + let mut x: Vec = (0..dm).map(|i| demb[sot * dm + i] + dpos[i]).collect(); + let dproj = |w: &str, b: Option<&str>, x: &Tensor, m: usize, inn: usize| -> Vec { + let mut o = dl(&gemv(d, &up(&g(w), vec![m, inn]), x).unwrap(), m); + if let Some(bb) = b { let bv = g(bb); for i in 0..m { o[i] += bv[i]; } } + o + }; + for l in 0..dec_layers { + let p = format!("model.decoder.layers.{l}"); + // causal self-attn (n_kv=1 at pos0) + let h = layer_norm(d, &up(&x, vec![dm]), &up(&g(&format!("{p}.self_attn_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.self_attn_layer_norm.bias")), vec![dm]), eps).unwrap(); + let q = dproj(&format!("{p}.self_attn.q_proj.weight"), Some(&format!("{p}.self_attn.q_proj.bias")), &h, dm, dm); + let kk = dproj(&format!("{p}.self_attn.k_proj.weight"), None, &h, dm, dm); + let vv = dproj(&format!("{p}.self_attn.v_proj.weight"), Some(&format!("{p}.self_attn.v_proj.bias")), &h, dm, dm); + let sa = sdpa_decode(d, &up(&q, vec![nh, hd]), &up(&kk, vec![nh, hd]), &up(&vv, vec![nh, hd]), hd, 1, 1, 1, scale).unwrap(); + let o = dproj(&format!("{p}.self_attn.out_proj.weight"), Some(&format!("{p}.self_attn.out_proj.bias")), &sa.reshaped(vec![dm]), dm, dm); + for i in 0..dm { x[i] += o[i]; } + // cross-attn (q from decoder, K/V from encoder output) + let hc = layer_norm(d, &up(&x, vec![dm]), &up(&g(&format!("{p}.encoder_attn_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.encoder_attn_layer_norm.bias")), vec![dm]), eps).unwrap(); + let qc = dproj(&format!("{p}.encoder_attn.q_proj.weight"), Some(&format!("{p}.encoder_attn.q_proj.bias")), &hc, dm, dm); + let kc = dl(&matmul(d, &up(&g(&format!("{p}.encoder_attn.k_proj.weight")), vec![dm, dm]), &up(&enc_out, vec![t_out, dm])).unwrap(), t_out * dm); // no bias + let mut vc = dl(&matmul(d, &up(&g(&format!("{p}.encoder_attn.v_proj.weight")), vec![dm, dm]), &up(&enc_out, vec![t_out, dm])).unwrap(), t_out * dm); + add_bias(&mut vc, &g(&format!("{p}.encoder_attn.v_proj.bias")), t_out, dm); + let kt = up(&reorg(&kc, t_out), vec![nh, t_out, hd]); let vt = up(&reorg(&vc, t_out), vec![nh, t_out, hd]); + let ca = sdpa_decode(d, &up(&qc, vec![nh, hd]), &kt, &vt, hd, t_out as u32, t_out as u32, 1, scale).unwrap(); + let oc = dproj(&format!("{p}.encoder_attn.out_proj.weight"), Some(&format!("{p}.encoder_attn.out_proj.bias")), &ca.reshaped(vec![dm]), dm, dm); + for i in 0..dm { x[i] += oc[i]; } + // MLP + let hm = layer_norm(d, &up(&x, vec![dm]), &up(&g(&format!("{p}.final_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.final_layer_norm.bias")), vec![dm]), eps).unwrap(); + let mut f = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.fc1.weight")), vec![ffn, dm]), &hm).unwrap(), &up(&g(&format!("{p}.fc1.bias")), vec![ffn])).unwrap(), ffn); + for v in f.iter_mut() { *v = gelu_erf(*v); } + let m2 = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.fc2.weight")), vec![dm, ffn]), &up(&f, vec![ffn])).unwrap(), &up(&g(&format!("{p}.fc2.bias")), vec![dm])).unwrap(), dm); + for i in 0..dm { x[i] += m2[i]; } + } + let xf = layer_norm(d, &up(&x, vec![dm]), &up(&g("model.decoder.layer_norm.weight"), vec![dm]), &up(&g("model.decoder.layer_norm.bias"), vec![dm]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&demb, vec![vocab, dm]), &xf).unwrap(), vocab); // tied proj_out + let argmax = (0..vocab).max_by(|&a, &b| logits[a].total_cmp(&logits[b])).unwrap(); + eprintln!("Whisper-base STT on CUDA: argmax = {argmax} (HF = 50362)"); + assert_eq!(argmax, 50362, "Whisper-base STT argmax != HF 50362"); + eprintln!("✅ Full real Whisper-base encoder→decoder STT matches HF on the shared engine (GB10 sm_121) — cross-attention path verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--openai--whisper-base/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/Cargo.toml b/rust/crates/backends/ffai-metal/Cargo.toml new file mode 100644 index 00000000..69238827 --- /dev/null +++ b/rust/crates/backends/ffai-metal/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ffai-metal" +description = "FFAI Metal backend — wraps metaltile-runtime's Context (MSL JIT + dispatch) behind the ffai-core Device trait. Runs the shared Rust models on Apple GPUs (incl. iOS)." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +parking_lot.workspace = true +ffai-core.workspace = true +# metaltile-runtime's Context is the public Metal dispatch face. Builds on +# every platform (Metal code is target_os=macos-gated inside; has_gpu()=false +# elsewhere), so no feature gating needed here. +metaltile-runtime.workspace = true +metaltile-core.workspace = true + +[dev-dependencies] +ffai-modeltests.workspace = true +ffai-ops.workspace = true +ffai-models.workspace = true +ffai-loader.workspace = true +ffai-runtime.workspace = true diff --git a/rust/crates/backends/ffai-metal/src/lib.rs b/rust/crates/backends/ffai-metal/src/lib.rs new file mode 100644 index 00000000..a69da72f --- /dev/null +++ b/rust/crates/backends/ffai-metal/src/lib.rs @@ -0,0 +1,186 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-metal +//! +//! Metal backend for the FFAI engine. Wraps `metaltile_runtime::Context` +//! (the public Metal face — MSL JIT, PSO cache, dispatch) behind the shared +//! [`ffai_core::Device`] trait, so the **same Rust models run on Apple GPUs +//! (and iOS)** as on CUDA. This is the half that makes "models shared across +//! CUDA *and* Metal" real. +//! +//! Each [`MetalBuffer`] keeps a host shadow (`Vec`, the `download` source of +//! truth) plus a lazily-cached GPU-resident `ResidentBuffer`, so pure inputs +//! (weights) upload **once** and bind resident across every dispatch instead of +//! being re-staged from host bytes each call. Outputs flow through host bytes +//! (preserves in-place reads + readback) and invalidate the stale resident copy. + +use ffai_core::{Backend, Binding, Device, DeviceBuffer, Error, Grid, Kernel, Result}; +use metaltile_runtime::{Context, DispatchSpec, MetalTileError, ResidentBuffer}; +use parking_lot::{Mutex, RwLock}; +use std::any::Any; +use std::collections::BTreeMap; +use std::sync::Arc; + +fn err(e: MetalTileError) -> Error { + Error::Dispatch(e.to_string()) +} + +/// A device buffer. `data` is the host shadow (source of truth for `download`); +/// `resident` lazily caches a GPU-resident [`ResidentBuffer`] so inputs that +/// don't change between dispatches (weights!) upload **once** instead of every +/// dispatch. Writing the buffer (it being a kernel output) invalidates the +/// resident copy so the next read re-uploads the new bytes. +pub struct MetalBuffer { + data: RwLock>, + resident: Mutex>, +} +// ResidentBuffer wraps an Rc (!Send on macOS); all access is +// serialized through the MetalDevice Context mutex from one logical owner, so +// the manual Send/Sync are sound for this usage (mirrors MetalDevice). +unsafe impl Send for MetalBuffer {} +unsafe impl Sync for MetalBuffer {} +impl DeviceBuffer for MetalBuffer { + fn len(&self) -> usize { + self.data.read().len() + } + fn as_any(&self) -> &dyn Any { + self + } +} + +/// Metal device: a metaltile `Context` behind the shared `Device` trait. +pub struct MetalDevice { + ctx: Mutex, + name: String, +} +// Context holds objc2 (`!Send`) types on macOS; we serialize all access +// through the Mutex and only ever submit from one logical owner, so the +// manual Send/Sync are sound for this usage. +unsafe impl Send for MetalDevice {} +unsafe impl Sync for MetalDevice {} + +impl MetalDevice { + /// Probe for a Metal GPU; `Ok(None)` if none (e.g. off Apple silicon). + pub fn create() -> Result>> { + let ctx = Context::new().map_err(err)?; + if !ctx.has_gpu() { + return Ok(None); + } + let name = format!("Apple GPU (family {:?})", ctx.gpu_family()); + Ok(Some(Arc::new(MetalDevice { ctx: Mutex::new(ctx), name }))) + } + + fn shadow(b: &Arc) -> Result<&MetalBuffer> { + b.as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg("metal: binding is not a MetalBuffer".into())) + } +} + +impl Device for MetalDevice { + fn backend(&self) -> Backend { + Backend::Metal + } + fn name(&self) -> &str { + &self.name + } + + fn alloc(&self, len: usize) -> Result> { + Ok(Arc::new(MetalBuffer { data: RwLock::new(vec![0u8; len]), resident: Mutex::new(None) })) + } + + fn upload(&self, bytes: &[u8]) -> Result> { + Ok(Arc::new(MetalBuffer { data: RwLock::new(bytes.to_vec()), resident: Mutex::new(None) })) + } + + fn download(&self, buf: &dyn DeviceBuffer, out: &mut [u8]) -> Result<()> { + let mb = buf + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg("metal: download buffer is not a MetalBuffer".into()))?; + let src = mb.data.read(); + let n = out.len().min(src.len()); + out[..n].copy_from_slice(&src[..n]); + Ok(()) + } + + fn dispatch(&self, kernel: &Kernel, bindings: &[Binding], grid: Grid) -> Result<()> { + let ctx = self.ctx.lock(); + let n_params = kernel.params.len(); + // Bind every buffer as GPU-resident (uploaded once, cached on the + // MetalBuffer) and scalars as host bytes. Weights — pure inputs that + // never change — thus upload a single time instead of every dispatch, + // which was the dominant decode cost. Keys are param/constexpr NAMEs: + // tensor params first, then constexprs (same contract as CUDA). + let mut resident: BTreeMap = BTreeMap::new(); + let mut host_buffers: BTreeMap> = BTreeMap::new(); + let fn_consts: BTreeMap = BTreeMap::new(); + for (i, b) in bindings.iter().enumerate() { + let name = if i < n_params { + kernel.params[i].name.clone() + } else { + kernel.constexprs[i - n_params].name.name().to_string() + }; + match b { + Binding::Scalar(s) => { + host_buffers.insert(name, s.clone()); + } + Binding::Buffer(buf) => { + let mb = Self::shadow(buf)?; + // Output params go through host bytes (dispatch_chain + // allocates + returns them, and any in-place read sees the + // current value). Pure inputs bind resident + cache, so + // weights upload once across all dispatches. + if i < n_params && kernel.params[i].is_output { + host_buffers.insert(name, mb.data.read().clone()); + } else { + let mut slot = mb.resident.lock(); + if slot.is_none() { + let bytes = mb.data.read(); + *slot = Some(ctx.upload_resident(&bytes).map_err(err)?); + } + resident.insert(name, slot.as_ref().unwrap().clone()); + } + } + } + } + + let g = [grid.grid[0] as usize, grid.grid[1] as usize, grid.grid[2] as usize]; + let t = [grid.block[0] as usize, grid.block[1] as usize, grid.block[2] as usize]; + let spec = DispatchSpec { + kernel, + buffers: &host_buffers, + fn_consts: &fn_consts, + grid_groups: g, + threads_per_group: t, + resident: &resident, + }; + let results = ctx.dispatch_chain(&[spec]).map_err(err)?; + let result = results.into_iter().next().ok_or_else(|| Error::Msg("metal: empty dispatch result".into()))?; + + // Copy each output param's result into its shadow + invalidate its + // resident copy (it's now stale; next read re-uploads the new bytes). + for (i, p) in kernel.params.iter().enumerate() { + if p.is_output { + if let Some(out_bytes) = result.output(&p.name) { + if let Binding::Buffer(buf) = &bindings[i] { + let mb = Self::shadow(buf)?; + { + let mut w = mb.data.write(); + let n = w.len().min(out_bytes.len()); + w[..n].copy_from_slice(&out_bytes[..n]); + } + *mb.resident.lock() = None; + } + } + } + } + Ok(()) + } + + fn synchronize(&self) -> Result<()> { + // dispatch_chain commits + waits, so nothing is outstanding. + Ok(()) + } +} diff --git a/rust/crates/backends/ffai-metal/tests/all_models.rs b/rust/crates/backends/ffai-metal/tests/all_models.rs new file mode 100644 index 00000000..d63da54c --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/all_models.rs @@ -0,0 +1,11 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! The entire shared model suite on Metal — ONE file. The forwards live once in +//! ffai-modeltests; this just builds the device and runs them. A new backend +//! (ROCm/Vulkan/…) copies this file with its own Device. See docs/BACKENDS.md. +use ffai_metal::MetalDevice; +#[test] +fn all_models_on_metal() { + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + ffai_modeltests::run_all(dev.as_ref(), "Apple GPU"); +} diff --git a/rust/crates/backends/ffai-metal/tests/conv1d_test.rs b/rust/crates/backends/ffai-metal/tests/conv1d_test.rs new file mode 100644 index 00000000..62227747 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/conv1d_test.rs @@ -0,0 +1,42 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Causal conv1d step (conv1d_causal_step) on Metal vs CPU — the short-conv +//! used by Mamba2 + audio front-ends. +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::conv1d_causal_step; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn tn(d:&dyn Device,v:&[f32],sh:Vec)->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),sh,DType::F32)} + +#[test] +fn conv1d_causal_step_on_metal_matches_cpu(){ + let Some(dev)=MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let (nc, ks) = (128usize, 4usize); + let x: Vec = (0..nc).map(|i| ((i as f32)*0.013).sin()).collect(); + let w: Vec = (0..ks*nc).map(|i| 0.1+((i as f32)*0.019).cos()*0.2).collect(); + let b: Vec = (0..nc).map(|i| (i as f32)*0.001-0.05).collect(); + let st: Vec = (0..(ks-1)*nc).map(|i| ((i as f32)*0.007).sin()*0.5).collect(); + + let st_t = tn(dev.as_ref(),&st,vec![(ks-1)*nc]); + let y = conv1d_causal_step(dev.as_ref(), &tn(dev.as_ref(),&x,vec![nc]), &tn(dev.as_ref(),&w,vec![ks*nc]), &tn(dev.as_ref(),&b,vec![nc]), &st_t, nc as u32, ks as u32).unwrap(); + dev.synchronize().unwrap(); + let mut yb=vec![0u8;nc*4]; dev.download(y.buffer.as_ref(),&mut yb).unwrap(); let yv=fb(&yb); + let mut sb=vec![0u8;(ks-1)*nc*4]; dev.download(st_t.buffer.as_ref(),&mut sb).unwrap(); let so=fb(&sb); + + // CPU ref + let mut y_ref=vec![0.0f32;nc]; let mut s_ref=vec![0.0f32;(ks-1)*nc]; + for d in 0..nc { + let mut acc=b[d]+w[(ks-1)*nc+d]*x[d]; + for k in 0..ks-1 { acc += w[k*nc+d]*st[k*nc+d]; } + y_ref[d]=acc; + for k in 0..ks.saturating_sub(2) { s_ref[k*nc+d]=st[(k+1)*nc+d]; } + s_ref[(ks-2)*nc+d]=x[d]; + } + let mut ey=0.0f32; for i in 0..nc { ey=ey.max((yv[i]-y_ref[i]).abs()); } + let mut es=0.0f32; for i in 0..(ks-1)*nc { es=es.max((so[i]-s_ref[i]).abs()); } + eprintln!("conv1d_causal_step on Metal vs CPU: y max|Δ|={ey:.3e} state max|Δ|={es:.3e}"); + assert!(ey<=1e-4 && es<=1e-4, "conv1d mismatch y={ey:.3e} state={es:.3e}"); + eprintln!("✅ Causal conv1d step runs on Apple GPU through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-metal/tests/dequant_q4_oob.rs b/rust/crates/backends/ffai-metal/tests/dequant_q4_oob.rs new file mode 100644 index 00000000..841f6e3e --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/dequant_q4_oob.rs @@ -0,0 +1,215 @@ +// Copyright 2026 Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Metal-validation cross-check for the CUDA `dequant_q4_off` MMU-fault. +//! +//! The Nemotron batched-prefill CUDA path deterministically MMU-faults (Xid 31, +//! VIRT_READ out-of-bounds) and the prime suspect is `ffai_ops::dequant_q4_off` +//! — the 1-thread-per-u32-word Q4 dequant used for the dense projections +//! (blk_off=0) and the per-MoE-expert sub-slabs (blk_off != 0). This test calls +//! that op directly at both shapes, under Metal's GPU/shader validation layer +//! (enabled via env in the test harness; see comment at the bottom), and +//! bit-compares the output against a CPU reference dequant to catch a silent +//! over-read that lands in valid memory on Metal but faults at the buffer +//! boundary on CUDA. +//! +//! Run with validation ON: +//! MTL_SHADER_VALIDATION=1 MTL_DEBUG_LAYER=1 METAL_DEVICE_WRAPPER_TYPE=1 \ +//! cargo test -p ffai-metal --test dequant_q4_oob -- --nocapture +//! +//! Nemotron-Cascade-2-30B-A3B dims: hid=2688, inter=1856, n_exp=128. + +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::{dequant_q4_off, quantize_q4}; + +// ── f16 round-trip (mirrors the loader's tb_f16 + the kernel's f16 read) ────── +fn f32_to_f16(f: f32) -> u16 { + let x = f.to_bits(); + let sign = ((x >> 16) & 0x8000) as u16; + let e = ((x >> 23) & 0xff) as i32 - 112; // 127 - 15 + if e <= 0 { + return sign; + } + if e >= 0x1f { + return sign | 0x7c00; + } + let m = (x >> 13) & 0x3ff; + let round = (x >> 12) & 1; + let v = ((e as u32) << 10) | m; + sign | ((v + round) as u16) +} + +fn f16_to_f32(h: u16) -> f32 { + let sign = ((h as u32) & 0x8000) << 16; + let exp = ((h as u32) >> 10) & 0x1f; + let mant = (h as u32) & 0x3ff; + let bits = if exp == 0 { + if mant == 0 { + sign + } else { + // subnormal + let mut e = -1i32; + let mut m = mant; + while m & 0x400 == 0 { + m <<= 1; + e -= 1; + } + m &= 0x3ff; + sign | (((e + 127 - 15) as u32) << 23) | (m << 13) + } + } else if exp == 0x1f { + sign | 0x7f80_0000 | (mant << 13) + } else { + sign | ((exp + 127 - 15) << 23) | (mant << 13) + }; + f32::from_bits(bits) +} + +fn tb_u32(v: &[u32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn tb_f16(v: &[f32]) -> Vec { + v.iter().flat_map(|&f| f32_to_f16(f).to_le_bytes()).collect() +} +fn dl_f16(d: &dyn Device, t: &Tensor, n: usize) -> Vec { + let mut b = vec![0u8; n * 2]; + d.download(t.buffer.as_ref(), &mut b).unwrap(); + b.chunks_exact(2) + .map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]]))) + .collect() +} + +/// CPU reference dequant of the `[m,k]` slab at block offset `blk_off` inside a +/// `qs`/`scales` pool. Scales already rounded through f16. Mirrors the kernel. +fn cpu_dequant_off( + qs: &[u32], + scales_f16: &[f32], + m: usize, + k: usize, + blk_off: usize, +) -> Vec { + let bpr = k / 32; + let mut out = vec![0f32; m * k]; + for r in 0..m { + for b in 0..bpr { + let blk = blk_off + r * bpr + b; + let d = scales_f16[blk]; + for word in 0..4 { + let packed = qs[blk * 4 + word]; + for i in 0..8 { + let nib = (packed >> (i * 4)) & 0xf; + let q = if nib >= 8 { nib as i32 - 16 } else { nib as i32 }; + out[r * k + b * 32 + word * 8 + i] = q as f32 * d; + } + } + } + } + out +} + +/// Build a Q4 pool of `n_rows` rows × `k` cols, scales rounded through f16 to +/// match the GPU's f16 scale read. Returns (qs, scales_f16_rounded). +fn build_pool(n_rows: usize, k: usize) -> (Vec, Vec) { + let n = n_rows * k; + let vals: Vec = (0..n) + .map(|i| (i as f32 * 0.013 - 0.4).sin() * 1.7) + .collect(); + let (qs, scales) = quantize_q4(&vals, n_rows, k); + let scales_f16: Vec = scales.iter().map(|&s| f16_to_f32(f32_to_f16(s))).collect(); + (qs, scales_f16) +} + +fn max_abs_diff(a: &[f32], b: &[f32]) -> f32 { + a.iter() + .zip(b) + .fold(0f32, |m, (x, y)| m.max((x - y).abs())) +} + +/// (a) Dense projection: blk_off=0, full pool. m=k=2688 (hid). +#[test] +fn dequant_q4_dense_projection_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let (m, k) = (2688usize, 2688usize); + let (qs, sc16) = build_pool(m, k); + let qt = Tensor::new(d.upload(&tb_u32(&qs)).unwrap(), vec![qs.len()], DType::U32); + let st = Tensor::new(d.upload(&tb_f16(&sc16)).unwrap(), vec![sc16.len()], DType::F16); + + let out = dequant_q4_off(d, &qt, &st, m, k, DType::F16, 0).unwrap(); + let got = dl_f16(d, &out, m * k); + let want = cpu_dequant_off(&qs, &sc16, m, k, 0); + let diff = max_abs_diff(&got, &want); + eprintln!("[dense blk_off=0 m={m} k={k}] max|GPU-CPU| = {diff:e}"); + // Tolerance = a few f16 ULP at this magnitude (our hand-rolled f16 oracle is + // not bit-identical to Metal's storage rounding; >1e-2 would mean real + // corruption / an over-read pulling in garbage). + assert!(diff < 1e-2, "dense projection dequant mismatch: {diff:e}"); +} + +/// (b) MoE expert sub-slab: blk_off = e*inter*(hid/32), full pool present. +/// Exercises every expert offset including the LAST (e=n_exp-1), whose top +/// block is the final block of the pool — the CUDA boundary-fault candidate. +#[test] +fn dequant_q4_expert_subslab_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let (n_exp, inter, hid) = (128usize, 1856usize, 2688usize); + let up_bpr = hid / 32; // Q4 blocks per up-weight row + // up pool: [n_exp*inter, hid] + let (qs, sc16) = build_pool(n_exp * inter, hid); + let qt = Tensor::new(d.upload(&tb_u32(&qs)).unwrap(), vec![qs.len()], DType::U32); + let st = Tensor::new(d.upload(&tb_f16(&sc16)).unwrap(), vec![sc16.len()], DType::F16); + + // Spot-check the first, a middle, and the LAST expert (boundary case). + for &e in &[0usize, n_exp / 2, n_exp - 1] { + let blk_off = e * inter * up_bpr; + let out = dequant_q4_off(d, &qt, &st, inter, hid, DType::F16, blk_off).unwrap(); + let got = dl_f16(d, &out, inter * hid); + let want = cpu_dequant_off(&qs, &sc16, inter, hid, blk_off); + let diff = max_abs_diff(&got, &want); + eprintln!("[expert e={e} blk_off={blk_off} m={inter} k={hid}] max|GPU-CPU| = {diff:e}"); + assert!(diff < 1e-2, "expert {e} dequant mismatch: {diff:e}"); + } +} + +/// (c) TIGHT-POOL boundary probe: a pool sized to EXACTLY the last expert's +/// slab (blk_off points at offset 0 of a buffer that holds only ONE expert's +/// worth of blocks). The kernel's top read is `qs[(blk_off + (m-1)*bpr + +/// (bpr-1))*4 + 3]`. If blk_off is non-zero but the bound buffer does NOT +/// actually contain `blk_off + m*bpr` blocks, the kernel over-reads. This is +/// what the CUDA MMU faults on. We bind a buffer that is exactly `m*bpr*4` +/// u32 words but pass a non-zero blk_off → guaranteed over-read of the qs +/// (and scales) buffer. Under Metal shader validation this MUST flag an OOB. +#[test] +fn dequant_q4_undersized_pool_overread_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let (inter, hid) = (1856usize, 2688usize); + let up_bpr = hid / 32; + // Pool holds exactly ONE expert's blocks, but we ask for expert index 1. + let (qs, sc16) = build_pool(inter, hid); + let qt = Tensor::new(d.upload(&tb_u32(&qs)).unwrap(), vec![qs.len()], DType::U32); + let st = Tensor::new(d.upload(&tb_f16(&sc16)).unwrap(), vec![sc16.len()], DType::F16); + let blk_off = 1 * inter * up_bpr; // points PAST the end of this 1-expert pool + + eprintln!( + "[overread] pool words={} but reading from blk_off={} (word {}) — expect Metal OOB flag", + qs.len(), + blk_off, + blk_off * 4 + ); + // We do NOT assert numeric correctness here — the point is whether Metal's + // validation layer reports the OOB read. If validation is off, Metal reads + // garbage/zeros from past the buffer (lenient) and this returns Ok. + let res = dequant_q4_off(d, &qt, &st, inter, hid, DType::F16, blk_off); + eprintln!("[overread] dispatch result = {:?}", res.map(|_| "Ok")); +} diff --git a/rust/crates/backends/ffai-metal/tests/dispatch_bench.rs b/rust/crates/backends/ffai-metal/tests/dispatch_bench.rs new file mode 100644 index 00000000..f5e57ecb --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/dispatch_bench.rs @@ -0,0 +1,48 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Per-dispatch overhead micro-benchmark (Rust → Metal). Isolates the cost of +//! ONE kernel dispatch on the Apple GPU — the quantity that decides whether the +//! Rust host adds overhead vs native Swift. It does not: a dispatch is an +//! `MTLCommandBuffer.commit()` + wait, a Metal/driver cost paid identically +//! whether the encoder is driven from Swift (MetalKit) or Rust (objc2). The +//! decode bottleneck is the dispatch COUNT (~120/token), not the host language. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::{add, gemv}; +use std::time::Instant; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } + +#[test] +fn metal_per_dispatch_overhead() { + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + let up = |v: &[f32], sh: Vec| Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32); + + // tiny elementwise add — work is ~0, so timing = pure dispatch overhead + let n = 4096usize; + let a = up(&vec![1.0f32; n], vec![n]); + let b = up(&vec![2.0f32; n], vec![n]); + // realistic decode op: a [2048x2048] gemv (the projection shape in a 2B model) + let m = 2048usize; + let w = up(&vec![0.01f32; m * m], vec![m, m]); + let x = up(&vec![0.1f32; m], vec![m]); + + // warmup (JIT + PSO) + for _ in 0..20 { let _ = add(d, &a, &b).unwrap(); let _ = gemv(d, &w, &x).unwrap(); } + + let iters = 3000; + let t0 = Instant::now(); + for _ in 0..iters { let _ = add(d, &a, &b).unwrap(); } + let add_us = t0.elapsed().as_secs_f64() * 1e6 / iters as f64; + + let t1 = Instant::now(); + for _ in 0..iters { let _ = gemv(d, &w, &x).unwrap(); } + let gemv_us = t1.elapsed().as_secs_f64() * 1e6 / iters as f64; + + eprintln!("Rust → Metal per-dispatch overhead (Apple GPU), {iters} iters each:"); + eprintln!(" tiny add (4096): {add_us:.1} µs/dispatch"); + eprintln!(" gemv (2048x2048): {gemv_us:.1} µs/dispatch"); + eprintln!(" → a ~120-dispatch decode step ≈ {:.2} ms just in dispatch overhead", add_us * 120.0 / 1000.0); + eprintln!(" This commit()+wait cost is the same Metal driver call Swift makes; the host language is not the variable."); +} diff --git a/rust/crates/backends/ffai-metal/tests/dsv4_layer.rs b/rust/crates/backends/ffai-metal/tests/dsv4_layer.rs new file mode 100644 index 00000000..4914c4c7 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/dsv4_layer.rs @@ -0,0 +1,67 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full DSv4 attention SUBBLOCK (mHC ⊗ MLA): mHC mix → sinkhorn split → +//! collapse → MLA → expand → new 4-channel state. On Metal vs CPU. This is +//! the complete DSv4 attention layer unit. pos=0 → RoPE identity (validates +//! the layer wiring; RoPE verified separately). +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_models::dsv4::{dsv4_attn_subblock, MhcWeights, MlaConfig, MlaWeights}; +use ffai_ops::dsv4_mhc_sinkhorn_split; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn fill(n:usize,s:usize)->Vec{(0..n).map(|i|(((i*7+s*131)%89) as f32-44.0)*0.008).collect()} +fn tn(d:&dyn Device,v:&[f32],sh:Vec)->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),sh,DType::F32)} +fn rms(x:&[f32],w:&[f32],eps:f32)->Vec{let n=x.len();let ms:f32=x.iter().map(|v|v*v).sum::()/n as f32;let s=1.0/(ms+eps).sqrt();(0..n).map(|i|x[i]*s*w[i]).collect()} +fn mv(m:&[f32],v:&[f32],r:usize,k:usize)->Vec{(0..r).map(|i|(0..k).map(|c|m[i*k+c]*v[c]).sum()).collect()} + +#[allow(clippy::too_many_arguments)] +fn cpu_mla(x:&[f32], an:&[f32], qa:&[f32], qan:&[f32], qb:&[f32], kv:&[f32], kvan:&[f32], sink:&[f32], oa:&[Vec], ob:&[f32], cfg:&MlaConfig)->Vec{ + let (h,hd,ql,qd,ol,og)=(cfg.hidden,cfg.head_dim,cfg.q_lora_rank,cfg.n_heads*cfg.head_dim,cfg.o_lora_rank,cfg.o_groups); + let gsize=qd/og; let xn=rms(x,an,cfg.eps); + let q0=mv(qb,&rms(&mv(qa,&xn,ql,h),qan,cfg.eps),qd,ql); + let mut q=q0.clone(); + for hh in 0..cfg.n_heads{let row=&q0[hh*hd..(hh+1)*hd];let ms:f32=row.iter().map(|v|v*v).sum::()/hd as f32;let s=1.0/(ms+cfg.eps).sqrt();for d in 0..hd{q[hh*hd+d]=row[d]*s;}} + let kvn=rms(&mv(kv,&xn,hd,h),kvan,cfg.eps); let scale=1.0/(hd as f32).sqrt(); + let mut attn=vec![0.0f32;qd]; + for hh in 0..cfg.n_heads{let sc=scale*(0..hd).map(|d|q[hh*hd+d]*kvn[d]).sum::();let m=sc.max(sink[hh]);let p=(sc-m).exp()/((sc-m).exp()+(sink[hh]-m).exp());for d in 0..hd{attn[hh*hd+d]=p*kvn[d];}} + let mut olow=vec![0.0f32;og*ol]; + for g in 0..og{let s=&attn[g*gsize..(g+1)*gsize];let r=mv(&oa[g],s,ol,gsize);olow[g*ol..(g+1)*ol].copy_from_slice(&r);} + mv(ob,&olow,h,og*ol) +} + +#[test] +fn dsv4_attn_subblock_on_metal_matches_cpu(){ + let Some(dev)=MetalDevice::create().expect("metal init") else { eprintln!("no Metal — skip"); return; }; + let cfg=MlaConfig{hidden:512,n_heads:2,head_dim:512,q_lora_rank:256,n_nope:448,half_rot:32,o_lora_rank:64,o_groups:8,rope_theta:10000.0,eps:1e-6}; + let (h,hd,ql,qd,ol,og)=(cfg.hidden,cfg.head_dim,cfg.q_lora_rank,cfg.n_heads*cfg.head_dim,cfg.o_lora_rank,cfg.o_groups); + let nhc=4; let gsize=qd/og; let eps=1e-6f32; let iters=20u32; + // weights + let an=fill(h,1);let qa=fill(ql*h,2);let qan=fill(ql,3);let qb=fill(qd*ql,4);let kv=fill(hd*h,5);let kvan=fill(hd,6);let sink=vec![0.4f32,-0.2]; + let oa:Vec>=(0..og).map(|g|fill(ol*gsize,20+g)).collect();let ob=fill(h*(og*ol),40); + let hc_fn=fill(24*nhc*h,7);let hc_scale=[0.5f32,0.7,0.9];let hc_base=fill(24,8); + let hc_state=fill(nhc*h,99); + + let mla=MlaWeights{attn_norm:tn(dev.as_ref(),&an,vec![h]),q_a:tn(dev.as_ref(),&qa,vec![ql,h]),q_a_norm:tn(dev.as_ref(),&qan,vec![ql]),q_b:tn(dev.as_ref(),&qb,vec![qd,ql]),kv:tn(dev.as_ref(),&kv,vec![hd,h]),kv_a_norm:tn(dev.as_ref(),&kvan,vec![hd]),sink:tn(dev.as_ref(),&sink,vec![2]),output_a:oa.iter().map(|g|tn(dev.as_ref(),g,vec![ol,gsize])).collect(),output_b:tn(dev.as_ref(),&ob,vec![h,og*ol])}; + let mhc=MhcWeights{hc_fn:tn(dev.as_ref(),&hc_fn,vec![24,nhc*h]),hc_scale,hc_base:hc_base.clone()}; + let ts=tn(dev.as_ref(),&hc_state,vec![nhc,h]); + + let out=dsv4_attn_subblock(dev.as_ref(),&cfg,&mhc,&mla,&ts,0,eps,iters).unwrap(); + dev.synchronize().unwrap(); + let mut ob_b=vec![0u8;nhc*h*4];dev.download(out.buffer.as_ref(),&mut ob_b).unwrap(); + let got=fb(&ob_b); + + // CPU ref + let mixes=mv(&hc_fn,&hc_state,24,nhc*h); + let (pre,post,comb)=dsv4_mhc_sinkhorn_split(&mixes,hc_scale,&hc_base,eps,iters); + let x:Vec=(0..h).map(|d|(0..nhc).map(|c|pre[c]*hc_state[c*h+d]).sum()).collect(); + let blk=cpu_mla(&x,&an,&qa,&qan,&qb,&kv,&kvan,&sink,&oa,&ob,&cfg); + let mut want=vec![0.0f32;nhc*h]; + for dst in 0..nhc{for d in 0..h{let mut a=blk[d]*post[dst];for src in 0..nhc{a+=comb[dst*nhc+src]*hc_state[src*h+d];}want[dst*h+d]=a;}} + + let mut e=0.0f32;for i in 0..nhc*h{e=e.max((got[i]-want[i]).abs());} + eprintln!("DSv4 attn subblock (mHC⊗MLA) on Metal vs CPU: max|Δ|={e:.3e}"); + assert!(e<=5e-3,"subblock mismatch: {e:.3e}"); + eprintln!("✅ Full DSv4 attention layer unit runs on Apple GPU through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-metal/tests/dsv4_mla.rs b/rust/crates/backends/ffai-metal/tests/dsv4_mla.rs new file mode 100644 index 00000000..f0d8b7ec --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/dsv4_mla.rs @@ -0,0 +1,76 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full DSv4 MLA attention composite on Metal vs CPU. pos=0 → RoPE is +//! identity, so this validates the COMPOSITION wiring (q low-rank → +//! per-head q-norm → sink-SDPA → grouped O-LoRA); RoPE itself is verified +//! separately in dsv4_test. +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_models::dsv4::{mla_attention, MlaConfig, MlaWeights}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn fill(n: usize, s: usize) -> Vec { (0..n).map(|i| (((i * 7 + s * 131) % 89) as f32 - 44.0) * 0.01).collect() } +fn tn(d: &dyn Device, v: &[f32], shape: Vec) -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), shape, DType::F32) } +fn rms(x: &[f32], w: &[f32], eps: f32) -> Vec { + let n = x.len(); let ms: f32 = x.iter().map(|v| v*v).sum::()/n as f32; let s = 1.0/(ms+eps).sqrt(); + (0..n).map(|i| x[i]*s*w[i]).collect() +} +fn mv(m: &[f32], v: &[f32], rows: usize, k: usize) -> Vec { (0..rows).map(|r| (0..k).map(|c| m[r*k+c]*v[c]).sum()).collect() } + +#[test] +fn dsv4_mla_attention_on_metal_matches_cpu() { + let Some(dev) = MetalDevice::create().expect("metal init") else { eprintln!("no Metal — skip"); return; }; + let cfg = MlaConfig { hidden:512, n_heads:2, head_dim:512, q_lora_rank:256, n_nope:448, half_rot:32, o_lora_rank:64, o_groups:8, rope_theta:10000.0, eps:1e-6 }; + let (h, hd, ql, qd, ol, og) = (cfg.hidden, cfg.head_dim, cfg.q_lora_rank, cfg.n_heads*cfg.head_dim, cfg.o_lora_rank, cfg.o_groups); + let gsize = qd / og; + + let attn_norm = fill(h,1); let q_a = fill(ql*h,2); let q_a_norm = fill(ql,3); let q_b = fill(qd*ql,4); + let kv = fill(hd*h,5); let kv_a_norm = fill(hd,6); let sink = vec![0.4f32,-0.2]; + let output_a: Vec> = (0..og).map(|g| fill(ol*gsize, 20+g)).collect(); + let output_b = fill(h*(og*ol), 40); + let x = fill(h, 99); + + let w = MlaWeights { + attn_norm: tn(dev.as_ref(),&attn_norm,vec![h]), + q_a: tn(dev.as_ref(),&q_a,vec![ql,h]), q_a_norm: tn(dev.as_ref(),&q_a_norm,vec![ql]), + q_b: tn(dev.as_ref(),&q_b,vec![qd,ql]), + kv: tn(dev.as_ref(),&kv,vec![hd,h]), kv_a_norm: tn(dev.as_ref(),&kv_a_norm,vec![hd]), + sink: tn(dev.as_ref(),&sink,vec![2]), + output_a: output_a.iter().map(|g| tn(dev.as_ref(),g,vec![ol,gsize])).collect(), + output_b: tn(dev.as_ref(),&output_b,vec![h,og*ol]), + }; + let tx = tn(dev.as_ref(),&x,vec![h]); + let out = mla_attention(dev.as_ref(), &cfg, &w, &tx, 0).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; h*4]; dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU ref (pos=0 → rope identity). + let xn = rms(&x, &attn_norm, cfg.eps); + let qa = mv(&q_a,&xn,ql,h); let qan = rms(&qa,&q_a_norm,cfg.eps); let qf = mv(&q_b,&qan,qd,ql); + let mut q = qf.clone(); + for hh in 0..cfg.n_heads { // per-head unit RMS (ones weight) + let row = &qf[hh*hd..(hh+1)*hd]; + let ms: f32 = row.iter().map(|v| v*v).sum::()/hd as f32; let s = 1.0/(ms+cfg.eps).sqrt(); + for d in 0..hd { q[hh*hd+d] = row[d]*s; } + } + let kvn = rms(&mv(&kv,&xn,hd,h), &kv_a_norm, cfg.eps); + let scale = 1.0/(hd as f32).sqrt(); + let mut attn = vec![0.0f32; qd]; + for hh in 0..cfg.n_heads { + let score = scale * (0..hd).map(|d| q[hh*hd+d]*kvn[d]).sum::(); + let m = score.max(sink[hh]); + let p = (score-m).exp() / ((score-m).exp() + (sink[hh]-m).exp()); + for d in 0..hd { attn[hh*hd+d] = p*kvn[d]; } + } + // grouped O + let mut o_low = vec![0.0f32; og*ol]; + for g in 0..og { let s = &attn[g*gsize..(g+1)*gsize]; let r = mv(&output_a[g], s, ol, gsize); o_low[g*ol..(g+1)*ol].copy_from_slice(&r); } + let want = mv(&output_b, &o_low, h, og*ol); + + let mut e = 0.0f32; for i in 0..h { e = e.max((got[i]-want[i]).abs()); } + eprintln!("DSv4 MLA attention on Metal vs CPU: max|Δ|={e:.3e}"); + assert!(e <= 5e-3, "mla mismatch: {e:.3e}"); + eprintln!("✅ DSv4 MLA attention composite runs on Apple GPU through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-metal/tests/dsv4_moe.rs b/rust/crates/backends/ffai-metal/tests/dsv4_moe.rs new file mode 100644 index 00000000..0728450d --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/dsv4_moe.rs @@ -0,0 +1,59 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! DSv4 MoE feed-forward (sqrtsoftplus route + clamped-SwiGLU experts + +//! shared expert) on Metal vs CPU. +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_models::dsv4::{dsv4_moe, Dsv4Expert, Dsv4Moe}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn fill(n: usize, s: usize) -> Vec { (0..n).map(|i| (((i*7+s*131)%89) as f32 - 44.0)*0.01).collect() } +fn tn(d: &dyn Device, v: &[f32], shape: Vec) -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), shape, DType::F32) } +fn mv(m:&[f32],v:&[f32],r:usize,k:usize)->Vec{(0..r).map(|i|(0..k).map(|c|m[i*k+c]*v[c]).sum()).collect()} +fn sl(g:&[f32],u:&[f32],lim:f32)->Vec{(0..g.len()).map(|i|{let gl=g[i].min(lim);let s=gl/(1.0+(-gl).exp());s*u[i].clamp(-lim,lim)}).collect()} + +#[test] +fn dsv4_moe_on_metal_matches_cpu() { + let Some(dev)=MetalDevice::create().expect("metal init") else { eprintln!("no Metal — skip"); return; }; + let (h, im, ne, tk) = (256usize, 512usize, 8usize, 2usize); + let (rs, lim) = (1.5f32, 10.0f32); + let router = fill(ne*h, 1); + let bias: Vec = (0..ne).map(|i| (i as f32)*0.03 - 0.1).collect(); + let ex: Vec<(Vec,Vec,Vec)> = (0..ne).map(|e| (fill(im*h,10+e), fill(im*h,30+e), fill(h*im,50+e))).collect(); + let sh = (fill(im*h,200), fill(im*h,210), fill(h*im,220)); + let x = fill(h, 99); + + let mk = |g:&Vec,u:&Vec,d:&Vec| Dsv4Expert{gate:tn(dev.as_ref(),g,vec![im,h]),up:tn(dev.as_ref(),u,vec![im,h]),down:tn(dev.as_ref(),d,vec![h,im])}; + let w = Dsv4Moe { + router: tn(dev.as_ref(),&router,vec![ne,h]), bias: bias.clone(), + experts: ex.iter().map(|(g,u,d)| mk(g,u,d)).collect(), + shared: mk(&sh.0,&sh.1,&sh.2), top_k: tk, routed_scaling: rs, swiglu_limit: lim, + }; + let tx = tn(dev.as_ref(),&x,vec![h]); + let out = dsv4_moe(dev.as_ref(), &w, &tx).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; h*4]; dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU ref + let logits = mv(&router,&x,ne,h); + let unb: Vec = logits.iter().map(|&v| (v.max(0.0)+(1.0+(-v.abs()).exp()).ln()).sqrt()).collect(); + let bia: Vec = unb.iter().zip(&bias).map(|(u,b)| u+b).collect(); + let mut ord: Vec = (0..ne).collect(); ord.sort_by(|&a,&b| bia[b].total_cmp(&bia[a])); + let top: Vec = ord.into_iter().take(tk).collect(); + let den: f32 = top.iter().map(|&e| unb[e]).sum(); + let wts: Vec = top.iter().map(|&e| unb[e]/den*rs).collect(); + let mut acc = vec![0.0f32; h]; + for (&e,&gw) in top.iter().zip(&wts) { + let (g,u,d)=&ex[e]; let inner=sl(&mv(g,&x,im,h),&mv(u,&x,im,h),lim); let o=mv(d,&inner,h,im); + for i in 0..h { acc[i]+=gw*o[i]; } + } + let si=sl(&mv(&sh.0,&x,im,h),&mv(&sh.1,&x,im,h),lim); let so=mv(&sh.2,&si,h,im); + for i in 0..h { acc[i]+=so[i]; } + + let mut e=0.0f32; for i in 0..h { e=e.max((got[i]-acc[i]).abs()); } + eprintln!("DSv4 MoE on Metal vs CPU: max|Δ|={e:.3e} (top {top:?})"); + assert!(e <= 5e-3, "dsv4 moe mismatch: {e:.3e}"); + eprintln!("✅ DSv4 MoE feed-forward runs on Apple GPU through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-metal/tests/dsv4_test.rs b/rust/crates/backends/ffai-metal/tests/dsv4_test.rs new file mode 100644 index 00000000..40196ee3 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/dsv4_test.rs @@ -0,0 +1,217 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! DeepSeek-V4 MLA primitive: partial RoPE on the rope tail, on Metal vs a +//! CPU reference. First validated DSv4-specific op on the shared layer. + +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::{ + dsv4_mhc_collapse, dsv4_mhc_expand, dsv4_partial_rope, sdpa_decode_sink, sqrtsoftplus_route, + swiglu_limit, +}; + +fn tb(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn fb(b: &[u8]) -> Vec { + b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() +} + +#[test] +fn dsv4_partial_rope_on_metal_matches_cpu() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + const NH: usize = 4; + const HD: usize = 512; + const NOPE: usize = 448; + const HALF: usize = 32; // (HD-NOPE)/2 = 32, n_rot = 64 + let pos: u32 = 7; + let theta = 10_000.0f32; + + let qk: Vec = (0..NH * HD).map(|i| ((i % 23) as f32 - 11.0) * 0.05).collect(); + let tq = Tensor::new(dev.upload(&tb(&qk)).unwrap(), vec![NH, HD], DType::F32); + + let out = dsv4_partial_rope( + dev.as_ref(), &tq, NH as u32, HD as u32, NOPE as u32, HALF as u32, pos, theta, false, + ) + .unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; NH * HD * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU reference (matches the kernel's own test). + let mut want = qk.clone(); + for head in 0..NH { + for p in 0..HALF { + let inv_freq = (-(p as f32) * 2.0 * theta.ln() / (2.0 * HALF as f32)).exp(); + let th = pos as f32 * inv_freq; + let (c, s) = (th.cos(), th.sin()); + let lo = head * HD + NOPE + 2 * p; + let hi = lo + 1; + want[lo] = qk[lo] * c - qk[hi] * s; + want[hi] = qk[lo] * s + qk[hi] * c; + } + } + + let mut err = 0.0f32; + for i in 0..NH * HD { + err = err.max((got[i] - want[i]).abs()); + } + eprintln!("dsv4_partial_rope on Metal vs CPU: max|Δ|={err:.3e}"); + assert!(err <= 1e-4, "partial_rope mismatch: {err:.3e}"); + eprintln!("✅ DSv4 partial RoPE runs on Apple GPU through the shared op layer, matches CPU."); +} + +#[test] +fn dsv4_sink_sdpa_on_metal_matches_cpu() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + const NQ: usize = 2; + const HD: usize = 512; + const NKV: usize = 64; + const HPG: usize = 2; // n_kv_heads = 1 + let scale = 1.0f32 / (HD as f32).sqrt(); + + let q: Vec = (0..NQ * HD).map(|i| ((i % 19) as f32 - 9.0) * 0.03).collect(); + let kc: Vec = (0..NKV * HD).map(|i| ((i % 23) as f32 - 11.0) * 0.02).collect(); + let vc: Vec = (0..NKV * HD).map(|i| ((i % 13) as f32 - 6.0) * 0.025).collect(); + let sink: Vec = vec![0.5, -0.3]; + + let tq = Tensor::new(dev.upload(&tb(&q)).unwrap(), vec![NQ, HD], DType::F32); + let tk = Tensor::new(dev.upload(&tb(&kc)).unwrap(), vec![NKV, HD], DType::F32); + let tv = Tensor::new(dev.upload(&tb(&vc)).unwrap(), vec![NKV, HD], DType::F32); + let ts = Tensor::new(dev.upload(&tb(&sink)).unwrap(), vec![NQ], DType::F32); + + let out = sdpa_decode_sink(dev.as_ref(), &tq, &tk, &tv, &ts, NKV as u32, NKV as u32, HPG as u32, scale).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; NQ * HD * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + // CPU reference (single kv head; sink extends the denominator). + let mut want = vec![0.0f32; NQ * HD]; + for h in 0..NQ { + let scores: Vec = (0..NKV) + .map(|t| scale * (0..HD).map(|d| q[h * HD + d] * kc[t * HD + d]).sum::()) + .collect(); + let m0 = scores.iter().cloned().fold(f32::MIN, f32::max); + let m = m0.max(sink[h]); + let exps: Vec = scores.iter().map(|s| (s - m).exp()).collect(); + let denom: f32 = exps.iter().sum::() + (sink[h] - m).exp(); + for t in 0..NKV { + let p = exps[t] / denom; + for d in 0..HD { + want[h * HD + d] += p * vc[t * HD + d]; + } + } + } + + let mut err = 0.0f32; + for i in 0..NQ * HD { + err = err.max((got[i] - want[i]).abs()); + } + eprintln!("dsv4 sink-SDPA on Metal vs CPU: max|Δ|={err:.3e}"); + assert!(err <= 1e-4, "sink sdpa mismatch: {err:.3e}"); + eprintln!("✅ DSv4 d512 sink-SDPA runs on Apple GPU through the shared op layer, matches CPU."); +} + +#[test] +fn dsv4_moe_ops_on_metal_match_cpu() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let lim = 10.0f32; + let sig = |x: f32| x / (1.0 + (-x).exp()); + + // ── swiglu_limit ── + let g: Vec = (0..1024).map(|i| (i % 41) as f32 * 0.8 - 16.0).collect(); + let u: Vec = (0..1024).map(|i| (i % 37) as f32 * 0.9 - 16.0).collect(); + let tg = Tensor::new(dev.upload(&tb(&g)).unwrap(), vec![1024], DType::F32); + let tu = Tensor::new(dev.upload(&tb(&u)).unwrap(), vec![1024], DType::F32); + let out = swiglu_limit(dev.as_ref(), &tg, &tu, lim).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; 1024 * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + let mut e = 0.0f32; + for i in 0..1024 { + let want = sig(g[i].min(lim)) * u[i].clamp(-lim, lim); + e = e.max((got[i] - want).abs()); + } + assert!(e <= 1e-5, "swiglu_limit mismatch: {e:.3e}"); + eprintln!("✅ DSv4 swiglu_limit on Metal: max|Δ|={e:.1e}"); + + // ── sqrtsoftplus router (host-side) ── + let logits: Vec = (0..8).map(|i| (i as f32 - 4.0) * 1.3).collect(); + let bias: Vec = (0..8).map(|i| (i as f32) * 0.05 - 0.2).collect(); + let (unb, bia) = sqrtsoftplus_route(&logits, &bias); + let mut e2 = 0.0f32; + for i in 0..8 { + let sp = logits[i].max(0.0) + (1.0 + (-logits[i].abs()).exp()).ln(); + let un = sp.sqrt(); + e2 = e2.max((unb[i] - un).abs()).max((bia[i] - (un + bias[i])).abs()); + } + assert!(e2 <= 1e-6, "router mismatch: {e2:.3e}"); + eprintln!("✅ DSv4 sqrtsoftplus router (host) matches reference: max|Δ|={e2:.1e}"); +} + +#[test] +fn dsv4_mhc_on_metal_matches_cpu() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + const H: usize = 512; // multiple of 256 + const NHC: usize = 4; + + // ── collapse ── + let state: Vec = (0..NHC * H).map(|i| ((i % 17) as f32 - 8.0) * 0.1).collect(); + let pre: Vec = vec![0.6, 0.9, 0.3, 1.1]; + let ts = Tensor::new(dev.upload(&tb(&state)).unwrap(), vec![NHC, H], DType::F32); + let tp = Tensor::new(dev.upload(&tb(&pre)).unwrap(), vec![NHC], DType::F32); + let out = dsv4_mhc_collapse(dev.as_ref(), &ts, &tp, H as u32, NHC as u32).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; H * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + let mut e = 0.0f32; + for d in 0..H { + let want: f32 = (0..NHC).map(|c| pre[c] * state[c * H + d]).sum(); + e = e.max((got[d] - want).abs()); + } + assert!(e <= 1e-4, "collapse mismatch: {e:.3e}"); + eprintln!("✅ DSv4 mHC collapse on Metal: max|Δ|={e:.1e}"); + + // ── expand ── + let block_out: Vec = (0..H).map(|i| ((i % 13) as f32 - 6.0) * 0.05).collect(); + let post: Vec = vec![1.2, 0.8, 1.0, 0.5]; + let comb: Vec = (0..NHC * NHC).map(|i| ((i % 7) as f32 - 3.0) * 0.1).collect(); + let resid: Vec = (0..NHC * H).map(|i| ((i % 11) as f32 - 5.0) * 0.07).collect(); + let tbo = Tensor::new(dev.upload(&tb(&block_out)).unwrap(), vec![H], DType::F32); + let tpo = Tensor::new(dev.upload(&tb(&post)).unwrap(), vec![NHC], DType::F32); + let tco = Tensor::new(dev.upload(&tb(&comb)).unwrap(), vec![NHC * NHC], DType::F32); + let tr = Tensor::new(dev.upload(&tb(&resid)).unwrap(), vec![NHC, H], DType::F32); + let st = dsv4_mhc_expand(dev.as_ref(), &tbo, &tpo, &tco, &tr, H as u32, NHC as u32).unwrap(); + dev.synchronize().unwrap(); + let mut sb = vec![0u8; NHC * H * 4]; + dev.download(st.buffer.as_ref(), &mut sb).unwrap(); + let gs = fb(&sb); + let mut e3 = 0.0f32; + for dst in 0..NHC { + for d in 0..H { + let mut want = block_out[d] * post[dst]; + for src in 0..NHC { + want += comb[dst * NHC + src] * resid[src * H + d]; + } + e3 = e3.max((gs[dst * H + d] - want).abs()); + } + } + assert!(e3 <= 1e-4, "expand mismatch: {e3:.3e}"); + eprintln!("✅ DSv4 mHC expand on Metal: max|Δ|={e3:.1e}"); +} diff --git a/rust/crates/backends/ffai-metal/tests/gemv_q8.rs b/rust/crates/backends/ffai-metal/tests/gemv_q8.rs new file mode 100644 index 00000000..651e8813 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/gemv_q8.rs @@ -0,0 +1,135 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Q8_0 resident-weight matvec (`ffai_ops::gemv_q8`) vs a CPU dequant-dot +//! reference. Same kernel IR codegens to CUDA — this is the cheap local proof +//! of the quantized-GEMV path that the resident NemotronH decode loop rides on. +use ffai_core::{DType, Tensor}; +use ffai_ops::{gemv_q8, quantize_q8}; +use ffai_metal::MetalDevice; + +fn tb_f32(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn tb_u32(v: &[u32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn gemv_q8_matches_cpu_dequant_dot() { + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + let (m, k) = (128usize, 256usize); + let mut s = 0x1234_5678u32; + let mut rng = || { s ^= s << 13; s ^= s >> 17; s ^= s << 5; (s as f32 / u32::MAX as f32) - 0.5 }; + let w: Vec = (0..m * k).map(|_| rng()).collect(); + let x: Vec = (0..k).map(|_| rng()).collect(); + + let (qs, scales) = quantize_q8(&w, m, k); + + // CPU reference: the SAME dequant the kernel does (int8·scale), dotted with x. + let bpr = k / 32; + let mut want = vec![0f32; m]; + for r in 0..m { + let mut acc = 0f32; + for b in 0..bpr { + let dscale = scales[r * bpr + b]; + for i in 0..32 { + let packed = qs[r * bpr * 8 + b * 8 + i / 4]; + let by = ((packed >> ((i % 4) * 8)) & 0xff) as u8; + acc += dscale * (by as i8 as f32) * x[b * 32 + i]; + } + } + want[r] = acc; + } + + let qs_t = Tensor::new(d.upload(&tb_u32(&qs)).unwrap(), vec![qs.len()], DType::U32); + let sc_t = Tensor::new(d.upload(&tb_f32(&scales)).unwrap(), vec![scales.len()], DType::F32); + let x_t = Tensor::new(d.upload(&tb_f32(&x)).unwrap(), vec![k], DType::F32); + let out_t = gemv_q8(d, &qs_t, &sc_t, &x_t, m, k, m).unwrap(); // rows_per_group=m ⇒ dense + let mut ob = vec![0u8; m * 4]; + d.download(out_t.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + + let maxerr = (0..m).fold(0f32, |a, r| a.max((got[r] - want[r]).abs())); + eprintln!("gemv_q8 max abs err vs CPU dequant dot = {maxerr:.6}"); + assert!(maxerr < 1e-3, "gemv_q8 mismatch: {maxerr}"); + eprintln!("✅ gemv_q8 (Q8_0 resident matvec) matches CPU dequant dot"); +} + +#[test] +fn relu2_matches_host() { + let Some(dev) = MetalDevice::create().expect("metal") else { return; }; + let d = dev.as_ref(); + let x: Vec = (-16..16).map(|i| i as f32 * 0.3).collect(); + let xt = Tensor::new(d.upload(&tb_f32(&x)).unwrap(), vec![x.len()], DType::F32); + let ot = ffai_ops::relu2(d, &xt).unwrap(); + let mut ob = vec![0u8; x.len() * 4]; + d.download(ot.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + let want: Vec = x.iter().map(|&v| { let r = v.max(0.0); r * r }).collect(); + let e = (0..x.len()).fold(0f32, |a, i| a.max((got[i] - want[i]).abs())); + eprintln!("relu2 max err = {e:.6}"); + assert!(e < 1e-5, "relu2 mismatch {e}"); +} + +#[test] +fn fma_inplace_matches_host() { + let Some(dev) = MetalDevice::create().expect("metal") else { return; }; + let d = dev.as_ref(); + let n = 100usize; + let acc0: Vec = (0..n).map(|i| i as f32 * 0.1).collect(); + let x: Vec = (0..n).map(|i| (i as f32).sin()).collect(); + let s: Vec = vec![2.5; n]; + let acc = Tensor::new(d.upload(&tb_f32(&acc0)).unwrap(), vec![n], DType::F32); + let xt = Tensor::new(d.upload(&tb_f32(&x)).unwrap(), vec![n], DType::F32); + let st = Tensor::new(d.upload(&tb_f32(&s)).unwrap(), vec![n], DType::F32); + ffai_ops::fma_inplace(d, &acc, &xt, &st).unwrap(); + let mut ob = vec![0u8; n * 4]; d.download(acc.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + let e = (0..n).fold(0f32, |a, i| a.max((got[i] - (acc0[i] + x[i] * s[i])).abs())); + eprintln!("fma_inplace max err = {e:.6}"); + assert!(e < 1e-5, "fma mismatch {e}"); +} + +#[test] +fn gemv_q4_matches_cpu_dequant() { + let Some(dev) = MetalDevice::create().expect("metal") else { return; }; + let d = dev.as_ref(); + let (m, k) = (96usize, 256usize); + let mut s = 0xBEEFu32; + let mut rng = || { s ^= s << 13; s ^= s >> 17; s ^= s << 5; (s as f32 / u32::MAX as f32) - 0.5 }; + let w: Vec = (0..m * k).map(|_| rng()).collect(); + let x: Vec = (0..k).map(|_| rng()).collect(); + let (qs, sc) = ffai_ops::quantize_q4(&w, m, k); + let bpr = k / 32; + let mut want = vec![0f32; m]; + for r in 0..m { let mut a = 0f32; + for b in 0..bpr { let dd = sc[r*bpr+b]; + for i in 0..32 { let word = qs[r*bpr*4 + b*4 + i/8]; let nib = (word >> ((i%8)*4)) & 0xf; + let q = nib as i32 - if nib > 7 { 16 } else { 0 }; a += dd * q as f32 * x[b*32+i]; } } + want[r] = a; } + let qt = Tensor::new(d.upload(&tb_u32(&qs)).unwrap(), vec![qs.len()], DType::U32); + let st = Tensor::new(d.upload(&tb_f32(&sc)).unwrap(), vec![sc.len()], DType::F32); + let xt = Tensor::new(d.upload(&tb_f32(&x)).unwrap(), vec![k], DType::F32); + let ot = ffai_ops::gemv_q4(d, &qt, &st, &xt, m, k, m).unwrap(); + let mut ob = vec![0u8; m*4]; d.download(ot.buffer.as_ref(), &mut ob).unwrap(); + let got = fb(&ob); + let e = (0..m).fold(0f32, |a, r| a.max((got[r]-want[r]).abs())); + eprintln!("gemv_q4 max err = {e:.6}"); + assert!(e < 1e-3, "gemv_q4 mismatch {e}"); +} + +#[test] +fn slice_and_conv_roll() { + let Some(dev) = MetalDevice::create().expect("metal") else { return; }; + let d = dev.as_ref(); + let src: Vec = (0..100).map(|i| i as f32).collect(); + let st = Tensor::new(d.upload(&tb_f32(&src)).unwrap(), vec![100], DType::F32); + let sl = ffai_ops::slice(d, &st, 10, 5).unwrap(); + let mut ob = vec![0u8; 5*4]; d.download(sl.buffer.as_ref(), &mut ob).unwrap(); + assert_eq!(fb(&ob), vec![10.,11.,12.,13.,14.], "slice"); + // conv_roll: conv_dim=2, kc=3 → state=4 (2 blocks), keep=2; new=[old[2..4], xbc[0..2]] + let old = Tensor::new(d.upload(&tb_f32(&[1.,2.,3.,4.])).unwrap(), vec![4], DType::F32); + let xbc = Tensor::new(d.upload(&tb_f32(&[9.,8.])).unwrap(), vec![2], DType::F32); + let nr = ffai_ops::conv_roll(d, &old, &xbc, 2, 3).unwrap(); + let mut ob = vec![0u8; 4*4]; d.download(nr.buffer.as_ref(), &mut ob).unwrap(); + assert_eq!(fb(&ob), vec![3.,4.,9.,8.], "conv_roll"); + eprintln!("✅ slice + conv_roll correct"); +} diff --git a/rust/crates/backends/ffai-metal/tests/gguf_regression_metal.rs b/rust/crates/backends/ffai-metal/tests/gguf_regression_metal.rs new file mode 100644 index 00000000..9a37b294 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/gguf_regression_metal.rs @@ -0,0 +1,264 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GGUF regression GATE — the single runnable check that locks in the M5 GGUF +//! lane. For every supported model it loads the GGUF end-to-end and generates a +//! completion of "The capital of France is" through BOTH inference paths: +//! +//! * f32 — `GgufModel::open` (dequant-to-upload, the reference) +//! * resident-Q8 — `GgufModel::open_q8` (weights kept Q8, `gemv_q8` decode — +//! 20-57× faster, bit-identical) +//! +//! and asserts, per model: +//! 1. COHERENCE — both continuations contain "Paris". +//! 2. AGREEMENT — the f32 and Q8 greedy token streams are IDENTICAL over the +//! shared decode budget (resident-Q8 is lossless here). +//! +//! Models covered (the production GGUF matrix): +//! * Qwen2.5-1.5B-Instruct Q8_0 (single file, BPE tokenizer) +//! * Qwen2.5-7B-Instruct Q4_K_M (2-part split, k-quant super-blocks) +//! * Phi-3.1-mini-128k Q4_K_M (FUSED qkv/ffn tensors, SPM tokenizer) +//! +//! This is the regression gate: future changes that silently break GGUF load, +//! coherence, or f32↔Q8 agreement fail HERE. It is backend-agnostic by +//! construction (host loader + registry ops), so the same path runs on +//! CUDA/Vulkan; the gate runs on macOS + Metal and SKIPS (passes) cleanly if no +//! Metal device or the model file is absent. +//! +//! Runtime note: the f32 path on the 7B Q4_K_M is the slow dequant-to-upload +//! path (~13 s/token), so the f32 baseline uses a SHORT shared budget while the +//! Q8 path — the recommended decode path — runs the full continuation. The +//! f32↔Q8 agreement assert compares only the shared (short) prefix. + +use ffai_loader::gguf::Gguf; +use ffai_metal::MetalDevice; +use ffai_models::gguf_tokenizer::GgufTokenizer; +use ffai_models::llama::GgufModel; +use ffai_runtime::{generate, Sampling, StopOn}; +use std::time::Instant; + +/// One row of the GGUF regression matrix. +struct ModelCase { + /// Human label for log lines / assert messages. + label: &'static str, + /// Env var that overrides the model path (CI / alternate locations). + env: &'static str, + /// Default local path (part-1 of a split is fine — the loader opens the rest). + default_path: &'static str, + /// Shared f32↔Q8 agreement budget. The f32 path runs this many decode + /// steps; Q8 runs `full_budget` but agreement compares only this prefix. + /// Keep small for models whose f32 path is the slow dequant-to-upload path. + agree_budget: usize, + /// Q8 decode budget (the recommended path; cheap, so run a real continuation). + full_budget: usize, +} + +const CASES: &[ModelCase] = &[ + ModelCase { + label: "Qwen2.5-1.5B-Q8_0", + env: "QWEN25_GGUF", + default_path: "/Users/tom/models/qwen2.5-1.5b-instruct-q8_0.gguf", + agree_budget: 12, + full_budget: 12, + }, + ModelCase { + label: "Qwen2.5-7B-Q4_K_M", + env: "QWEN25_7B_GGUF", + default_path: "/Users/tom/models/qwen2.5-7b-instruct-q4_k_m-00001-of-00002.gguf", + // f32 on the 7B is ~13 s/token (dequant-to-upload); keep the shared + // agreement budget tiny so the gate stays fast. + agree_budget: 4, + full_budget: 16, + }, + ModelCase { + label: "Phi-3.1-mini-Q4_K_M", + env: "PHI3_GGUF", + default_path: "/Users/tom/models/Phi-3.1-mini-128k-instruct-Q4_K_M_2.gguf", + agree_budget: 12, + full_budget: 16, + }, +]; + +const PROMPT: &str = "The capital of France is"; +const CAP: usize = 256; + +/// Pick the EOS id robustly across tokenizer flavors (Qwen BPE vs Phi-3 SPM). +fn eos_id(tok: &GgufTokenizer) -> Option { + tok.token_id("<|endoftext|>") + .or_else(|| tok.token_id("<|end|>")) + .or_else(|| tok.token_id("")) +} + +/// Run `model` greedily for `budget` decode steps and return (tokens, text, decode_tok/s). +fn run( + dev: &dyn ffai_core::Device, + model: &GgufModel, + tok: &GgufTokenizer, + prompt: &[u32], + eos: Option, + budget: usize, +) -> (Vec, String, f32) { + let stop = StopOn { max_new: budget, eos }; + // Warm the JIT so the first kernel compile doesn't pollute tok/s. + let _ = model.step(dev, prompt[0], 0).expect("warmup step"); + let t = Instant::now(); + let mut decode_steps = 0usize; + let out = generate(prompt, &stop, &Sampling::Greedy, 0, |token, pos| { + if pos + 1 > prompt.len() { + decode_steps += 1; + } + model.step(dev, token, pos).expect("decode step") + }); + let tps = decode_steps as f32 / t.elapsed().as_secs_f32().max(1e-6); + let text = tok.decode(&out); + (out, text, tps) +} + +/// THE GATE. One test, the whole GGUF matrix × {f32, resident-Q8}. +#[test] +fn gguf_regression_gate_all_models_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping GGUF regression gate"); + return; + }; + let d = dev.as_ref(); + eprintln!("═══ GGUF REGRESSION GATE — device: {} ═══", dev.name()); + + let mut ran = 0usize; + let mut failures: Vec = Vec::new(); + + for case in CASES { + let path = std::env::var(case.env).unwrap_or_else(|_| case.default_path.to_string()); + if !std::path::Path::new(&path).exists() { + eprintln!("── {}: model not found at {path} — SKIP", case.label); + continue; + } + ran += 1; + eprintln!("\n── {} ──────────────────────────────", case.label); + + let g = Gguf::open(&path).expect("open gguf"); + let tok = GgufTokenizer::from_gguf(&g).expect("tokenizer"); + let prompt = tok.encode(PROMPT); + assert!(!prompt.is_empty(), "{}: tokenizer produced no ids", case.label); + let eos = eos_id(&tok); + eprintln!( + " arch={:?} vocab={} prompt_ids={:?}", + g.meta_str("general.architecture"), + tok.vocab_size(), + prompt + ); + + // ── f32 reference path (shared agreement budget) ────────────────── + let m_f32 = GgufModel::open(d, &path, CAP).expect("open f32"); + let (out_f32, text_f32, tps_f32) = + run(d, &m_f32, &tok, &prompt, eos, case.agree_budget); + drop(m_f32); // free f32 weights before loading Q8 (peak-mem hygiene) + eprintln!(" f32 : {tps_f32:6.2} tok/s {text_f32:?}"); + + // ── resident-Q8 path (recommended; full continuation) ───────────── + let m_q8 = GgufModel::open_q8(d, &path, CAP).expect("open_q8"); + let (out_q8, text_q8, tps_q8) = run(d, &m_q8, &tok, &prompt, eos, case.full_budget); + eprintln!(" Q8 : {tps_q8:6.2} tok/s {text_q8:?}"); + eprintln!(" Q8 speedup vs f32: {:.1}×", tps_q8 / tps_f32.max(1e-6)); + + // ── ASSERT 1: coherence on BOTH paths ───────────────────────────── + if !text_f32.contains("Paris") { + failures.push(format!("{}: f32 lost coherence — {text_f32:?}", case.label)); + } + if !text_q8.contains("Paris") { + failures.push(format!("{}: Q8 lost coherence — {text_q8:?}", case.label)); + } + + // ── ASSERT 2: f32↔Q8 token agreement over the shared prefix ─────── + let n = case.agree_budget.min(out_f32.len()).min(out_q8.len()); + if out_f32[..n] != out_q8[..n] { + failures.push(format!( + "{}: f32↔Q8 token disagreement over {n} steps\n f32={:?}\n q8 ={:?}", + case.label, + &out_f32[..n], + &out_q8[..n], + )); + } else { + eprintln!(" ✅ coherent (f32 & Q8) + f32↔Q8 token streams identical ({n} steps)"); + } + } + + if ran == 0 { + eprintln!("\n⚠️ no GGUF models present — gate vacuously passed (set *_GGUF env vars)"); + return; + } + assert!( + failures.is_empty(), + "GGUF regression gate FAILED ({} model(s) checked):\n - {}", + ran, + failures.join("\n - "), + ); + eprintln!("\n═══ ✅ GGUF REGRESSION GATE PASSED — {ran} model(s) × {{f32, resident-Q8}} ═══"); +} + +/// The recommended entrypoint [`GgufModel::load`] (prefers resident-Q8, falls +/// back to f32) must load + generate coherently. Quick smoke on the smallest +/// model so the default GGUF inference path is itself regression-covered. +#[test] +fn gguf_recommended_load_entrypoint_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let path = std::env::var("QWEN25_GGUF") + .unwrap_or_else(|_| "/Users/tom/models/qwen2.5-1.5b-instruct-q8_0.gguf".to_string()); + if !std::path::Path::new(&path).exists() { + eprintln!("model not found at {path} — skipping"); + return; + } + let g = Gguf::open(&path).expect("open gguf"); + let tok = GgufTokenizer::from_gguf(&g).expect("tokenizer"); + let prompt = tok.encode(PROMPT); + let eos = eos_id(&tok); + + // `load` should pick the resident-Q8 path here (Q8_0 repacks cleanly). + let model = GgufModel::load(d, &path, CAP).expect("recommended load"); + let (_, text, tps) = run(d, &model, &tok, &prompt, eos, 12); + eprintln!("GgufModel::load → {tps:.2} tok/s {text:?}"); + assert!(text.contains("Paris"), "recommended load lost coherence: {text:?}"); + eprintln!("✅ GgufModel::load (recommended resident-Q8 entrypoint) coherent."); +} + +/// QUANT-COVERAGE AUDIT. For every supported model, enumerate the distinct ggml +/// quant types present in its 2-D matmul tensors and spot-check that +/// `Gguf::q8_repack` (the resident-Q8 repack used by `open_q8`) succeeds on one +/// tensor of EACH type. This proves the resident-Q8 path covers every +/// quant×model combo we ship (Q8_0, Q4_K, Q5_K, Q6_K) — no Metal device needed. +#[test] +fn gguf_resident_q8_quant_coverage_audit() { + use std::collections::BTreeMap; + let mut any = false; + for case in CASES { + let path = std::env::var(case.env).unwrap_or_else(|_| case.default_path.to_string()); + if !std::path::Path::new(&path).exists() { + eprintln!("── {}: not found — SKIP", case.label); + continue; + } + any = true; + let g = Gguf::open(&path).expect("open gguf"); + // One representative 2-D tensor name per distinct quant type. + let mut rep: BTreeMap = BTreeMap::new(); + for name in g.tensor_names() { + let t = g.tensor(name).unwrap(); + if t.dims.len() == 2 { + rep.entry(format!("{:?}", t.ggml_type)).or_insert_with(|| name.clone()); + } + } + eprintln!("── {} — 2-D quant types: {:?}", case.label, rep.keys().collect::>()); + for (ty, name) in &rep { + match g.q8_repack(name) { + Ok((_, _, m, k)) => eprintln!(" ✅ {ty:<10} q8_repack ok ({name}, [{m}×{k}])"), + Err(e) => panic!(" ❌ {ty} q8_repack FAILED on {name}: {e}"), + } + } + } + if !any { + eprintln!("⚠️ no models present — audit vacuously passed"); + } + eprintln!("✅ resident-Q8 quant-coverage audit passed (all present quant types repack)."); +} diff --git a/rust/crates/backends/ffai-metal/tests/gpt2_generate.rs b/rust/crates/backends/ffai-metal/tests/gpt2_generate.rs new file mode 100644 index 00000000..0d130987 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/gpt2_generate.rs @@ -0,0 +1,101 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GPT-2 autoregressive **greedy decode** (prefill + 30-token generation loop) +//! verified vs HF `generate(do_sample=False)`. Proves the full generate path — +//! prefill → argmax → append → re-forward — and **coherence**: the engine must +//! emit the exact token sequence HF does. Prints the generated ids so they can +//! be detokenized to text. (Each step re-forwards the growing sequence — an +//! incremental KV cache is a runtime optimization, not needed for correctness.) +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gelu, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn gpt2_greedy_generate_vs_hf() { + let dir = std::env::var("GPT2_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + + let (hid, nh, hd, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], b: &[f32], rows: usize, cols: usize| { for r in 0..rows { for c in 0..cols { m[r * cols + c] += b[c]; } } }; + let conv_t = |w: &[f32], nin: usize, nout: usize| -> Vec { let mut o = vec![0.0f32; nin * nout]; for i in 0..nin { for j in 0..nout { o[j * nin + i] = w[i * nout + j]; } } o }; + let reorg = |m: &[f32], n: usize| -> Vec { let mut o = vec![0.0f32; nh * n * hd]; for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * hid + h * hd + dd]; } } } o }; + + // preload weights once + let wte = g("wte.weight"); let wpe = g("wpe.weight"); + let lw: Vec<_> = (0..n_layers).map(|l| { + let p = format!("h.{l}"); + (g(&format!("{p}.ln_1.weight")), g(&format!("{p}.ln_1.bias")), + conv_t(&g(&format!("{p}.attn.c_attn.weight")), hid, 3 * hid), g(&format!("{p}.attn.c_attn.bias")), + conv_t(&g(&format!("{p}.attn.c_proj.weight")), hid, hid), g(&format!("{p}.attn.c_proj.bias")), + g(&format!("{p}.ln_2.weight")), g(&format!("{p}.ln_2.bias")), + conv_t(&g(&format!("{p}.mlp.c_fc.weight")), hid, 4 * hid), g(&format!("{p}.mlp.c_fc.bias")), + conv_t(&g(&format!("{p}.mlp.c_proj.weight")), 4 * hid, hid), g(&format!("{p}.mlp.c_proj.bias"))) + }).collect(); + let lnf_w = g("ln_f.weight"); let lnf_b = g("ln_f.bias"); + + // one full forward over `ids`, returns argmax of last position + let forward = |ids: &[usize]| -> usize { + let seq = ids.len(); + let mut x = vec![0.0f32; seq * hid]; + for (i, &tok) in ids.iter().enumerate() { for e in 0..hid { x[i * hid + e] = wte[tok * hid + e] + wpe[i * hid + e]; } } + for w in &lw { + let h = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&w.0, vec![hid]), &up(&w.1, vec![hid]), eps).unwrap(), seq * hid); + let mut qkv = dl(&matmul(d, &up(&w.2, vec![3 * hid, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * 3 * hid); + add_bias(&mut qkv, &w.3, seq, 3 * hid); + let (mut q, mut k, mut v) = (vec![0.0f32; seq * hid], vec![0.0f32; seq * hid], vec![0.0f32; seq * hid]); + for t in 0..seq { + q[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid..t * 3 * hid + hid]); + k[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + hid..t * 3 * hid + 2 * hid]); + v[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + 2 * hid..t * 3 * hid + 3 * hid]); + } + let kt = up(&reorg(&k, seq), vec![nh, seq, hd]); let vt = up(&reorg(&v, seq), vec![nh, seq, hd]); + let mut attn = vec![0.0f32; seq * hid]; + for i in 0..seq { + let a = sdpa_decode(d, &up(&q[i * hid..(i + 1) * hid], vec![nh, hd]), &kt, &vt, hd, (i + 1) as u32, seq as u32, 1, scale).unwrap(); + attn[i * hid..(i + 1) * hid].copy_from_slice(&dl(&a, hid)); + } + let mut o = dl(&matmul(d, &up(&w.4, vec![hid, hid]), &up(&attn, vec![seq, hid])).unwrap(), seq * hid); + add_bias(&mut o, &w.5, seq, hid); + for i in 0..seq * hid { x[i] += o[i]; } + let h2 = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&w.6, vec![hid]), &up(&w.7, vec![hid]), eps).unwrap(), seq * hid); + let mut f = dl(&matmul(d, &up(&w.8, vec![4 * hid, hid]), &up(&h2, vec![seq, hid])).unwrap(), seq * 4 * hid); + add_bias(&mut f, &w.9, seq, 4 * hid); + let act = dl(&gelu(d, &up(&f, vec![seq * 4 * hid])).unwrap(), seq * 4 * hid); + let mut m = dl(&matmul(d, &up(&w.10, vec![hid, 4 * hid]), &up(&act, vec![seq, 4 * hid])).unwrap(), seq * hid); + add_bias(&mut m, &w.11, seq, hid); + for i in 0..seq * hid { x[i] += m[i]; } + } + let xf = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&lnf_w, vec![hid]), &up(&lnf_b, vec![hid]), eps).unwrap(), seq * hid); + let last = &xf[(seq - 1) * hid..seq * hid]; + let logits = dl(&matmul(d, &up(&wte, vec![vocab, hid]), &up(last, vec![1, hid])).unwrap(), vocab); + (0..vocab).max_by(|&a, &b| logits[a].total_cmp(&logits[b])).unwrap() + }; + + // greedy decode loop + let mut ids: Vec = vec![464, 3139, 286, 4881, 318]; // "The capital of France is" + let prompt_len = ids.len(); + for _ in 0..30 { let nxt = forward(&ids); ids.push(nxt); } + let outv = &ids[prompt_len..]; + eprintln!("GPT-2 greedy gen on Metal: {outv:?}"); + + let hf = [262usize, 3139, 286, 262, 4141, 2066, 11, 290, 262, 3139, 286, 262, 4141, 2066, 318, 262, 3139, 286, 262, 4141, 2066, 13, 198, 198, 464, 4141, 2066, 318, 262, 3139]; + assert_eq!(outv, &hf, "GPT-2 greedy generation diverges from HF"); + eprintln!("✅ GPT-2 greedy decode (30 tokens) matches HF generate() exactly — prefill + decode + coherence on the shared engine."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--gpt2/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/tests/gpt2_kvcache.rs b/rust/crates/backends/ffai-metal/tests/gpt2_kvcache.rs new file mode 100644 index 00000000..b6d6c5f9 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/gpt2_kvcache.rs @@ -0,0 +1,112 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GPT-2 generation with a real **incremental KV cache** + **device-resident +//! weights** + throughput numbers. Weights are uploaded to the device ONCE; +//! prefill fills a per-layer K/V cache; each decode step forwards only the new +//! token and attends over the cache (n_kv = current length) — O(seq)/step, not +//! O(seq²). Only tiny per-step activations + the growing cache cross the bus. +//! Asserts generated tokens still match HF and prints prefill/decode tok/s. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{add, gelu, gemv, layer_norm, matmul, sdpa_decode}; +use std::time::Instant; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +struct LW { ln1w: Tensor, ln1b: Tensor, cattn: Tensor, cattn_b: Tensor, cproj: Tensor, cproj_b: Tensor, + ln2w: Tensor, ln2b: Tensor, fc: Tensor, fc_b: Tensor, proj: Tensor, proj_b: Tensor } + +#[test] +fn gpt2_kvcache_decode_throughput() { + let dir = std::env::var("GPT2_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + let plat = "Metal"; + + let (hid, nh, hd, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let conv_t = |w: &[f32], nin: usize, nout: usize| -> Vec { let mut o = vec![0.0f32; nin * nout]; for i in 0..nin { for j in 0..nout { o[j * nin + i] = w[i * nout + j]; } } o }; + let reorg = |m: &[f32], n: usize| -> Vec { let mut o = vec![0.0f32; nh * n * hd]; for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * hid + h * hd + dd]; } } } o }; + + // ── upload all weights to the device ONCE (resident) ── + let t_load = Instant::now(); + let wte = g("wte.weight"); let wpe = g("wpe.weight"); + let wte_t = up(&wte, vec![vocab, hid]); // lm_head (tied) + let lwt: Vec = (0..n_layers).map(|l| { let p = format!("h.{l}"); LW { + ln1w: up(&g(&format!("{p}.ln_1.weight")), vec![hid]), ln1b: up(&g(&format!("{p}.ln_1.bias")), vec![hid]), + cattn: up(&conv_t(&g(&format!("{p}.attn.c_attn.weight")), hid, 3*hid), vec![3*hid, hid]), cattn_b: up(&g(&format!("{p}.attn.c_attn.bias")), vec![3*hid]), + cproj: up(&conv_t(&g(&format!("{p}.attn.c_proj.weight")), hid, hid), vec![hid, hid]), cproj_b: up(&g(&format!("{p}.attn.c_proj.bias")), vec![hid]), + ln2w: up(&g(&format!("{p}.ln_2.weight")), vec![hid]), ln2b: up(&g(&format!("{p}.ln_2.bias")), vec![hid]), + fc: up(&conv_t(&g(&format!("{p}.mlp.c_fc.weight")), hid, 4*hid), vec![4*hid, hid]), fc_b: up(&g(&format!("{p}.mlp.c_fc.bias")), vec![4*hid]), + proj: up(&conv_t(&g(&format!("{p}.mlp.c_proj.weight")), 4*hid, hid), vec![hid, 4*hid]), proj_b: up(&g(&format!("{p}.mlp.c_proj.bias")), vec![hid]), + }}).collect(); + let lnf_w = up(&g("ln_f.weight"), vec![hid]); let lnf_b = up(&g("ln_f.bias"), vec![hid]); + let load_s = t_load.elapsed().as_secs_f64(); + + let mut kc: Vec> = vec![Vec::new(); n_layers]; + let mut vc: Vec> = vec![Vec::new(); n_layers]; + let argmax = |logits: &[f32]| (0..vocab).max_by(|&a, &b| logits[a].total_cmp(&logits[b])).unwrap(); + + // forward ONE token at `pos`, extending the cache; resident weights, returns next argmax + let step = |tok: usize, pos: usize, kc: &mut Vec>, vc: &mut Vec>| -> usize { + let x0: Vec = (0..hid).map(|e| wte[tok*hid+e] + wpe[pos*hid+e]).collect(); + let mut xt = up(&x0, vec![hid]); // residual stream stays device-resident + for (l, w) in lwt.iter().enumerate() { + let h = layer_norm(d, &xt, &w.ln1w, &w.ln1b, eps).unwrap(); + let qkv_t = add(d, &gemv(d, &w.cattn, &h).unwrap(), &w.cattn_b).unwrap(); + let qkv = dl(&qkv_t, 3*hid); // the one unavoidable download — feeds the host KV cache + kc[l].extend_from_slice(&qkv[hid..2*hid]); vc[l].extend_from_slice(&qkv[2*hid..3*hid]); + let len = kc[l].len() / hid; + let kt = up(&reorg(&kc[l], len), vec![nh, len, hd]); + let vt = up(&reorg(&vc[l], len), vec![nh, len, hd]); + let a = sdpa_decode(d, &up(&qkv[0..hid], vec![nh, hd]), &kt, &vt, hd, len as u32, len as u32, 1, scale).unwrap(); + let o = add(d, &gemv(d, &w.cproj, &a.reshaped(vec![hid])).unwrap(), &w.cproj_b).unwrap(); + xt = add(d, &xt, &o).unwrap(); // residual on-device (no round-trip) + let h2 = layer_norm(d, &xt, &w.ln2w, &w.ln2b, eps).unwrap(); + let f = add(d, &gemv(d, &w.fc, &h2).unwrap(), &w.fc_b).unwrap(); + let act = gelu(d, &f).unwrap(); + let m = add(d, &gemv(d, &w.proj, &act).unwrap(), &w.proj_b).unwrap(); + xt = add(d, &xt, &m).unwrap(); // residual on-device + } + let xf = layer_norm(d, &xt, &lnf_w, &lnf_b, eps).unwrap(); + let logits = dl(&matmul(d, &wte_t, &xf.reshaped(vec![1, hid])).unwrap(), vocab); + argmax(&logits) + }; + + // warmup: first dispatch of each kernel JIT-compiles (MSL) — exclude from timings + let t_warm = Instant::now(); + { let mut wk = vec![Vec::new(); n_layers]; let mut wv = vec![Vec::new(); n_layers]; step(464, 0, &mut wk, &mut wv); } + let warm_s = t_warm.elapsed().as_secs_f64(); + + let prompt: Vec = vec![464, 3139, 286, 4881, 318]; // "The capital of France is" + let t0 = Instant::now(); + let mut next = 0usize; + for (pos, &tok) in prompt.iter().enumerate() { next = step(tok, pos, &mut kc, &mut vc); } + let prefill_s = t0.elapsed().as_secs_f64(); + + let mut outv = vec![next]; + let t1 = Instant::now(); + let mut pos = prompt.len(); + for _ in 0..29 { let tok = *outv.last().unwrap(); let nxt = step(tok, pos, &mut kc, &mut vc); outv.push(nxt); pos += 1; } + let decode_s = t1.elapsed().as_secs_f64(); + + let hf = [262usize, 3139, 286, 262, 4141, 2066, 11, 290, 262, 3139, 286, 262, 4141, 2066, 318, 262, 3139, 286, 262, 4141, 2066, 13, 198, 198, 464, 4141, 2066, 318, 262, 3139]; + eprintln!("GPT-2 KV-cache decode on {plat} (resident weights):"); + eprintln!(" weight upload (1x): {load_s:.3}s kernel JIT warmup (1x): {warm_s:.3}s"); + eprintln!(" prefill {} tok in {:.3}s = {:.1} tok/s", prompt.len(), prefill_s, prompt.len() as f64 / prefill_s); + eprintln!(" decode {} tok in {:.3}s = {:.1} tok/s ({:.1} ms/tok)", outv.len(), decode_s, outv.len() as f64 / decode_s, decode_s * 1000.0 / outv.len() as f64); + assert_eq!(&outv[..], &hf, "KV-cache generation diverges from HF"); + eprintln!("✅ Incremental KV-cache decode (resident weights) matches HF generate() exactly on {plat}."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--gpt2/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/tests/gpt2_prefill.rs b/rust/crates/backends/ffai-metal/tests/gpt2_prefill.rs new file mode 100644 index 00000000..44cdc85a --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/gpt2_prefill.rs @@ -0,0 +1,105 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GPT-2 multi-token **causal prefill** verified vs HF — the one remaining +//! primitive (all other model tests are single-token at pos 0). Processes an +//! 8-token prompt through the full stack; each position attends causally over +//! [0, i] (via `sdpa_decode` with n_kv = i+1 against the per-head K/V cache, +//! kv_stride = seq_len). Proves causal masking + the prefill path on the shared +//! op layer. (GPT-2 uses learned position embeddings, isolating causal masking +//! from RoPE.) argmax of the last position vs HF. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gelu, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn gpt2_causal_prefill_vs_hf() { + let dir = std::env::var("GPT2_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + + let (hid, nh, hd, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + let ids = [464usize, 2068, 7586, 21831, 11687, 625, 262, 16931]; + let seq = ids.len(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], b: &[f32], rows: usize, cols: usize| { for r in 0..rows { for c in 0..cols { m[r * cols + c] += b[c]; } } }; + let conv_t = |w: &[f32], nin: usize, nout: usize| -> Vec { + let mut o = vec![0.0f32; nin * nout]; + for i in 0..nin { for j in 0..nout { o[j * nin + i] = w[i * nout + j]; } } o + }; + let reorg = |m: &[f32], n: usize| -> Vec { // [n,hid] → [nh,n,hd] + let mut o = vec![0.0f32; nh * n * hd]; + for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * hid + h * hd + dd]; } } } o + }; + + let wte = g("wte.weight"); let wpe = g("wpe.weight"); + // x[seq, hid] = token embed + learned position embed + let mut x = vec![0.0f32; seq * hid]; + for (i, &tok) in ids.iter().enumerate() { + for e in 0..hid { x[i * hid + e] = wte[tok * hid + e] + wpe[i * hid + e]; } + } + + for l in 0..n_layers { + let p = format!("h.{l}"); + // attention (pre-LN over all positions) + let h = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.ln_1.weight")), vec![hid]), &up(&g(&format!("{p}.ln_1.bias")), vec![hid]), eps).unwrap(), seq * hid); + let cattn_w = conv_t(&g(&format!("{p}.attn.c_attn.weight")), hid, 3 * hid); + let mut qkv = dl(&matmul(d, &up(&cattn_w, vec![3 * hid, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * 3 * hid); + add_bias(&mut qkv, &g(&format!("{p}.attn.c_attn.bias")), seq, 3 * hid); + // split q/k/v [seq,hid] + let mut q = vec![0.0f32; seq * hid]; let mut k = vec![0.0f32; seq * hid]; let mut v = vec![0.0f32; seq * hid]; + for t in 0..seq { + q[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid..t * 3 * hid + hid]); + k[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + hid..t * 3 * hid + 2 * hid]); + v[t * hid..(t + 1) * hid].copy_from_slice(&qkv[t * 3 * hid + 2 * hid..t * 3 * hid + 3 * hid]); + } + let kt = up(&reorg(&k, seq), vec![nh, seq, hd]); + let vt = up(&reorg(&v, seq), vec![nh, seq, hd]); + // causal: position i attends over [0, i] → n_kv = i+1, kv_stride = seq + let mut attn = vec![0.0f32; seq * hid]; + for i in 0..seq { + let qi = up(&q[i * hid..(i + 1) * hid], vec![nh, hd]); + let a = sdpa_decode(d, &qi, &kt, &vt, hd, (i + 1) as u32, seq as u32, 1, scale).unwrap(); + attn[i * hid..(i + 1) * hid].copy_from_slice(&dl(&a, hid)); + } + let cproj_w = conv_t(&g(&format!("{p}.attn.c_proj.weight")), hid, hid); + let mut o = dl(&matmul(d, &up(&cproj_w, vec![hid, hid]), &up(&attn, vec![seq, hid])).unwrap(), seq * hid); + add_bias(&mut o, &g(&format!("{p}.attn.c_proj.bias")), seq, hid); + for i in 0..seq * hid { x[i] += o[i]; } + + // MLP (pre-LN, gelu_new = tanh) + let h2 = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.ln_2.weight")), vec![hid]), &up(&g(&format!("{p}.ln_2.bias")), vec![hid]), eps).unwrap(), seq * hid); + let fc_w = conv_t(&g(&format!("{p}.mlp.c_fc.weight")), hid, 4 * hid); + let mut f = dl(&matmul(d, &up(&fc_w, vec![4 * hid, hid]), &up(&h2, vec![seq, hid])).unwrap(), seq * 4 * hid); + add_bias(&mut f, &g(&format!("{p}.mlp.c_fc.bias")), seq, 4 * hid); + let act = dl(&gelu(d, &up(&f, vec![seq * 4 * hid])).unwrap(), seq * 4 * hid); + let proj_w = conv_t(&g(&format!("{p}.mlp.c_proj.weight")), 4 * hid, hid); + let mut m = dl(&matmul(d, &up(&proj_w, vec![hid, 4 * hid]), &up(&act, vec![seq, 4 * hid])).unwrap(), seq * hid); + add_bias(&mut m, &g(&format!("{p}.mlp.c_proj.bias")), seq, hid); + for i in 0..seq * hid { x[i] += m[i]; } + } + + // final LN; logits at LAST position (tied lm_head) + let xf = dl(&layer_norm(d, &up(&x, vec![seq, hid]), &up(&g("ln_f.weight"), vec![hid]), &up(&g("ln_f.bias"), vec![hid]), eps).unwrap(), seq * hid); + let last = &xf[(seq - 1) * hid..seq * hid]; + let logits = dl(&matmul(d, &up(&wte, vec![vocab, hid]), &up(last, vec![1, hid])).unwrap(), vocab); + let mut idx: Vec = (0..vocab).collect(); + idx.sort_by(|&a, &b| logits[b].total_cmp(&logits[a])); + eprintln!("GPT-2 causal prefill (seq={seq}) on Metal: top3 = {:?} (HF = [11, 21831, 7586])", &idx[..3]); + assert_eq!(&idx[..3], &[11usize, 21831, 7586], "GPT-2 prefill top-3 != HF"); + eprintln!("✅ GPT-2 multi-token causal prefill matches HF on the shared engine (Apple GPU) — causal prefill path verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--gpt2/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/tests/hf_model.rs b/rust/crates/backends/ffai-metal/tests/hf_model.rs new file mode 100644 index 00000000..d7af0fe3 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/hf_model.rs @@ -0,0 +1,77 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Generic dense-LLM verification harness on Metal. Point MODEL_DIR at any +//! HF model dir (config.json + model.safetensors); load_hf derives the +//! config + arch flags, runs forward_single(TOK), and prints the argmax. +//! One code path for the whole Llama/Qwen/Mistral/Yi/Phi/SmolLM family. +//! +//! MODEL_DIR=/path/to/model TOK=9707 EXPECT=21806 \ +//! cargo test -p ffai-metal --test hf_model -- --nocapture + +use ffai_core::DType; +use ffai_metal::MetalDevice; +use ffai_models::llama::{forward_single, load_hf}; + +fn decode(b: &[u8], dt: DType) -> Vec { + match dt { + DType::F32 => b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(), + DType::BF16 => { + b.chunks_exact(2).map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect() + } + DType::F16 => b.chunks_exact(2).map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect(), + _ => vec![], + } +} +fn half_to_f32(h: u16) -> f32 { + let sign = ((h >> 15) & 1) as u32; + let exp = ((h >> 10) & 0x1f) as u32; + let mant = (h & 0x3ff) as u32; + let bits = if exp == 0 { + if mant == 0 { sign << 31 } else { + let mut e = -1i32; let mut m = mant; + while m & 0x400 == 0 { m <<= 1; e -= 1; } + (sign << 31) | (((e + 127 - 15) as u32) << 23) | ((m & 0x3ff) << 13) + } + } else if exp == 0x1f { + (sign << 31) | (0xff << 23) | (mant << 13) + } else { + (sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13) + }; + f32::from_bits(bits) +} + +#[test] +fn hf_model_forward_on_metal() { + let Some(dir) = std::env::var("MODEL_DIR").ok() else { + eprintln!("set MODEL_DIR — skipping"); + return; + }; + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + eprintln!("device: {} — load_hf {dir}", dev.name()); + let m = load_hf(dev.as_ref(), &dir).expect("load_hf"); + eprintln!( + "cfg: hidden={} heads={}/{} hd={} inter={} layers={} vocab={} qk_norm={} bias={}", + m.cfg.hidden, m.cfg.n_q_heads, m.cfg.n_kv_heads, m.cfg.head_dim, m.cfg.intermediate, + m.n_layers, m.vocab, m.cfg.qk_norm, m.cfg.attn_bias + ); + + let token: u32 = std::env::var("TOK").ok().and_then(|s| s.parse().ok()).unwrap_or(9707); + let logits = forward_single(dev.as_ref(), &m.cfg, &m.weights, token).expect("forward"); + dev.synchronize().unwrap(); + let dt = logits.dtype; + let mut lb = vec![0u8; m.vocab * dt.size_bytes()]; + dev.download(logits.buffer.as_ref(), &mut lb).unwrap(); + let l = decode(&lb, dt); + + let mut idx: Vec = (0..m.vocab).collect(); + idx.sort_by(|&a, &b| l[b].total_cmp(&l[a])); + eprintln!("token {token} → top-5 (Metal): {:?}", &idx[..5]); + eprintln!("ARGMAX (Metal) = {}", idx[0]); + if let Some(exp) = std::env::var("EXPECT").ok().and_then(|s| s.parse::().ok()) { + assert_eq!(idx[0], exp, "Metal argmax {} != expected {exp}", idx[0]); + eprintln!("✅ argmax matches expected {exp}"); + } +} diff --git a/rust/crates/backends/ffai-metal/tests/llama_prefill.rs b/rust/crates/backends/ffai-metal/tests/llama_prefill.rs new file mode 100644 index 00000000..b922c1a8 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/llama_prefill.rs @@ -0,0 +1,96 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Llama-family multi-token causal prefill WITH RoPE-at-position, verified vs +//! HF — completes the prefill primitive (GPT-2 prefill proved causal masking +//! with learned positions; this proves RoPE applied per real position + GQA + +//! SwiGLU in a causal prefill). Uses SmolVLM-256M's text model (a SmolLM2-style +//! Llama: hidden 576, GQA 9q/3kv, head_dim 64, rope θ=1e5, 30 layers), run +//! text-only. This is exactly the text-half prefill a full VLM forward runs +//! after splicing image embeds — every piece now on the shared op layer. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{matmul, rms_norm, rope_llama, sdpa_decode, swiglu}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn llama_causal_prefill_rope_vs_hf() { + let dir = std::env::var("SMOLVLM_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + + let (hid, nq, nkv, hd, inter, n_layers, vocab, eps, theta) = + (576usize, 9usize, 3usize, 64usize, 1536usize, 30usize, 49280usize, 1e-5f32, 100000.0f32); + let qd = nq * hd; let kvd = nkv * hd; // 576, 192 + let scale = 1.0 / (hd as f32).sqrt(); + let ids = [1usize, 2520, 1396, 253, 8137, 275, 253]; + let seq = ids.len(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + + let tp = "model.text_model"; + let embed = g(&format!("{tp}.embed_tokens.weight")); + let mut x = vec![0.0f32; seq * hid]; + for (i, &tok) in ids.iter().enumerate() { x[i * hid..(i + 1) * hid].copy_from_slice(&embed[tok * hid..(tok + 1) * hid]); } + + for l in 0..n_layers { + let p = format!("{tp}.layers.{l}"); + // attention (pre-RMSNorm) + let h = dl(&rms_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.input_layernorm.weight")), vec![hid]), eps).unwrap(), seq * hid); + let q = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![qd, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * qd); + let k = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![kvd, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * kvd); + let v = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![kvd, hid]), &up(&h, vec![seq, hid])).unwrap(), seq * kvd); + // RoPE at each real position + let mut qr = vec![0.0f32; seq * qd]; let mut kr = vec![0.0f32; seq * kvd]; + for t in 0..seq { + let qt = rope_llama(d, &up(&q[t * qd..(t + 1) * qd], vec![nq, hd]), t as u32, theta, 1.0, 1.0, 1.0, 1e9).unwrap(); + qr[t * qd..(t + 1) * qd].copy_from_slice(&dl(&qt, qd)); + let kt = rope_llama(d, &up(&k[t * kvd..(t + 1) * kvd], vec![nkv, hd]), t as u32, theta, 1.0, 1.0, 1.0, 1e9).unwrap(); + kr[t * kvd..(t + 1) * kvd].copy_from_slice(&dl(&kt, kvd)); + } + // K/V cache [nkv, seq, hd] + let mut kc = vec![0.0f32; nkv * seq * hd]; let mut vc = vec![0.0f32; nkv * seq * hd]; + for t in 0..seq { for h2 in 0..nkv { for dd in 0..hd { + kc[h2 * seq * hd + t * hd + dd] = kr[t * kvd + h2 * hd + dd]; + vc[h2 * seq * hd + t * hd + dd] = v[t * kvd + h2 * hd + dd]; + }}} + let kt = up(&kc, vec![nkv, seq, hd]); let vt = up(&vc, vec![nkv, seq, hd]); + // causal GQA: position i attends [0,i] + let mut attn = vec![0.0f32; seq * qd]; + for i in 0..seq { + let qi = up(&qr[i * qd..(i + 1) * qd], vec![nq, hd]); + let a = sdpa_decode(d, &qi, &kt, &vt, hd, (i + 1) as u32, seq as u32, (nq / nkv) as u32, scale).unwrap(); + attn[i * qd..(i + 1) * qd].copy_from_slice(&dl(&a, qd)); + } + let o = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.o_proj.weight")), vec![hid, qd]), &up(&attn, vec![seq, qd])).unwrap(), seq * hid); + for i in 0..seq * hid { x[i] += o[i]; } + + // SwiGLU MLP (pre-RMSNorm) + let h2 = dl(&rms_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{p}.post_attention_layernorm.weight")), vec![hid]), eps).unwrap(), seq * hid); + let gate = matmul(d, &up(&g(&format!("{p}.mlp.gate_proj.weight")), vec![inter, hid]), &up(&h2, vec![seq, hid])).unwrap(); + let upp = matmul(d, &up(&g(&format!("{p}.mlp.up_proj.weight")), vec![inter, hid]), &up(&h2, vec![seq, hid])).unwrap(); + let act = swiglu(d, &gate, &upp).unwrap(); + let down = dl(&matmul(d, &up(&g(&format!("{p}.mlp.down_proj.weight")), vec![hid, inter]), &act.reshaped(vec![seq, inter])).unwrap(), seq * hid); + for i in 0..seq * hid { x[i] += down[i]; } + } + + // final norm; logits at last position (untied lm_head) + let xf = dl(&rms_norm(d, &up(&x, vec![seq, hid]), &up(&g(&format!("{tp}.norm.weight")), vec![hid]), eps).unwrap(), seq * hid); + let last = &xf[(seq - 1) * hid..seq * hid]; + let logits = dl(&matmul(d, &up(&g("lm_head.weight"), vec![vocab, hid]), &up(last, vec![1, hid])).unwrap(), vocab); + let mut idx: Vec = (0..vocab).collect(); + idx.sort_by(|&a, &b| logits[b].total_cmp(&logits[a])); + eprintln!("Llama (SmolVLM-text) RoPE causal prefill (seq={seq}) on Metal: top3 = {:?} (HF = [12642, 4052, 216])", &idx[..3]); + assert_eq!(&idx[..3], &[12642usize, 4052, 216], "Llama prefill top-3 != HF"); + eprintln!("✅ Llama RoPE-at-position causal prefill matches HF on the shared engine (Apple GPU) — full prefill path verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--HuggingFaceTB--SmolVLM-256M-Instruct/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/tests/metal_model.rs b/rust/crates/backends/ffai-metal/tests/metal_model.rs new file mode 100644 index 00000000..623bb86a --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/metal_model.rs @@ -0,0 +1,125 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! The SAME transformer decode layer that runs on CUDA, now on **Metal** +//! (Apple GPU) through the shared Device trait, checked against a CPU +//! reference. This is the cross-platform proof: one builder + one op layer, +//! correct on both backends — only the Device impl swaps. +//! +//! Runs on macOS with a Metal GPU; skips elsewhere. + +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_models::llama::{LayerWeights, LlamaConfig, decode_layer_self}; + +fn to_bytes(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn from_bytes(b: &[u8]) -> Vec { + b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() +} +fn fill(n: usize, salt: usize) -> Vec { + (0..n).map(|i| (((i * 7 + salt * 131) % 97) as f32 - 48.0) * 0.0008).collect() +} +fn tens(dev: &dyn Device, data: &[f32], shape: Vec) -> Tensor { + Tensor::new(dev.upload(&to_bytes(data)).unwrap(), shape, DType::F32) +} +fn silu(x: f32) -> f32 { + x / (1.0 + (-x).exp()) +} +fn matvec(mat: &[f32], v: &[f32], m: usize, k: usize) -> Vec { + (0..m).map(|r| (0..k).map(|c| mat[r * k + c] * v[c]).sum()).collect() +} +fn rmsnorm(x: &[f32], w: &[f32], eps: f32) -> Vec { + let n = x.len(); + let ms: f32 = x.iter().map(|v| v * v).sum::() / n as f32; + let s = 1.0 / (ms + eps).sqrt(); + (0..n).map(|i| x[i] * s * w[i]).collect() +} + +#[test] +fn qwen2_decode_layer_on_metal_matches_cpu() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + eprintln!("device: {}", dev.name()); + + let cfg = LlamaConfig { + hidden: 896, + n_q_heads: 14, + n_kv_heads: 2, + head_dim: 64, + intermediate: 4864, + rope_theta: 1_000_000.0, + eps: 1e-6, + qk_norm: false, + attn_bias: false, + }; + let h = cfg.hidden; + let qd = cfg.n_q_heads * cfg.head_dim; + let kd = cfg.n_kv_heads * cfg.head_dim; + let im = cfg.intermediate; + + let attn_norm = fill(h, 1); + let wq = fill(qd * h, 2); + let wk = fill(kd * h, 3); + let wv = fill(kd * h, 4); + let wo = fill(h * qd, 5); + let mlp_norm = fill(h, 6); + let w_gate = fill(im * h, 7); + let w_up = fill(im * h, 8); + let w_down = fill(h * im, 9); + let x = fill(h, 10); + + let w = LayerWeights { + attn_norm: tens(dev.as_ref(), &attn_norm, vec![h]), + wq: tens(dev.as_ref(), &wq, vec![qd, h]), + wk: tens(dev.as_ref(), &wk, vec![kd, h]), + wv: tens(dev.as_ref(), &wv, vec![kd, h]), + wo: tens(dev.as_ref(), &wo, vec![h, qd]), + bias_q: None, + bias_k: None, + bias_v: None, + q_norm: None, + k_norm: None, + mlp_norm: tens(dev.as_ref(), &mlp_norm, vec![h]), + w_gate: tens(dev.as_ref(), &w_gate, vec![im, h]), + w_up: tens(dev.as_ref(), &w_up, vec![im, h]), + w_down: tens(dev.as_ref(), &w_down, vec![h, im]), + }; + let tx = tens(dev.as_ref(), &x, vec![h]); + + let out = decode_layer_self(dev.as_ref(), &cfg, &w, &tx, 0).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; h * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got = from_bytes(&ob); + + // CPU reference (pos=0 → RoPE identity, n_kv=1 → attn=v per group). + let hh = rmsnorm(&x, &attn_norm, cfg.eps); + let v = matvec(&wv, &hh, kd, h); + let hpg = cfg.n_q_heads / cfg.n_kv_heads; + let mut attn = vec![0.0f32; qd]; + for qh in 0..cfg.n_q_heads { + let kvh = qh / hpg; + for d in 0..cfg.head_dim { + attn[qh * cfg.head_dim + d] = v[kvh * cfg.head_dim + d]; + } + } + let o = matvec(&wo, &attn, h, qd); + let x1: Vec = (0..h).map(|i| x[i] + o[i]).collect(); + let h2 = rmsnorm(&x1, &mlp_norm, cfg.eps); + let gate = matvec(&w_gate, &h2, im, h); + let up = matvec(&w_up, &h2, im, h); + let act: Vec = (0..im).map(|i| silu(gate[i]) * up[i]).collect(); + let down = matvec(&w_down, &act, h, im); + let want: Vec = (0..h).map(|i| x1[i] + down[i]).collect(); + + let mut err = 0.0f32; + for i in 0..h { + err = err.max((got[i] - want[i]).abs()); + } + eprintln!("transformer decode layer on METAL vs CPU: max|Δ|={err:.3e}"); + assert!(err <= 5e-3, "metal decode layer mismatch: max|Δ|={err:.3e}"); + eprintln!("✅ Same Qwen2 decode layer runs on Apple GPU through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-metal/tests/moe_test.rs b/rust/crates/backends/ffai-metal/tests/moe_test.rs new file mode 100644 index 00000000..f155475a --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/moe_test.rs @@ -0,0 +1,95 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! MoE feed-forward (router → top-k → per-expert SwiGLU → weighted sum) on +//! Metal vs a CPU reference. Proves the MoE compute path — the exotic family +//! covering DeepSeek-V4 / GPT-OSS / Granite4 / Qwen-MoE — runs correctly on +//! the shared op layer. (Real MoE-model-vs-HF verification follows once the +//! large expert weights are staged.) + +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_models::moe::{ExpertWeights, MoeMlp, moe_mlp}; + +fn tb(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn fill(n: usize, s: usize) -> Vec { + (0..n).map(|i| (((i * 7 + s * 131) % 97) as f32 - 48.0) * 0.01).collect() +} +fn tens(d: &dyn Device, v: &[f32], shape: Vec) -> Tensor { + Tensor::new(d.upload(&tb(v)).unwrap(), shape, DType::F32) +} +fn silu(x: f32) -> f32 { + x / (1.0 + (-x).exp()) +} +fn mv(m: &[f32], v: &[f32], rows: usize, k: usize) -> Vec { + (0..rows).map(|r| (0..k).map(|c| m[r * k + c] * v[c]).sum()).collect() +} + +#[test] +fn moe_mlp_on_metal_matches_cpu() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + const H: usize = 256; + const INTER: usize = 512; + const NE: usize = 8; + const TOPK: usize = 2; + + let router = fill(NE * H, 1); + let h = fill(H, 99); + let experts: Vec<(Vec, Vec, Vec)> = (0..NE) + .map(|e| (fill(INTER * H, 10 + e), fill(INTER * H, 100 + e), fill(H * INTER, 200 + e))) + .collect(); + + let w = MoeMlp { + router: tens(dev.as_ref(), &router, vec![NE, H]), + experts: experts + .iter() + .map(|(g, u, d)| ExpertWeights { + gate: tens(dev.as_ref(), g, vec![INTER, H]), + up: tens(dev.as_ref(), u, vec![INTER, H]), + down: tens(dev.as_ref(), d, vec![H, INTER]), + }) + .collect(), + top_k: TOPK, + norm_topk: true, + }; + let th = tens(dev.as_ref(), &h, vec![H]); + + let out = moe_mlp(dev.as_ref(), &w, &th).unwrap(); + dev.synchronize().unwrap(); + let mut ob = vec![0u8; H * 4]; + dev.download(out.buffer.as_ref(), &mut ob).unwrap(); + let got: Vec = ob.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(); + + // CPU reference. + let logits = mv(&router, &h, NE, H); + let mut order: Vec = (0..NE).collect(); + order.sort_by(|&a, &b| logits[b].total_cmp(&logits[a])); + let top: Vec = order.into_iter().take(TOPK).collect(); + let m = top.iter().map(|&i| logits[i]).fold(f32::MIN, f32::max); + let e: Vec = top.iter().map(|&i| (logits[i] - m).exp()).collect(); + let s: f32 = e.iter().sum(); + let wts: Vec = e.iter().map(|x| x / s).collect(); + let mut want = vec![0.0f32; H]; + for (&ei, &gw) in top.iter().zip(&wts) { + let (g, u, d) = &experts[ei]; + let gate = mv(g, &h, INTER, H); + let up = mv(u, &h, INTER, H); + let act: Vec = (0..INTER).map(|i| silu(gate[i]) * up[i]).collect(); + let out = mv(d, &act, H, INTER); + for i in 0..H { + want[i] += gw * out[i]; + } + } + + let mut err = 0.0f32; + for i in 0..H { + err = err.max((got[i] - want[i]).abs()); + } + eprintln!("MoE MLP on Metal vs CPU: max|Δ|={err:.3e} (top experts {top:?})"); + assert!(err <= 5e-3, "moe mismatch: {err:.3e}"); + eprintln!("✅ MoE feed-forward runs on Apple GPU through the shared op layer, matches CPU."); +} diff --git a/rust/crates/backends/ffai-metal/tests/nemotron.rs b/rust/crates/backends/ffai-metal/tests/nemotron.rs new file mode 100644 index 00000000..ceed7336 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/nemotron.rs @@ -0,0 +1,24 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! NemotronH / Nemotron-Cascade-2-30B-A3B BATCHED PREFILL on Metal (Apple GPU). +//! +//! Parallels the CUDA `nemotron.rs` test but exercises the backend-gated portable +//! path in `bench_nemotron`: when the device is not CUDA the projection GEMMs run +//! via `gemm_q4_mpp` (Q4-native Apple hardware MMA), the MoE / shared experts via +//! `dequant_q4_off` + `matmul`, the scan via `ssm_prefill_scan`, attention via +//! `sdpa_multi` — no cuBLAS / Marlin / CUDA-raw kernels. +//! +//! Weights: an MLX 4-bit Nemotron-Cascade checkpoint, dequantized on the fly by +//! the loader's MLX-affine path. Set NEMOTRON_DIR to the checkpoint dir. +//! Not in run_all (large model + needs local weights). NEMOTRON_PREFILL_BATCHED=1 +//! and NEMOTRON_PREFILL_S= drive the batched prefill inside bench_nemotron. +use ffai_metal::MetalDevice; + +#[test] +fn nemotron_batched_prefill_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + ffai_modeltests::bench_nemotron(dev.as_ref(), "Apple M5 Max (Metal)"); +} diff --git a/rust/crates/backends/ffai-metal/tests/qwen3_metal.rs b/rust/crates/backends/ffai-metal/tests/qwen3_metal.rs new file mode 100644 index 00000000..0a08e592 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/qwen3_metal.rs @@ -0,0 +1,68 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Real Qwen3-0.6B (BF16) through the shared Rust engine on the **Apple +//! GPU** — the same model that matches HF on CUDA, now on Metal. Proves a +//! real model is verified on BOTH platforms (the argmax must equal HF's +//! 21806 for input "Hello"=9707, the same answer the CUDA path gave). +//! +//! Runs on macOS with a Metal GPU; skips elsewhere. + +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_models::llama::{LlamaConfig, forward_single, load_qwen3}; + +fn bf16_to_f32(b: &[u8]) -> Vec { + b.chunks_exact(2) + .map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)) + .collect() +} + +#[test] +fn qwen3_0_6b_real_forward_on_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let path = std::env::var("QWEN3_PATH") + .unwrap_or_else(|_| "/Users/tom/models/Qwen3-0.6B-hf/model.safetensors".to_string()); + eprintln!("device: {} — loading {path}", dev.name()); + let st = SafeTensors::open(&path).expect("open safetensors"); + + let cfg = LlamaConfig { + hidden: 1024, + n_q_heads: 16, + n_kv_heads: 8, + head_dim: 128, + intermediate: 3072, + rope_theta: 1_000_000.0, + eps: 1e-6, + qk_norm: true, + attn_bias: false, + }; + const N_LAYERS: usize = 28; + const VOCAB: usize = 151936; + + let mw = load_qwen3(dev.as_ref(), &st, &cfg, N_LAYERS).expect("load qwen3"); + eprintln!("model loaded; running forward on Apple GPU…"); + + let token: u32 = std::env::var("TOK").ok().and_then(|s| s.parse().ok()).unwrap_or(9707); + let logits = forward_single(dev.as_ref(), &cfg, &mw, token).expect("forward"); + dev.synchronize().unwrap(); + + let mut lb = vec![0u8; VOCAB * 2]; + dev.download(logits.buffer.as_ref(), &mut lb).unwrap(); + let l = bf16_to_f32(&lb); + + let mut idx: Vec = (0..VOCAB).collect(); + idx.sort_by(|&a, &b| l[b].total_cmp(&l[a])); + eprintln!("input token {token} → top-5 (Metal):"); + for &i in idx.iter().take(5) { + eprintln!(" id {i:>6} logit {:.4}", l[i]); + } + eprintln!("ARGMAX (Metal) = {}", idx[0]); + // HF / CUDA both gave 21806 for token 9707. + if token == 9707 { + assert_eq!(idx[0], 21806, "Metal argmax disagrees with HF/CUDA (21806)"); + eprintln!("✅ Qwen3-0.6B on Apple GPU predicts 21806 — matches HF and the CUDA run."); + } +} diff --git a/rust/crates/backends/ffai-metal/tests/qwen_moe.rs b/rust/crates/backends/ffai-metal/tests/qwen_moe.rs new file mode 100644 index 00000000..dee14deb --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/qwen_moe.rs @@ -0,0 +1,76 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Real MoE feed-forward vs HF: load a real Qwen2-MoE block's weights and +//! run the MoE forward (softmax→top-k routing, SwiGLU experts, sigmoid-gated +//! shared expert) on the shared op layer, comparing to HF transformers' +//! Qwen2MoeSparseMoeBlock output for the same input. Turns MoE from +//! "compute-verified-vs-CPU" into "real-weights-verified-vs-HF". +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gemv, swiglu}; + +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} + +#[test] +fn qwen2_moe_block_on_metal_matches_hf() { + let path = std::env::var("QWENMOE_DIR").ok() + .map(|d| format!("{d}/model.safetensors")) + .unwrap_or_else(|| std::fs::read_to_string("/tmp/qwenmoe_path.txt").map(|s| format!("{}/model.safetensors", s.trim())).unwrap_or_default()); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + + let (h, _moe_i, ne, tk) = (32usize, 44usize, 8usize, 4usize); + let t = |name: &str| -> Tensor { + let (bytes, dt, shape) = st.tensor(name).unwrap(); + assert_eq!(dt, DType::F32); + Tensor::new(dev.upload(bytes).unwrap(), shape.to_vec(), DType::F32) + }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b=vec![0u8;n*4]; dev.download(t.buffer.as_ref(),&mut b).unwrap(); fb(&b) }; + + let x: Vec = (0..h).map(|i| i as f32 * 0.03 - 0.5).collect(); + let tx = Tensor::new(dev.upload(&tb(&x)).unwrap(), vec![h], DType::F32); + let p = "model.layers.0.mlp"; + + // Router: softmax over all, top-k (norm_topk_prob=false → raw probs). + let logits_t = gemv(dev.as_ref(), &t(&format!("{p}.gate.weight")), &tx).unwrap(); + dev.synchronize().unwrap(); + let logits = dl(&logits_t, ne); + let m = logits.iter().cloned().fold(f32::MIN, f32::max); + let exps: Vec = logits.iter().map(|v| (v - m).exp()).collect(); + let s: f32 = exps.iter().sum(); + let probs: Vec = exps.iter().map(|e| e / s).collect(); + let mut order: Vec = (0..ne).collect(); + order.sort_by(|&a, &b| probs[b].total_cmp(&probs[a])); + let top: Vec = order.into_iter().take(tk).collect(); + + let mut acc = vec![0.0f32; h]; + for &e in &top { + let g = gemv(dev.as_ref(), &t(&format!("{p}.experts.{e}.gate_proj.weight")), &tx).unwrap(); + let u = gemv(dev.as_ref(), &t(&format!("{p}.experts.{e}.up_proj.weight")), &tx).unwrap(); + let act = swiglu(dev.as_ref(), &g, &u).unwrap(); + let o = gemv(dev.as_ref(), &t(&format!("{p}.experts.{e}.down_proj.weight")), &act).unwrap(); + dev.synchronize().unwrap(); + let ov = dl(&o, h); + for i in 0..h { acc[i] += probs[e] * ov[i]; } + } + // Shared expert, sigmoid-gated. + let slog = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert_gate.weight")), &tx).unwrap(); + dev.synchronize().unwrap(); + let sg_val = 1.0 / (1.0 + (-dl(&slog, 1)[0]).exp()); + let sg = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert.gate_proj.weight")), &tx).unwrap(); + let su = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert.up_proj.weight")), &tx).unwrap(); + let sact = swiglu(dev.as_ref(), &sg, &su).unwrap(); + let so = gemv(dev.as_ref(), &t(&format!("{p}.shared_expert.down_proj.weight")), &sact).unwrap(); + dev.synchronize().unwrap(); + let sov = dl(&so, h); + for i in 0..h { acc[i] += sg_val * sov[i]; } + + let hf = [-2.6e-05f32,-2.5e-05,4e-06,-7.1e-05,1.3e-05,3.5e-05,2.5e-05,1e-06,-2.7e-05,-2e-05,3.2e-05,-2.2e-05,3.2e-05,2.8e-05,-2.5e-05,9e-06,-2.5e-05,0.0,6e-05,2e-06,-1.9e-05,1e-05,-1.6e-05,3.2e-05,3.1e-05,2.2e-05,-3.2e-05,2.2e-05,9e-06,2.5e-05,1e-05,-2.2e-05]; + let mut e = 0.0f32; for i in 0..h { e = e.max((acc[i]-hf[i]).abs()); } + eprintln!("Qwen2-MoE block on Metal vs HF: max|Δ|={e:.2e} (top experts {top:?})"); + eprintln!("rust[..6]={:?}", &acc[..6]); + assert!(e <= 3e-6, "qwen moe vs HF mismatch: {e:.2e}"); + eprintln!("✅ Real Qwen2-MoE feed-forward matches HF on the shared op layer (Apple GPU)."); +} diff --git a/rust/crates/backends/ffai-metal/tests/siglip.rs b/rust/crates/backends/ffai-metal/tests/siglip.rs new file mode 100644 index 00000000..c50149dd --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/siglip.rs @@ -0,0 +1,133 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full real VLM **vision tower** (SigLIP-base-patch16-224) forward on the +//! shared engine, verified vs HF transformers. This is the VLM family's new +//! half — vision encoder = conv/patch-embed (as a matmul) + bidirectional +//! transformer (LayerNorm + full self-attention + GELU-MLP). The LLM half is +//! already covered (dense Llama family); a VLM is vision-tower → projector → +//! LLM. Heavy projections run on the device `matmul`; attention on the device +//! `sdpa_decode` (full/bidirectional = attend over all n_kv patches, looped +//! per query); LayerNorm + GELU(tanh) on device. Bias/residual/head-reorg are +//! trivial host elementwise. Input is a deterministic synthetic pixel tensor +//! (same `sin(0.01·i)` formula as the HF reference). +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{gelu, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn siglip_vision_tower_vs_hf() { + let dir = std::env::var("SIGLIP_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + + // config (SigLIP-base-patch16-224 vision) + let (hid, n_layers, nh, hd, inter, img, patch, eps) = + (768usize, 12usize, 12usize, 64usize, 3072usize, 224usize, 16usize, 1e-6f32); + let grid = img / patch; // 14 + let np = grid * grid; // 196 patches + let scale = 1.0 / (hd as f32).sqrt(); // 0.125 + + let g = |name: &str| -> Vec { let (b, dt, _s) = st.tensor(name).unwrap(); assert_eq!(dt, DType::F32, "{name}"); fb(b) }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + // add a per-column bias to a [rows, cols] host buffer (broadcast over rows) + let add_bias = |m: &mut [f32], bias: &[f32], rows: usize, cols: usize| { + for r in 0..rows { for c in 0..cols { m[r * cols + c] += bias[c]; } } + }; + + // ── patch embedding (conv2d stride=patch ≡ matmul over flattened patches) ── + let n_pix = 3 * img * img; + let pv: Vec = (0..n_pix).map(|i| (0.01 * i as f32).sin()).collect(); + // patch matrix [np, 3*patch*patch], inner order (c, kh, kw) to match conv weight flatten + let pdim = 3 * patch * patch; // 768 + let mut patches = vec![0.0f32; np * pdim]; + for gh in 0..grid { for gw in 0..grid { + let p = gh * grid + gw; + for c in 0..3 { for kh in 0..patch { for kw in 0..patch { + let h = gh * patch + kh; let w = gw * patch + kw; + patches[p * pdim + c * patch * patch + kh * patch + kw] = pv[c * img * img + h * img + w]; + }}} + }} + let cw = g("vision_model.embeddings.patch_embedding.weight"); // [768, 3,16,16] = [768, 768] + let emb = dl(&matmul(d, &up(&cw, vec![hid, pdim]), &up(&patches, vec![np, pdim])).unwrap(), np * hid); + let cb = g("vision_model.embeddings.patch_embedding.bias"); + let pos = g("vision_model.embeddings.position_embedding.weight"); // [196, 768] + let mut x = emb.clone(); + add_bias(&mut x, &cb, np, hid); + for i in 0..np * hid { x[i] += pos[i]; } + + // ── encoder ── + for l in 0..n_layers { + let p = format!("vision_model.encoder.layers.{l}"); + // self-attention + let ln1 = layer_norm(d, &up(&x, vec![np, hid]), + &up(&g(&format!("{p}.layer_norm1.weight")), vec![hid]), + &up(&g(&format!("{p}.layer_norm1.bias")), vec![hid]), eps).unwrap(); + let mut q = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![hid, hid]), &ln1).unwrap(), np * hid); + let mut k = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![hid, hid]), &ln1).unwrap(), np * hid); + let mut v = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![hid, hid]), &ln1).unwrap(), np * hid); + add_bias(&mut q, &g(&format!("{p}.self_attn.q_proj.bias")), np, hid); + add_bias(&mut k, &g(&format!("{p}.self_attn.k_proj.bias")), np, hid); + add_bias(&mut v, &g(&format!("{p}.self_attn.v_proj.bias")), np, hid); + // reorg to per-head KV cache [nh, np, hd] for sdpa_decode (kv_stride = np) + let mut kb = vec![0.0f32; nh * np * hd]; + let mut vb = vec![0.0f32; nh * np * hd]; + for t in 0..np { for h in 0..nh { for dd in 0..hd { + kb[h * np * hd + t * hd + dd] = k[t * hid + h * hd + dd]; + vb[h * np * hd + t * hd + dd] = v[t * hid + h * hd + dd]; + }}} + let kt = up(&kb, vec![nh, np, hd]); + let vt = up(&vb, vec![nh, np, hd]); + // full bidirectional attention: each query patch attends over all np patches + let mut attn = vec![0.0f32; np * hid]; + for t in 0..np { + let qt = up(&q[t * hid..(t + 1) * hid], vec![nh, hd]); + let a = sdpa_decode(d, &qt, &kt, &vt, hd, np as u32, np as u32, 1, scale).unwrap(); + let ad = dl(&a, hid); + attn[t * hid..(t + 1) * hid].copy_from_slice(&ad); + } + let mut o = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.out_proj.weight")), vec![hid, hid]), &up(&attn, vec![np, hid])).unwrap(), np * hid); + add_bias(&mut o, &g(&format!("{p}.self_attn.out_proj.bias")), np, hid); + for i in 0..np * hid { x[i] += o[i]; } // residual + + // GELU-MLP + let ln2 = layer_norm(d, &up(&x, vec![np, hid]), + &up(&g(&format!("{p}.layer_norm2.weight")), vec![hid]), + &up(&g(&format!("{p}.layer_norm2.bias")), vec![hid]), eps).unwrap(); + let mut h1 = dl(&matmul(d, &up(&g(&format!("{p}.mlp.fc1.weight")), vec![inter, hid]), &ln2).unwrap(), np * inter); + add_bias(&mut h1, &g(&format!("{p}.mlp.fc1.bias")), np, inter); + let act = gelu(d, &up(&h1, vec![np, inter])).unwrap(); + let mut h2 = dl(&matmul(d, &up(&g(&format!("{p}.mlp.fc2.weight")), vec![hid, inter]), &act).unwrap(), np * hid); + add_bias(&mut h2, &g(&format!("{p}.mlp.fc2.bias")), np, hid); + for i in 0..np * hid { x[i] += h2[i]; } // residual + } + + // ── post layernorm = last_hidden_state ── + let lhs = dl(&layer_norm(d, &up(&x, vec![np, hid]), + &up(&g("vision_model.post_layernorm.weight"), vec![hid]), + &up(&g("vision_model.post_layernorm.bias"), vec![hid]), eps).unwrap(), np * hid); + + // HF reference (deterministic sin(0.01·i) pixel input) + let want0 = [-0.05489f32, -0.43045, 0.37643, 0.09968, -1.22139]; + let want100 = [0.03598f32, 1.52808, 0.41827, -0.1857, -1.84941]; + let mut e = 0.0f32; + for i in 0..5 { e = e.max((lhs[i] - want0[i]).abs()); } + for i in 0..5 { e = e.max((lhs[100 * hid + i] - want100[i]).abs()); } + let sum: f32 = lhs.iter().sum(); + eprintln!("SigLIP vision tower on Metal: LHS[0,:5]={:?}", &lhs[..5]); + eprintln!(" LHS[100,:5]={:?} sum={sum:.3} (HF sum=-792.795) max|Δ| first-rows={e:.3e}", &lhs[100 * hid..100 * hid + 5]); + assert!(e < 2e-2, "SigLIP last_hidden_state mismatch vs HF: max|Δ|={e:.3e}"); + assert!((sum + 792.795).abs() < 5.0, "SigLIP LHS sum off: {sum}"); + eprintln!("✅ Full real SigLIP vision tower matches HF on the shared engine (Apple GPU) — VLM vision half verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--google--siglip-base-patch16-224/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/tests/smolvlm.rs b/rust/crates/backends/ffai-metal/tests/smolvlm.rs new file mode 100644 index 00000000..1771291e --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/smolvlm.rs @@ -0,0 +1,73 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! SmolVLM (Idefics3) vision→text **connector** verified vs HF. This is the one +//! VLM-stitch component not already covered: the SigLIP vision tower and the +//! dense-Llama text model are each verified separately, so the connector — +//! pixel-shuffle (gather a `scale_factor`×`scale_factor` block of patches into +//! one token's channels) + a `modality_projection` linear into the text hidden +//! dim — is the remaining new piece. Pixel-shuffle is exact index math; the +//! projection runs on the verified `matmul`. A full SmolVLM forward then = +//! [verified SigLIP tower] → [this connector] → splice into text → [verified +//! causal Llama], all on the shared op layer. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::matmul; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +#[test] +fn smolvlm_connector_vs_hf() { + let dir = std::env::var("SMOLVLM_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + + let (vdim, sf, txt) = (768usize, 4usize, 576usize); // vision hid, scale_factor, text hid + let np = 1024usize; let grid = 32usize; // 32×32 patches + let og = grid / sf; // 8 → 64 output tokens + let n_tok = og * og; // 64 + let cin = vdim * sf * sf; // 12288 + + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + + // deterministic vision-tower output [1024, 768] + let x: Vec = (0..np * vdim).map(|i| (0.01 * i as f32).sin()).collect(); + + // pixel-shuffle: out[tok=h4*og+w2][e4], e4 → h_sub=e4/(vdim*sf), w_sub=(e4 % (vdim*sf))/vdim, e=e4%vdim + // source patch (h=h4*sf+h_sub, w=w2*sf+w_sub): x[(h*grid + w)*vdim + e] + let mut shuf = vec![0.0f32; n_tok * cin]; + for h4 in 0..og { for w2 in 0..og { + let tok = h4 * og + w2; + for e4 in 0..cin { + let h_sub = e4 / (vdim * sf); + let w_sub = (e4 % (vdim * sf)) / vdim; + let e = e4 % vdim; + let h = h4 * sf + h_sub; let w = w2 * sf + w_sub; + shuf[tok * cin + e4] = x[(h * grid + w) * vdim + e]; + } + }} + + // modality_projection (Linear, no bias): [txt, cin] @ shuf[n_tok, cin] → [n_tok, txt] + let proj = st.tensor_f32("model.connector.modality_projection.proj.weight").unwrap().0; + let out = dl(&matmul(d, &up(&proj, vec![txt, cin]), &up(&shuf, vec![n_tok, cin])).unwrap(), n_tok * txt); + + let want0 = [1.56379f32, 0.48131, -0.04509, -1.15257, 1.72148]; + let want63 = [-0.73231f32, -0.52461, 1.12685, 1.35081, -2.54256]; + let mut e = 0.0f32; + for i in 0..5 { e = e.max((out[i] - want0[i]).abs()); } + for i in 0..5 { e = e.max((out[63 * txt + i] - want63[i]).abs()); } + let sum: f32 = out.iter().sum(); + eprintln!("SmolVLM connector on Metal: out[0,:5]={:?}", &out[..5]); + eprintln!(" out[63,:5]={:?} sum={sum:.3} (HF=79.852) max|Δ|={e:.3e}", &out[63 * txt..63 * txt + 5]); + assert!(e < 2e-3, "SmolVLM connector mismatch vs HF: {e:.3e}"); + assert!((sum - 79.852).abs() < 0.5, "connector sum off: {sum}"); + eprintln!("✅ SmolVLM connector (pixel-shuffle + modality projection) matches HF on the shared engine (Apple GPU) — VLM stitch component verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--HuggingFaceTB--SmolVLM-256M-Instruct/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/tests/ssd_profile.rs b/rust/crates/backends/ffai-metal/tests/ssd_profile.rs new file mode 100644 index 00000000..3645ab42 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/ssd_profile.rs @@ -0,0 +1,102 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! SSD-scan PROFILE + L-sweep on Metal. Times `ssm_prefill_scan_ssd_portable` +//! (the portable/Metal chunked-matmul Mamba2 scan) at S∈{2048,8192} across +//! L∈{64,128,256,512}, plus the sequential reference, to find the optimal +//! chunk length and quantify the O(S·L) intra-chunk vs O(nc) inter-chunk +//! tradeoff. Run with: cargo test --release -p ffai-metal --test ssd_profile -- --nocapture --test-threads=1 +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::{ssm_prefill_scan, ssm_prefill_scan_ssd_portable}; +use std::time::Instant; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn fill(n: usize, s: usize) -> Vec { (0..n).map(|i| (((i * 7 + s * 131) % 89) as f32 - 44.0) * 0.02).collect() } + +fn cos_sim(a: &[f32], b: &[f32]) -> f64 { + let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64); + for i in 0..a.len() { + dot += a[i] as f64 * b[i] as f64; + na += (a[i] as f64).powi(2); + nb += (b[i] as f64).powi(2); + } + dot / (na.sqrt() * nb.sqrt()) +} + +#[test] +fn ssd_lsweep_profile_metal() { + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + let (dh, ds, h, ng) = (64usize, 128usize, 64usize, 8usize); + let n_layers = 23usize; // Nemotron-Cascade has 23 Mamba layers + let iters = 5usize; + + let s_list = [2048usize, 8192usize]; + let l_list = [64u32, 128u32, 256u32, 512u32]; + + for &t in &s_list { + // Synthetic well-conditioned inputs (small dt → stable decay). + let x = fill(t * h * dh, 11); + let a_log: Vec = (0..h).map(|i| -0.5 - 0.01 * (i % 7) as f32).collect(); + let b = fill(t * ng * ds, 13); + let c = fill(t * ng * ds, 17); + let dsk = fill(h, 19); + let dt_v: Vec = (0..t * h).map(|i| 0.02 + 0.001 * ((i % 11) as f32)).collect(); + let si = vec![0.0f32; h * dh * ds]; + let mk = |v: &[f32], sh: Vec| Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32); + let xt = mk(&x, vec![t * h * dh]); + let at = mk(&a_log, vec![h]); + let bt = mk(&b, vec![t * ng * ds]); + let ct = mk(&c, vec![t * ng * ds]); + let dskt = mk(&dsk, vec![h]); + let dtt = mk(&dt_v, vec![t * h]); + + eprintln!("\n========== S={t} (one Mamba layer; ×{n_layers} layers for e2e estimate) =========="); + + // Sequential reference (the current default scan). + let (so_seq, y_seq) = { + let sit = mk(&si, vec![h * dh * ds]); + ssm_prefill_scan(d, &xt, &at, &bt, &ct, &dskt, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32).unwrap() + }; + d.synchronize().unwrap(); + let t0 = Instant::now(); + for _ in 0..iters { + let sit = mk(&si, vec![h * dh * ds]); + let _ = ssm_prefill_scan(d, &xt, &at, &bt, &ct, &dskt, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32).unwrap(); + d.synchronize().unwrap(); + } + let seq_us = t0.elapsed().as_secs_f64() * 1e6 / iters as f64; + eprintln!(" sequential scan : {:>9.1} us/layer {:>8.2} ms × {n_layers} layers", seq_us, seq_us * n_layers as f64 / 1e3); + let ys = fb(&{ let mut bb = vec![0u8; t * h * dh * 4]; d.download(y_seq.buffer.as_ref(), &mut bb).unwrap(); bb }); + let ss = fb(&{ let mut bb = vec![0u8; h * dh * ds * 4]; d.download(so_seq.buffer.as_ref(), &mut bb).unwrap(); bb }); + + for &l in &l_list { + let nc = (t as u32).div_ceil(l); + // warmup + correctness + let (so_p, y_p) = { + let sit = mk(&si, vec![h * dh * ds]); + ssm_prefill_scan_ssd_portable(d, &xt, &at, &bt, &ct, &dskt, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32, l).unwrap() + }; + d.synchronize().unwrap(); + let yp = fb(&{ let mut bb = vec![0u8; t * h * dh * 4]; d.download(y_p.buffer.as_ref(), &mut bb).unwrap(); bb }); + let sp = fb(&{ let mut bb = vec![0u8; h * dh * ds * 4]; d.download(so_p.buffer.as_ref(), &mut bb).unwrap(); bb }); + let y_cos = cos_sim(&ys, &yp); + let s_cos = cos_sim(&ss, &sp); + + let t1 = Instant::now(); + for _ in 0..iters { + let sit = mk(&si, vec![h * dh * ds]); + let _ = ssm_prefill_scan_ssd_portable(d, &xt, &at, &bt, &ct, &dskt, &dtt, &sit, + t as u32, dh as u32, ds as u32, h as u32, ng as u32, l).unwrap(); + d.synchronize().unwrap(); + } + let us = t1.elapsed().as_secs_f64() * 1e6 / iters as f64; + let spd = seq_us / us; + eprintln!(" SSD L={l:<4} nc={nc:<4}: {:>9.1} us/layer {:>8.2} ms ×{n_layers} ({spd:.2}× vs seq) y_cos={y_cos:.6} s_cos={s_cos:.6}", us, us * n_layers as f64 / 1e3); + } + } +} diff --git a/rust/crates/backends/ffai-metal/tests/ssm_test.rs b/rust/crates/backends/ffai-metal/tests/ssm_test.rs new file mode 100644 index 00000000..1bb58d08 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/ssm_test.rs @@ -0,0 +1,123 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Mamba2 SSD selective-scan decode step (mt_ssm_step) on Metal vs CPU — +//! the core SSM-family op (Mamba2/Jamba/FalconH1/LFM2). +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::{ssm_step, ssm_prefill_scan, ssm_prefill_scan_ssd_portable}; + +fn tb(v:&[f32])->Vec{v.iter().flat_map(|x|x.to_le_bytes()).collect()} +fn fb(b:&[u8])->Vec{b.chunks_exact(4).map(|c|f32::from_le_bytes(c.try_into().unwrap())).collect()} +fn fill(n:usize,s:usize)->Vec{(0..n).map(|i|(((i*7+s*131)%89) as f32-44.0)*0.02).collect()} +fn tn(d:&dyn Device,v:&[f32],sh:Vec)->Tensor{Tensor::new(d.upload(&tb(v)).unwrap(),sh,DType::F32)} + +#[test] +fn mt_ssm_step_on_metal_matches_cpu(){ + let Some(dev)=MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let (nh, dh, ds, hpg) = (4usize, 8usize, 32usize, 2usize); + let ng = nh/hpg; + let x=fill(nh*dh,1); let a_log=fill(nh,2); let b=fill(ng*ds,3); let c=fill(ng*ds,4); let dsk=fill(nh,5); let dt:Vec=(0..nh).map(|i|0.3+0.1*i as f32).collect(); let si=fill(nh*dh*ds,7); + + let (so_t, out_t) = ssm_step(dev.as_ref(), + &tn(dev.as_ref(),&x,vec![nh*dh]), &tn(dev.as_ref(),&a_log,vec![nh]), + &tn(dev.as_ref(),&b,vec![ng*ds]), &tn(dev.as_ref(),&c,vec![ng*ds]), + &tn(dev.as_ref(),&dsk,vec![nh]), &tn(dev.as_ref(),&dt,vec![nh]), + &tn(dev.as_ref(),&si,vec![nh*dh*ds]), dh as u32, ds as u32, nh as u32, hpg as u32).unwrap(); + dev.synchronize().unwrap(); + let mut sb=vec![0u8;nh*dh*ds*4]; dev.download(so_t.buffer.as_ref(),&mut sb).unwrap(); let so=fb(&sb); + let mut ob=vec![0u8;nh*dh*4]; dev.download(out_t.buffer.as_ref(),&mut ob).unwrap(); let out=fb(&ob); + + // CPU ref + let mut so_ref=vec![0.0f32;nh*dh*ds]; let mut out_ref=vec![0.0f32;nh*dh]; + for n in 0..nh { + let g=n/hpg; let da=(-(a_log[n].exp())*dt[n]).exp(); + for d in 0..dh { + let xv=x[n*dh+d]; let mut acc=0.0f32; + for s in 0..ds { + let ns=da*si[n*dh*ds+d*ds+s]+xv*dt[n]*b[g*ds+s]; + so_ref[n*dh*ds+d*ds+s]=ns; acc+=ns*c[g*ds+s]; + } + out_ref[n*dh+d]=acc+xv*dsk[n]; + } + } + let mut es=0.0f32; for i in 0..nh*dh*ds { es=es.max((so[i]-so_ref[i]).abs()); } + let mut eo=0.0f32; for i in 0..nh*dh { eo=eo.max((out[i]-out_ref[i]).abs()); } + eprintln!("mt_ssm_step on Metal vs CPU: state max|Δ|={es:.3e} out max|Δ|={eo:.3e}"); + assert!(es<=1e-4 && eo<=1e-4, "ssm mismatch state={es:.3e} out={eo:.3e}"); + eprintln!("✅ Mamba2 SSD selective-scan step runs on Apple GPU through the shared op layer, matches CPU."); +} + +// Direct scan-level gate: the PORTABLE chunked-matmul SSD prefill scan +// (ssm_prefill_scan_ssd_portable, all #[kernel] ops + ffai_gemm_batched) must +// match the sequential reference (ssm_prefill_scan) to tight tolerance — the +// whole point of the state-space-duality rewrite is bit-equivalence to the +// serial recurrence (modulo f32 accumulation order). Nemotron cell dims. +fn cos_sim(a: &[f32], b: &[f32]) -> f64 { + let (mut dot, mut na, mut nb) = (0.0f64, 0.0f64, 0.0f64); + for i in 0..a.len() { + dot += a[i] as f64 * b[i] as f64; + na += (a[i] as f64).powi(2); + nb += (b[i] as f64).powi(2); + } + dot / (na.sqrt() * nb.sqrt()) +} + +fn run_portable_vs_seq(t: usize, l: u32) { + let Some(dev)=MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + // Nemotron cell: dh=64, ds=128, H=64, G=8. + let (dh, ds, h, ng) = (64usize, 128usize, 64usize, 8usize); + // Synthetic but well-conditioned inputs (small dt so decays are stable). + let x = fill(t * h * dh, 11); + let a_log: Vec = (0..h).map(|i| -0.5 - 0.01 * (i % 7) as f32).collect(); + let b = fill(t * ng * ds, 13); + let c = fill(t * ng * ds, 17); + let dsk = fill(h, 19); + let dt_v: Vec = (0..t * h).map(|i| 0.02 + 0.001 * ((i % 11) as f32)).collect(); + let si = vec![0.0f32; h * dh * ds]; // prefill starts from zero state + let mk = |v: &[f32], sh: Vec| tn(d, v, sh); + + let (so_seq, y_seq) = ssm_prefill_scan( + d, &mk(&x, vec![t * h * dh]), &mk(&a_log, vec![h]), + &mk(&b, vec![t * ng * ds]), &mk(&c, vec![t * ng * ds]), + &mk(&dsk, vec![h]), &mk(&dt_v, vec![t * h]), + &mk(&si, vec![h * dh * ds]), t as u32, dh as u32, ds as u32, h as u32, ng as u32, + ).unwrap(); + let (so_port, y_port) = ssm_prefill_scan_ssd_portable( + d, &mk(&x, vec![t * h * dh]), &mk(&a_log, vec![h]), + &mk(&b, vec![t * ng * ds]), &mk(&c, vec![t * ng * ds]), + &mk(&dsk, vec![h]), &mk(&dt_v, vec![t * h]), + &mk(&si, vec![h * dh * ds]), t as u32, dh as u32, ds as u32, h as u32, ng as u32, l, + ).unwrap(); + d.synchronize().unwrap(); + + let dl = |ts: &Tensor, n: usize| { let mut bb = vec![0u8; n * 4]; d.download(ts.buffer.as_ref(), &mut bb).unwrap(); fb(&bb) }; + let ys = dl(&y_seq, t * h * dh); + let yp = dl(&y_port, t * h * dh); + let ss = dl(&so_seq, h * dh * ds); + let sp = dl(&so_port, h * dh * ds); + + let y_max = (0..ys.len()).map(|i| (ys[i] - yp[i]).abs()).fold(0.0f32, f32::max); + let s_max = (0..ss.len()).map(|i| (ss[i] - sp[i]).abs()).fold(0.0f32, f32::max); + let y_cos = cos_sim(&ys, &yp); + let s_cos = cos_sim(&ss, &sp); + let y_norm = (ys.iter().map(|v| (*v as f64).powi(2)).sum::() / ys.len() as f64).sqrt(); + eprintln!("portable-SSD vs sequential scan [T={t}, L={l}, nc={}]:", (t as u32).div_ceil(l)); + eprintln!(" y: max|Δ|={y_max:.3e} cosine={y_cos:.8} (y RMS≈{y_norm:.4})"); + eprintln!(" state: max|Δ|={s_max:.3e} cosine={s_cos:.8}"); + assert!(y_cos > 0.9999, "y cosine {y_cos:.8} too low (T={t}, L={l})"); + assert!(s_cos > 0.9999, "state cosine {s_cos:.8} too low (T={t}, L={l})"); + eprintln!("✅ portable chunked-matmul SSD scan matches sequential (cosine>0.9999) — portable kernels only."); +} + +// nc=1 (single chunk: pure intra-chunk path, no inter-chunk recurrence). +#[test] +fn ssd_portable_scan_single_chunk_metal() { run_portable_vs_seq(128, 128); } + +// nc>1 with a partial tail chunk (exercises serial inter-chunk carry + zero-pad). +#[test] +fn ssd_portable_scan_multi_chunk_metal() { run_portable_vs_seq(300, 128); } + +// L=256, multiple full chunks (the default chunk length). +#[test] +fn ssd_portable_scan_l256_metal() { run_portable_vs_seq(512, 256); } diff --git a/rust/crates/backends/ffai-metal/tests/subslab_oob.rs b/rust/crates/backends/ffai-metal/tests/subslab_oob.rs new file mode 100644 index 00000000..bf29bd6d --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/subslab_oob.rs @@ -0,0 +1,196 @@ +// Copyright 2026 Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Metal-validation harness for the sub-slab / offset OOB class in `ffai-ops`. +//! +//! Companion to `dequant_q4_oob.rs`. Several ops address a sub-slab of a RAW +//! bound buffer purely via a host-supplied offset/stride (the tensor.offset is +//! ignored by design): `slice` (`src[off + i]`) and `strided_col_copy` +//! (`src[ti*stride + col_off + ci]`). A caller binding a buffer that does not +//! actually hold `offset + slab` elements over-reads past the end — silent +//! garbage on Metal (lenient past-the-end reads), a deterministic MMU-fault on +//! CUDA. Each op now has a defensive host-side bounds check that converts the +//! mis-size into a loud, shape-named `Err` BEFORE the GPU dispatch. +//! +//! These tests prove, per op: +//! (a) a CORRECTLY sized call dispatches clean and bit-matches a CPU oracle, +//! (b) an UNDERSIZED bound buffer trips the guard with a clear message +//! (an `Err`, NOT a GPU fault / silent garbage), +//! (c) the EXACT-fit boundary call (need == have) does NOT false-trip. +//! +//! Run with Metal GPU/shader validation ON: +//! MTL_SHADER_VALIDATION=1 MTL_DEBUG_LAYER=1 METAL_DEVICE_WRAPPER_TYPE=1 \ +//! cargo test -p ffai-metal --test subslab_oob -- --nocapture + +use ffai_core::{DType, Device, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::{slice, strided_col_copy}; + +fn tb_f32(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} +fn dl_f32(d: &dyn Device, t: &Tensor, n: usize) -> Vec { + let mut b = vec![0u8; n * 4]; + d.download(t.buffer.as_ref(), &mut b).unwrap(); + b.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} +fn max_abs_diff(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs())) +} + +// ── slice: out[i] = src[off + i] ──────────────────────────────────────────── + +/// (a) Correct-size slice: bind a buffer that holds off+len elems → clean + +/// bit-exact vs the CPU oracle. +#[test] +fn slice_correct_size_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let total = 4096usize; + let (off, len) = (1000usize, 1500usize); // off+len = 2500 < 4096 + let src: Vec = (0..total).map(|i| (i as f32 * 0.011).sin() * 3.0).collect(); + let st = Tensor::new(d.upload(&tb_f32(&src)).unwrap(), vec![total], DType::F32); + + let out = slice(d, &st, off, len).expect("correct-size slice must dispatch"); + let got = dl_f32(d, &out, len); + let want = &src[off..off + len]; + let diff = max_abs_diff(&got, want); + eprintln!("[slice off={off} len={len} have={total}] max|GPU-CPU| = {diff:e}"); + assert!(diff < 1e-6, "slice mismatch: {diff:e}"); +} + +/// (b) Undersized buffer: bind a buffer that holds FEWER than off+len elems → +/// the guard must return Err (NOT dispatch an over-read). +#[test] +fn slice_undersized_buffer_trips_guard_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let total = 1024usize; + let (off, len) = (900usize, 200usize); // off+len = 1100 > 1024 → OOB + let src: Vec = (0..total).map(|i| i as f32).collect(); + let st = Tensor::new(d.upload(&tb_f32(&src)).unwrap(), vec![total], DType::F32); + + let res = slice(d, &st, off, len); + eprintln!( + "[slice undersized off={off} len={len} have={total}] result = {:?}", + res.as_ref().map(|_| "Ok") + ); + let err = res.err().expect("undersized slice MUST return Err, not over-read"); + let msg = format!("{err:?}"); + assert!(msg.contains("slice OOB"), "guard message should name the op: {msg}"); +} + +/// (c) Exact-fit boundary: off+len == have. Must NOT false-trip (need == have +/// is in-bounds: the top read is src[off+len-1], the last valid element). +#[test] +fn slice_exact_fit_no_false_trip_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let total = 2048usize; + let (off, len) = (48usize, 2000usize); // off+len = 2048 == total + let src: Vec = (0..total).map(|i| (i as f32 * 0.03).cos()).collect(); + let st = Tensor::new(d.upload(&tb_f32(&src)).unwrap(), vec![total], DType::F32); + + let out = slice(d, &st, off, len).expect("exact-fit slice must NOT false-trip"); + let got = dl_f32(d, &out, len); + let want = &src[off..off + len]; + let diff = max_abs_diff(&got, want); + eprintln!("[slice EXACT off={off} len={len} have={total}] max|GPU-CPU| = {diff:e}"); + assert!(diff < 1e-6, "exact-fit slice mismatch: {diff:e}"); +} + +// ── strided_col_copy: dst[ti*width+ci] = src[ti*stride + col_off + ci] ─────── + +fn scc_oracle(src: &[f32], s: usize, stride: usize, col_off: usize, width: usize) -> Vec { + (0..s) + .flat_map(|ti| (0..width).map(move |ci| src[ti * stride + col_off + ci])) + .collect() +} + +/// (a) Correct-size strided_col_copy: src holds (s-1)*stride+col_off+width +/// elems → clean + bit-exact vs CPU oracle. +#[test] +fn strided_col_copy_correct_size_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + let (s, stride, col_off, width) = (16usize, 128usize, 32usize, 64usize); + let total = s * stride; // 2048; max read = 15*128+32+63 = 1015 < 2048 + let src: Vec = (0..total).map(|i| (i as f32 * 0.007).sin()).collect(); + let st = Tensor::new(d.upload(&tb_f32(&src)).unwrap(), vec![total], DType::F32); + + let out = strided_col_copy(d, &st, s, stride, col_off, width) + .expect("correct-size strided_col_copy must dispatch"); + let got = dl_f32(d, &out, s * width); + let want = scc_oracle(&src, s, stride, col_off, width); + let diff = max_abs_diff(&got, &want); + eprintln!("[scc s={s} stride={stride} col_off={col_off} width={width}] max|GPU-CPU| = {diff:e}"); + assert!(diff < 1e-6, "strided_col_copy mismatch: {diff:e}"); +} + +/// (b) Undersized buffer: bind a src whose last row's column window runs past +/// the end → guard must return Err (NOT dispatch an over-read). +#[test] +fn strided_col_copy_undersized_buffer_trips_guard_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + // need = (s-1)*stride + col_off + width = 7*100 + 90 + 50 = 840. Bind 800. + let (s, stride, col_off, width) = (8usize, 100usize, 90usize, 50usize); + let have = 800usize; // < 840 → the last row's window over-reads + let src: Vec = (0..have).map(|i| i as f32).collect(); + let st = Tensor::new(d.upload(&tb_f32(&src)).unwrap(), vec![have], DType::F32); + + let res = strided_col_copy(d, &st, s, stride, col_off, width); + eprintln!( + "[scc undersized s={s} stride={stride} col_off={col_off} width={width} have={have}] result = {:?}", + res.as_ref().map(|_| "Ok") + ); + let err = res + .err() + .expect("undersized strided_col_copy MUST return Err, not over-read"); + let msg = format!("{err:?}"); + assert!( + msg.contains("strided_col_copy OOB"), + "guard message should name the op: {msg}" + ); +} + +/// (c) Exact-fit boundary: need == have. Must NOT false-trip. +#[test] +fn strided_col_copy_exact_fit_no_false_trip_metal() { + let Some(dev) = MetalDevice::create().expect("metal init") else { + eprintln!("no Metal device — skipping"); + return; + }; + let d = dev.as_ref(); + // width is a multiple of 64 (the op's documented grid assumption: s*width + // must be divisible by the 64-thread block). need = (s-1)*stride + col_off + + // width = 3*200 + 30 + 64 = 694. Bind EXACTLY that many → need == have. + let (s, stride, col_off, width) = (4usize, 200usize, 30usize, 64usize); + let have = (s - 1) * stride + col_off + width; // 694 + let src: Vec = (0..have).map(|i| (i as f32 * 0.05).cos()).collect(); + let st = Tensor::new(d.upload(&tb_f32(&src)).unwrap(), vec![have], DType::F32); + + let out = strided_col_copy(d, &st, s, stride, col_off, width) + .expect("exact-fit strided_col_copy must NOT false-trip"); + let got = dl_f32(d, &out, s * width); + let want = scc_oracle(&src, s, stride, col_off, width); + let diff = max_abs_diff(&got, &want); + eprintln!("[scc EXACT s={s} stride={stride} col_off={col_off} width={width} have={have}] max|GPU-CPU| = {diff:e}"); + assert!(diff < 1e-6, "exact-fit strided_col_copy mismatch: {diff:e}"); +} diff --git a/rust/crates/backends/ffai-metal/tests/vit_ops.rs b/rust/crates/backends/ffai-metal/tests/vit_ops.rs new file mode 100644 index 00000000..c818c9f3 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/vit_ops.rs @@ -0,0 +1,64 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! LayerNorm + GELU(tanh) ops vs CPU reference — the two new primitives the +//! VLM vision tower (ViT / SigLIP / CLIP) needs beyond the LLM op set. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_ops::{gelu, layer_norm, matmul}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn gelu_tanh(x: f32) -> f32 { 0.5 * x * (1.0 + (0.7978845608 * (x + 0.044715 * x * x * x)).tanh()) } + +#[test] +fn layernorm_and_gelu_vs_cpu() { + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + let up = |v: &[f32], sh: Vec| Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32); + let dl = |t: &Tensor, n: usize| { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + + // LayerNorm over n=768 (SigLIP-base width), 2 rows. + let (rows, n, eps) = (2usize, 768usize, 1e-6f32); + let x: Vec = (0..rows * n).map(|i| ((i * 37 % 101) as f32 - 50.0) * 0.03).collect(); + let w: Vec = (0..n).map(|i| 1.0 + ((i % 11) as f32 - 5.0) * 0.02).collect(); + let b: Vec = (0..n).map(|i| ((i % 7) as f32 - 3.0) * 0.05).collect(); + let got = dl(&layer_norm(d, &up(&x, vec![rows, n]), &up(&w, vec![n]), &up(&b, vec![n]), eps).unwrap(), rows * n); + let mut e = 0.0f32; + for r in 0..rows { + let row = &x[r * n..(r + 1) * n]; + let mean = row.iter().sum::() / n as f32; + let var = row.iter().map(|v| (v - mean) * (v - mean)).sum::() / n as f32; + let is = 1.0 / (var + eps).sqrt(); + for i in 0..n { + let want = (row[i] - mean) * is * w[i] + b[i]; + e = e.max((got[r * n + i] - want).abs()); + } + } + eprintln!("LayerNorm max|Δ| = {e:.2e}"); + assert!(e < 1e-4, "layer_norm mismatch {e}"); + + // GELU(tanh) + let xv: Vec = (0..512).map(|i| (i as f32 - 256.0) * 0.05).collect(); + let g = dl(&gelu(d, &up(&xv, vec![xv.len()])).unwrap(), xv.len()); + let mut eg = 0.0f32; + for i in 0..xv.len() { eg = eg.max((g[i] - gelu_tanh(xv[i])).abs()); } + eprintln!("GELU(tanh) max|Δ| = {eg:.2e}"); + assert!(eg < 1e-4, "gelu mismatch {eg}"); + + // matmul (prefill linear): out[r,:] = weight · input[r,:], weight[out,in], input[rows,in] + let (rows2, in_d, out_d) = (196usize, 768usize, 1024usize); // rows not mult of 32 (edge) + let wt: Vec = (0..out_d * in_d).map(|i| ((i * 13 % 97) as f32 - 48.0) * 0.01).collect(); + let inp: Vec = (0..rows2 * in_d).map(|i| ((i * 7 % 89) as f32 - 44.0) * 0.01).collect(); + let mm = dl(&matmul(d, &up(&wt, vec![out_d, in_d]), &up(&inp, vec![rows2, in_d])).unwrap(), rows2 * out_d); + let mut em = 0.0f32; + for r in [0usize, 95, 195] { + for o in [0usize, 500, 1023] { + let mut acc = 0.0f32; + for kk in 0..in_d { acc += wt[o * in_d + kk] * inp[r * in_d + kk]; } + em = em.max((mm[r * out_d + o] - acc).abs()); + } + } + eprintln!("matmul max|Δ| = {em:.2e}"); + assert!(em < 2e-3, "matmul mismatch {em}"); + eprintln!("✅ LayerNorm + GELU(tanh) + matmul match CPU on Apple GPU — ViT primitives ready."); +} diff --git a/rust/crates/backends/ffai-metal/tests/whisper.rs b/rust/crates/backends/ffai-metal/tests/whisper.rs new file mode 100644 index 00000000..72116a22 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/whisper.rs @@ -0,0 +1,144 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full real **audio encoder** (Whisper-tiny) forward on the shared engine, +//! verified vs HF transformers. The audio family's encoder = a conv front-end +//! (two Conv1d, done as im2col + `matmul`) + sinusoidal position embed + a +//! bidirectional transformer (LayerNorm + full self-attention + GELU-MLP) — +//! the same shared op set as the VLM tower. Whisper uses exact-erf GELU +//! (`activation_function="gelu"`), applied host-side here (the device +//! GELU-tanh op is verified separately for the VLM tower). Heavy projections +//! on the device `matmul`, attention on `sdpa_decode` (full/bidirectional, +//! looped per query), LayerNorm on device. Deterministic synthetic mel input. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +// erf via Abramowitz-Stegun 7.1.26 (~1.5e-7), then exact GELU = 0.5x(1+erf(x/√2)). +fn erf(x: f32) -> f32 { + let s = x.signum(); let x = x.abs(); + let t = 1.0 / (1.0 + 0.3275911 * x); + let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * (-x * x).exp(); + s * y +} +fn gelu_erf(x: f32) -> f32 { 0.5 * x * (1.0 + erf(x * std::f32::consts::FRAC_1_SQRT_2)) } + +#[test] +fn whisper_tiny_encoder_vs_hf() { + let dir = std::env::var("WHISPER_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + + // config (whisper-tiny) + let (mel, t_in, dm, n_layers, nh, hd, ffn, eps) = + (80usize, 3000usize, 384usize, 4usize, 6usize, 64usize, 1536usize, 1e-5f32); + let t_out = t_in / 2; // 1500 after conv2 stride-2 + let scale = 1.0 / (hd as f32).sqrt(); + + let g = |name: &str| -> Vec { let (b, dt, _s) = st.tensor(name).unwrap(); assert_eq!(dt, DType::F32, "{name}"); fb(b) }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], bias: &[f32], rows: usize, cols: usize| { + for r in 0..rows { for c in 0..cols { m[r * cols + c] += bias[c]; } } + }; + + // mel input [mel, t_in], deterministic + let feat: Vec = (0..mel * t_in).map(|i| (0.01 * i as f32).sin()).collect(); + + // ── conv1: Conv1d(80,384,k=3,pad=1,stride=1) → [t_in, 384] (im2col + matmul) ── + let k = 3usize; + let mut col1 = vec![0.0f32; t_in * (mel * k)]; + for t in 0..t_in { for c in 0..mel { for kk in 0..k { + let pos = t as isize - 1 + kk as isize; // pad=1 + if pos >= 0 && (pos as usize) < t_in { col1[t * (mel * k) + c * k + kk] = feat[c * t_in + pos as usize]; } + }}} + let w1 = g("model.encoder.conv1.weight"); // [384,80,3] = [384, 240] + let mut c1 = dl(&matmul(d, &up(&w1, vec![dm, mel * k]), &up(&col1, vec![t_in, mel * k])).unwrap(), t_in * dm); + add_bias(&mut c1, &g("model.encoder.conv1.bias"), t_in, dm); + for v in c1.iter_mut() { *v = gelu_erf(*v); } // c1: [t_in, 384], access [t][c] + + // ── conv2: Conv1d(384,384,k=3,pad=1,stride=2) → [t_out, 384] ── + let mut col2 = vec![0.0f32; t_out * (dm * k)]; + for j in 0..t_out { for c in 0..dm { for kk in 0..k { + let pos = 2 * j as isize - 1 + kk as isize; // stride 2, pad 1 + if pos >= 0 && (pos as usize) < t_in { col2[j * (dm * k) + c * k + kk] = c1[pos as usize * dm + c]; } + }}} + let w2 = g("model.encoder.conv2.weight"); // [384,384,3] = [384, 1152] + let mut c2 = dl(&matmul(d, &up(&w2, vec![dm, dm * k]), &up(&col2, vec![t_out, dm * k])).unwrap(), t_out * dm); + add_bias(&mut c2, &g("model.encoder.conv2.bias"), t_out, dm); + for v in c2.iter_mut() { *v = gelu_erf(*v); } + + // + sinusoidal position embedding + let pos = g("model.encoder.embed_positions.weight"); // [1500,384] + let mut x = c2; + for i in 0..t_out * dm { x[i] += pos[i]; } + + // ── encoder layers (pre-norm) ── + let np = t_out; + for l in 0..n_layers { + let p = format!("model.encoder.layers.{l}"); + // self-attention (k_proj has NO bias in Whisper) + let ln1 = layer_norm(d, &up(&x, vec![np, dm]), + &up(&g(&format!("{p}.self_attn_layer_norm.weight")), vec![dm]), + &up(&g(&format!("{p}.self_attn_layer_norm.bias")), vec![dm]), eps).unwrap(); + let mut q = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![dm, dm]), &ln1).unwrap(), np * dm); + let k_h = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![dm, dm]), &ln1).unwrap(), np * dm); + let mut v = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![dm, dm]), &ln1).unwrap(), np * dm); + add_bias(&mut q, &g(&format!("{p}.self_attn.q_proj.bias")), np, dm); + add_bias(&mut v, &g(&format!("{p}.self_attn.v_proj.bias")), np, dm); + let mut kb = vec![0.0f32; nh * np * hd]; + let mut vb = vec![0.0f32; nh * np * hd]; + for t in 0..np { for h in 0..nh { for dd in 0..hd { + kb[h * np * hd + t * hd + dd] = k_h[t * dm + h * hd + dd]; + vb[h * np * hd + t * hd + dd] = v[t * dm + h * hd + dd]; + }}} + let kt = up(&kb, vec![nh, np, hd]); + let vt = up(&vb, vec![nh, np, hd]); + let mut attn = vec![0.0f32; np * dm]; + for t in 0..np { + let qt = up(&q[t * dm..(t + 1) * dm], vec![nh, hd]); + let a = sdpa_decode(d, &qt, &kt, &vt, hd, np as u32, np as u32, 1, scale).unwrap(); + attn[t * dm..(t + 1) * dm].copy_from_slice(&dl(&a, dm)); + } + let mut o = dl(&matmul(d, &up(&g(&format!("{p}.self_attn.out_proj.weight")), vec![dm, dm]), &up(&attn, vec![np, dm])).unwrap(), np * dm); + add_bias(&mut o, &g(&format!("{p}.self_attn.out_proj.bias")), np, dm); + for i in 0..np * dm { x[i] += o[i]; } + + // GELU-MLP (exact-erf) + let ln2 = layer_norm(d, &up(&x, vec![np, dm]), + &up(&g(&format!("{p}.final_layer_norm.weight")), vec![dm]), + &up(&g(&format!("{p}.final_layer_norm.bias")), vec![dm]), eps).unwrap(); + let mut h1 = dl(&matmul(d, &up(&g(&format!("{p}.fc1.weight")), vec![ffn, dm]), &ln2).unwrap(), np * ffn); + add_bias(&mut h1, &g(&format!("{p}.fc1.bias")), np, ffn); + for vv in h1.iter_mut() { *vv = gelu_erf(*vv); } + let mut h2 = dl(&matmul(d, &up(&g(&format!("{p}.fc2.weight")), vec![dm, ffn]), &up(&h1, vec![np, ffn])).unwrap(), np * dm); + add_bias(&mut h2, &g(&format!("{p}.fc2.bias")), np, dm); + for i in 0..np * dm { x[i] += h2[i]; } + } + + // final encoder layer_norm = last_hidden_state + let lhs = dl(&layer_norm(d, &up(&x, vec![np, dm]), + &up(&g("model.encoder.layer_norm.weight"), vec![dm]), + &up(&g("model.encoder.layer_norm.bias"), vec![dm]), eps).unwrap(), np * dm); + + let want0 = [0.07884f32, 0.0372, 0.39875, 0.4143, -1.18014]; + let want750 = [1.36551f32, -1.5299, 1.39363, 1.85176, -1.30956]; + let mut e = 0.0f32; + for i in 0..5 { e = e.max((lhs[i] - want0[i]).abs()); } + for i in 0..5 { e = e.max((lhs[750 * dm + i] - want750[i]).abs()); } + let sum: f32 = lhs.iter().sum(); + eprintln!("Whisper-tiny encoder on Metal: ENC[0,:5]={:?}", &lhs[..5]); + eprintln!(" ENC[750,:5]={:?} sum={sum:.2} (HF=12390.46) max|Δ| first-rows={e:.3e}", &lhs[750 * dm..750 * dm + 5]); + assert!(e < 3e-2, "Whisper encoder mismatch vs HF: max|Δ|={e:.3e}"); + assert!((sum - 12390.46).abs() < 50.0, "Whisper encoder sum off: {sum}"); + eprintln!("✅ Full real Whisper-tiny audio encoder matches HF on the shared engine (Apple GPU) — audio half verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--openai--whisper-tiny/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-metal/tests/whisper_base.rs b/rust/crates/backends/ffai-metal/tests/whisper_base.rs new file mode 100644 index 00000000..8115a327 --- /dev/null +++ b/rust/crates/backends/ffai-metal/tests/whisper_base.rs @@ -0,0 +1,154 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Full real Whisper-base **encoder→decoder** STT forward (first decoder step), +//! verified vs HF. Adds the **cross-attention** mechanism: the decoder's query +//! attends over the encoder's 1500 output states (K/V from the encoder, per +//! decoder layer) — the same pattern every encoder-decoder and many VLMs use. +//! Encoder is the same conv-front-end + bidirectional transformer verified for +//! whisper-tiny (here d512/6L/8H). Decoder layer = causal self-attn (n_kv=1 at +//! pos 0) + cross-attn (n_kv=1500) + exact-erf GELU MLP, pre-norm, tied lm_head. +//! All on the shared op layer; exact-erf GELU host-side. +use ffai_core::{DType, Tensor}; +use ffai_metal::MetalDevice; +use ffai_loader::SafeTensors; +use ffai_ops::{add, gemv, layer_norm, matmul, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } +fn erf(x: f32) -> f32 { + let s = x.signum(); let x = x.abs(); + let t = 1.0 / (1.0 + 0.3275911 * x); + let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * (-x * x).exp(); + s * y +} +fn gelu_erf(x: f32) -> f32 { 0.5 * x * (1.0 + erf(x * std::f32::consts::FRAC_1_SQRT_2)) } + +#[test] +fn whisper_base_stt_vs_hf() { + let dir = std::env::var("WHISPER_BASE_DIR").unwrap_or_else(|_| glob_snap().unwrap_or_default()); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let Some(dev) = MetalDevice::create().expect("metal") else { eprintln!("no Metal — skip"); return; }; + let d = dev.as_ref(); + + let (mel, t_in, dm, nh, hd, ffn, eps) = (80usize, 3000usize, 512usize, 8usize, 64usize, 2048usize, 1e-5f32); + let (enc_layers, dec_layers, vocab) = (6usize, 6usize, 51865usize); + let t_out = t_in / 2; // 1500 + let scale = 1.0 / (hd as f32).sqrt(); + + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let add_bias = |m: &mut [f32], b: &[f32], rows: usize, cols: usize| { for r in 0..rows { for c in 0..cols { m[r * cols + c] += b[c]; } } }; + // reorg [n, dm] → per-head KV cache [nh, n, hd] + let reorg = |m: &[f32], n: usize| -> Vec { + let mut o = vec![0.0f32; nh * n * hd]; + for t in 0..n { for h in 0..nh { for dd in 0..hd { o[h * n * hd + t * hd + dd] = m[t * dm + h * hd + dd]; } } } + o + }; + + // ════ ENCODER ════ + let feat: Vec = (0..mel * t_in).map(|i| (0.01 * i as f32).sin()).collect(); + let k = 3usize; + // conv1 (80→512, k3 pad1 stride1) + let mut col1 = vec![0.0f32; t_in * (mel * k)]; + for t in 0..t_in { for c in 0..mel { for kk in 0..k { + let pos = t as isize - 1 + kk as isize; + if pos >= 0 && (pos as usize) < t_in { col1[t * (mel * k) + c * k + kk] = feat[c * t_in + pos as usize]; } + }}} + let mut c1 = dl(&matmul(d, &up(&g("model.encoder.conv1.weight"), vec![dm, mel * k]), &up(&col1, vec![t_in, mel * k])).unwrap(), t_in * dm); + add_bias(&mut c1, &g("model.encoder.conv1.bias"), t_in, dm); + for v in c1.iter_mut() { *v = gelu_erf(*v); } + // conv2 (512→512, k3 pad1 stride2) + let mut col2 = vec![0.0f32; t_out * (dm * k)]; + for j in 0..t_out { for c in 0..dm { for kk in 0..k { + let pos = 2 * j as isize - 1 + kk as isize; + if pos >= 0 && (pos as usize) < t_in { col2[j * (dm * k) + c * k + kk] = c1[pos as usize * dm + c]; } + }}} + let mut enc = dl(&matmul(d, &up(&g("model.encoder.conv2.weight"), vec![dm, dm * k]), &up(&col2, vec![t_out, dm * k])).unwrap(), t_out * dm); + add_bias(&mut enc, &g("model.encoder.conv2.bias"), t_out, dm); + for v in enc.iter_mut() { *v = gelu_erf(*v); } + let epos = g("model.encoder.embed_positions.weight"); + for i in 0..t_out * dm { enc[i] += epos[i]; } + // full self-attention over `src` [n,dm] (normed); returns attn output [n*dm] + let self_attn = |normed: &[f32], pfx: &str, n: usize| -> Vec { + let mut q = dl(&matmul(d, &up(&g(&format!("{pfx}.q_proj.weight")), vec![dm, dm]), &up(normed, vec![n, dm])).unwrap(), n * dm); + add_bias(&mut q, &g(&format!("{pfx}.q_proj.bias")), n, dm); + let kk = dl(&matmul(d, &up(&g(&format!("{pfx}.k_proj.weight")), vec![dm, dm]), &up(normed, vec![n, dm])).unwrap(), n * dm); // no bias + let mut vv = dl(&matmul(d, &up(&g(&format!("{pfx}.v_proj.weight")), vec![dm, dm]), &up(normed, vec![n, dm])).unwrap(), n * dm); + add_bias(&mut vv, &g(&format!("{pfx}.v_proj.bias")), n, dm); + let kt = up(&reorg(&kk, n), vec![nh, n, hd]); let vt = up(&reorg(&vv, n), vec![nh, n, hd]); + let mut out = vec![0.0f32; n * dm]; + for t in 0..n { + let qt = up(&q[t * dm..(t + 1) * dm], vec![nh, hd]); + let a = sdpa_decode(d, &qt, &kt, &vt, hd, n as u32, n as u32, 1, scale).unwrap(); + out[t * dm..(t + 1) * dm].copy_from_slice(&dl(&a, dm)); + } + let mut o = dl(&matmul(d, &up(&g(&format!("{pfx}.out_proj.weight")), vec![dm, dm]), &up(&out, vec![n, dm])).unwrap(), n * dm); + add_bias(&mut o, &g(&format!("{pfx}.out_proj.bias")), n, dm); + o + }; + for l in 0..enc_layers { + let p = format!("model.encoder.layers.{l}"); + let ln = dl(&layer_norm(d, &up(&enc, vec![t_out, dm]), &up(&g(&format!("{p}.self_attn_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.self_attn_layer_norm.bias")), vec![dm]), eps).unwrap(), t_out * dm); + let o = self_attn(&ln, &format!("{p}.self_attn"), t_out); + for i in 0..t_out * dm { enc[i] += o[i]; } + let ln2 = dl(&layer_norm(d, &up(&enc, vec![t_out, dm]), &up(&g(&format!("{p}.final_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.final_layer_norm.bias")), vec![dm]), eps).unwrap(), t_out * dm); + let mut h1 = dl(&matmul(d, &up(&g(&format!("{p}.fc1.weight")), vec![ffn, dm]), &up(&ln2, vec![t_out, dm])).unwrap(), t_out * ffn); + add_bias(&mut h1, &g(&format!("{p}.fc1.bias")), t_out, ffn); + for v in h1.iter_mut() { *v = gelu_erf(*v); } + let mut h2 = dl(&matmul(d, &up(&g(&format!("{p}.fc2.weight")), vec![dm, ffn]), &up(&h1, vec![t_out, ffn])).unwrap(), t_out * dm); + add_bias(&mut h2, &g(&format!("{p}.fc2.bias")), t_out, dm); + for i in 0..t_out * dm { enc[i] += h2[i]; } + } + let enc_out = dl(&layer_norm(d, &up(&enc, vec![t_out, dm]), &up(&g("model.encoder.layer_norm.weight"), vec![dm]), &up(&g("model.encoder.layer_norm.bias"), vec![dm]), eps).unwrap(), t_out * dm); + + // ════ DECODER (single SOT token at pos 0) ════ + let sot = 50258usize; + let demb = g("model.decoder.embed_tokens.weight"); + let dpos = g("model.decoder.embed_positions.weight"); + let mut x: Vec = (0..dm).map(|i| demb[sot * dm + i] + dpos[i]).collect(); + let dproj = |w: &str, b: Option<&str>, x: &Tensor, m: usize, inn: usize| -> Vec { + let mut o = dl(&gemv(d, &up(&g(w), vec![m, inn]), x).unwrap(), m); + if let Some(bb) = b { let bv = g(bb); for i in 0..m { o[i] += bv[i]; } } + o + }; + for l in 0..dec_layers { + let p = format!("model.decoder.layers.{l}"); + // causal self-attn (n_kv=1 at pos0) + let h = layer_norm(d, &up(&x, vec![dm]), &up(&g(&format!("{p}.self_attn_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.self_attn_layer_norm.bias")), vec![dm]), eps).unwrap(); + let q = dproj(&format!("{p}.self_attn.q_proj.weight"), Some(&format!("{p}.self_attn.q_proj.bias")), &h, dm, dm); + let kk = dproj(&format!("{p}.self_attn.k_proj.weight"), None, &h, dm, dm); + let vv = dproj(&format!("{p}.self_attn.v_proj.weight"), Some(&format!("{p}.self_attn.v_proj.bias")), &h, dm, dm); + let sa = sdpa_decode(d, &up(&q, vec![nh, hd]), &up(&kk, vec![nh, hd]), &up(&vv, vec![nh, hd]), hd, 1, 1, 1, scale).unwrap(); + let o = dproj(&format!("{p}.self_attn.out_proj.weight"), Some(&format!("{p}.self_attn.out_proj.bias")), &sa.reshaped(vec![dm]), dm, dm); + for i in 0..dm { x[i] += o[i]; } + // cross-attn (q from decoder, K/V from encoder output) + let hc = layer_norm(d, &up(&x, vec![dm]), &up(&g(&format!("{p}.encoder_attn_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.encoder_attn_layer_norm.bias")), vec![dm]), eps).unwrap(); + let qc = dproj(&format!("{p}.encoder_attn.q_proj.weight"), Some(&format!("{p}.encoder_attn.q_proj.bias")), &hc, dm, dm); + let kc = dl(&matmul(d, &up(&g(&format!("{p}.encoder_attn.k_proj.weight")), vec![dm, dm]), &up(&enc_out, vec![t_out, dm])).unwrap(), t_out * dm); // no bias + let mut vc = dl(&matmul(d, &up(&g(&format!("{p}.encoder_attn.v_proj.weight")), vec![dm, dm]), &up(&enc_out, vec![t_out, dm])).unwrap(), t_out * dm); + add_bias(&mut vc, &g(&format!("{p}.encoder_attn.v_proj.bias")), t_out, dm); + let kt = up(&reorg(&kc, t_out), vec![nh, t_out, hd]); let vt = up(&reorg(&vc, t_out), vec![nh, t_out, hd]); + let ca = sdpa_decode(d, &up(&qc, vec![nh, hd]), &kt, &vt, hd, t_out as u32, t_out as u32, 1, scale).unwrap(); + let oc = dproj(&format!("{p}.encoder_attn.out_proj.weight"), Some(&format!("{p}.encoder_attn.out_proj.bias")), &ca.reshaped(vec![dm]), dm, dm); + for i in 0..dm { x[i] += oc[i]; } + // MLP + let hm = layer_norm(d, &up(&x, vec![dm]), &up(&g(&format!("{p}.final_layer_norm.weight")), vec![dm]), &up(&g(&format!("{p}.final_layer_norm.bias")), vec![dm]), eps).unwrap(); + let mut f = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.fc1.weight")), vec![ffn, dm]), &hm).unwrap(), &up(&g(&format!("{p}.fc1.bias")), vec![ffn])).unwrap(), ffn); + for v in f.iter_mut() { *v = gelu_erf(*v); } + let m2 = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.fc2.weight")), vec![dm, ffn]), &up(&f, vec![ffn])).unwrap(), &up(&g(&format!("{p}.fc2.bias")), vec![dm])).unwrap(), dm); + for i in 0..dm { x[i] += m2[i]; } + } + let xf = layer_norm(d, &up(&x, vec![dm]), &up(&g("model.decoder.layer_norm.weight"), vec![dm]), &up(&g("model.decoder.layer_norm.bias"), vec![dm]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&demb, vec![vocab, dm]), &xf).unwrap(), vocab); // tied proj_out + let argmax = (0..vocab).max_by(|&a, &b| logits[a].total_cmp(&logits[b])).unwrap(); + eprintln!("Whisper-base STT on Metal: argmax = {argmax} (HF = 50362)"); + assert_eq!(argmax, 50362, "Whisper-base STT argmax != HF 50362"); + eprintln!("✅ Full real Whisper-base encoder→decoder STT matches HF on the shared engine (Apple GPU) — cross-attention path verified."); +} + +fn glob_snap() -> Option { + let base = format!("{}/.cache/huggingface/hub/models--openai--whisper-base/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} diff --git a/rust/crates/backends/ffai-vulkan/Cargo.toml b/rust/crates/backends/ffai-vulkan/Cargo.toml new file mode 100644 index 00000000..83257d30 --- /dev/null +++ b/rust/crates/backends/ffai-vulkan/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ffai-vulkan" +description = "FFAI Vulkan backend — wraps metaltile-runtime's VulkanDevice (GlslGenerator -> shaderc -> SPIR-V -> VkPipeline) behind the ffai-core Device trait. The portable GPU path for non-CUDA / non-Apple hardware." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +ffai-core.workspace = true +metaltile-core = { workspace = true, optional = true } +metaltile-runtime = { workspace = true, optional = true } + +[dev-dependencies] +ffai-core.workspace = true +metaltile-core.workspace = true +ffai-models.workspace = true +ffai-loader.workspace = true +ffai-runtime.workspace = true + +[features] +# Off by default so macOS / non-Vulkan hosts build the crate as a stub. +# `--features vulkan` pulls metaltile-runtime's real Vulkan device. +vulkan = ["dep:metaltile-core", "dep:metaltile-runtime", "metaltile-runtime/vulkan"] + +[[test]] +name = "vulkan_smoke" +required-features = ["vulkan"] + +[[test]] +name = "qwen25_gguf_vulkan" +required-features = ["vulkan"] + +[[test]] +name = "qwen25_first_logit_diag" +required-features = ["vulkan"] + +[[test]] +name = "qwen25_prefill_coopmat" +required-features = ["vulkan"] diff --git a/rust/crates/backends/ffai-vulkan/src/imp.rs b/rust/crates/backends/ffai-vulkan/src/imp.rs new file mode 100644 index 00000000..6b6fd4f3 --- /dev/null +++ b/rust/crates/backends/ffai-vulkan/src/imp.rs @@ -0,0 +1,596 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! Real Vulkan backend (compiled under `--features vulkan`). Wraps +//! `metaltile_runtime::VulkanDevice` — the GlslGenerator → shaderc → SPIR-V → +//! VkPipeline path that runs the kernel corpus bit-accurately on any Vulkan +//! 1.2+ device (validated on AMD RX 9070 XT / RDNA4) — behind the shared +//! [`ffai_core::Device`] trait. +//! +//! ## Two buffer models: resident (default, fast) + host-shadow (fallback) +//! +//! The CUDA backend keeps tensors resident in VRAM: `alloc`/`upload` return a +//! raw device handle, and `dispatch` binds those pre-uploaded buffers to a +//! cached pipeline. This backend now mirrors that, using the metaltile Vulkan +//! resident seam (`alloc_raw` / `htod_raw` / `dtoh_raw` / `compile_kernel` / +//! `run_pipeline_bound`): +//! +//! * **Resident path (default).** `upload`/`alloc` allocate a persistent +//! `VulkanRawBuffer` in VRAM and copy the host bytes in ONCE. `dispatch` +//! looks up a per-(kernel,dims) cached `VulkanPipeline` (compiled the +//! first time only), binds the already-resident buffers, and dispatches +//! via `run_pipeline_bound` — no per-op weight re-upload, no per-op +//! pipeline recompile. This is the CUDA-style residency model. +//! +//! * **Host-shadow path (fallback, `FFAI_VULKAN_HOST_SHADOW=1`).** The +//! original correctness path: buffers are `Vec` in RAM, and each +//! `dispatch` streams every bound buffer to VRAM, recompiles the pipeline, +//! runs `run_kernel`, and reads the outputs back. Correct but slow +//! (re-uploads weights + recompiles every dispatch). Kept as a safety net +//! / A-B reference. +//! +//! Both paths produce identical results; the resident path just eliminates the +//! per-dispatch host↔device round-trips that dominate decode latency. +//! +//! ## Batched submission (resident path only, default on) +//! +//! On top of residency, the resident path BATCHES dispatches: instead of one +//! `vkQueueSubmit` + `vkQueueWaitIdle` per op, `dispatch` records each op into a +//! pending queue and a single command buffer carries many dispatches (with a +//! shader-write→read barrier between them) to ONE submit + wait. The queue is +//! flushed at a size cap (`FFAI_VULKAN_BATCH_CAP`, default 48 — ~two +//! transformer layers, bounded by the metaltile descriptor pool), at +//! `synchronize`/`download` (a read must see the results), and on device drop. +//! This removes the per-op CPU↔GPU round-trip that dominates decode latency — +//! the single biggest decode lever once weights are resident. Disable with +//! `FFAI_VULKAN_BATCH=0` to fall back to per-op `run_pipeline_bound`. + +use std::any::Any; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex}; + +use ffai_core::{Backend, Binding, Device, DeviceBuffer, Error, Grid, Kernel, Result}; +use metaltile_core::ir::ParamKind; +use metaltile_runtime::{ + BatchDispatch, MetalTileError, VulkanDevice as MtVulkanDevice, VulkanPipeline, VulkanRawBuffer, +}; + +fn dispatch_err(e: MetalTileError) -> Error { + Error::Dispatch(e.to_string()) +} + +/// Whether to batch many dispatches into ONE `vkQueueSubmit` + `vkQueueWaitIdle` +/// (the default) instead of submitting + waiting per op. On by default; set +/// `FFAI_VULKAN_BATCH=0` to force the per-op `run_pipeline_bound` path (an A-B +/// reference / debugging escape hatch). +fn batch_enabled() -> bool { + std::env::var("FFAI_VULKAN_BATCH").map(|v| v == "0" || v.is_empty()).map(|off| !off).unwrap_or(true) +} + +/// Max dispatches accumulated before an automatic flush. Bounds the live +/// descriptor sets (the metaltile pool holds 1024) and the transient companion +/// VRAM held until submit, so a long forward pass can't exhaust either. A +/// transformer layer is ~15-25 dispatches, so this batches roughly two layers +/// per submit while staying well inside the pool. Override with +/// `FFAI_VULKAN_BATCH_CAP`. +fn batch_cap() -> usize { + std::env::var("FFAI_VULKAN_BATCH_CAP") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n > 0 && *n <= 512) + .unwrap_or(48) +} + +/// One dispatch deferred for batched submission. Owns everything the batch +/// needs alive until the single submit completes: the cached pipeline, the +/// resident buffer handles (plan order), push bytes, grid, the transient +/// strided-companion buffers to free post-flush, and `Arc`s to the bound +/// device buffers so their VRAM is NOT released by `Drop` mid-batch. +struct QueuedDispatch { + pipeline: Arc, + bufs: Vec, + push: Vec, + grid: [u32; 3], + transient: Vec, + _keepalive: Vec>, +} + +/// Whether to use the slow host-shadow path instead of the resident path. +/// Off by default (resident); set `FFAI_VULKAN_HOST_SHADOW=1` to force the +/// original streaming path for A-B comparison / debugging. +fn host_shadow_enabled() -> bool { + std::env::var("FFAI_VULKAN_HOST_SHADOW").map(|v| v != "0" && !v.is_empty()).unwrap_or(false) +} + +/// Synthesize a Strided param's `_shape` / `_strides` companion buffer +/// (row-major) from the param's static shape — mirrors metaltile's own +/// `synth_strided_meta` so the resident binding layout matches the emitter. +fn synth_strided_meta(shape: &metaltile_core::shape::Shape, strides: bool) -> Vec { + use metaltile_core::shape::Dim; + let dims: Vec = (0..shape.rank()) + .map(|i| match shape.dim(i) { + Some(Dim::Known(n)) => *n as u32, + _ => 1, + }) + .collect(); + let vals: Vec = if strides { + let mut s = vec![1u32; dims.len()]; + for i in (0..dims.len().saturating_sub(1)).rev() { + s[i] = s[i + 1] * dims[i + 1]; + } + s + } else { + dims + }; + vals.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +/// Vulkan implementation of the shared [`Device`] trait. Holds the metaltile +/// runtime device in an `Arc` so it outlives every buffer/dispatch, plus the +/// resident pipeline cache. +pub struct VulkanDevice { + dev: Arc, + name: String, + host_shadow: bool, + /// Cache of compiled pipelines keyed by a structural signature of the + /// kernel (name + params + constexprs + mode + block). Identical signature + /// ⟺ identical generated GLSL ⟺ reusable pipeline. + pipelines: Mutex>>, + /// Whether to defer dispatches into a single batched submit. + batch: bool, + /// Auto-flush threshold for the pending queue. + batch_cap: usize, + /// Dispatches recorded but not yet submitted. Flushed at the cap, at + /// `synchronize`/`download` (a read needs the results), and on device drop. + pending: Mutex>, +} + +impl VulkanDevice { + /// Probe for a Vulkan device; `Ok(None)` if no loader / device is present. + pub fn create() -> Result>> { + match MtVulkanDevice::create().map_err(dispatch_err)? { + Some(d) => { + let host_shadow = host_shadow_enabled(); + let mode = if host_shadow { "host-shadow" } else { "resident" }; + let name = format!("Vulkan device (qfam={}, {mode})", d.queue_family()); + let batch = !host_shadow && batch_enabled(); + Ok(Some(Arc::new(VulkanDevice { + dev: Arc::new(d), + name, + host_shadow, + pipelines: Mutex::new(HashMap::new()), + batch, + batch_cap: batch_cap(), + pending: Mutex::new(Vec::new()), + }))) + } + None => Ok(None), + } + } + + /// Structural cache key for a (kernel, block) pair. Built from the exact + /// data GLSL codegen consumes, so equal keys guarantee identical shaders. + fn pipeline_key(kernel: &Kernel, block: [u32; 3]) -> String { + let mut k = format!("{}|blk={:?}|mode={:?}|", kernel.name, block, kernel.mode); + for p in &kernel.params { + k.push_str(&format!( + "P({},{:?},{:?},{},{:?});", + p.name, p.dtype, p.kind, p.is_output, p.shape + )); + } + for ce in &kernel.constexprs { + k.push_str(&format!("C({},{:?});", ce.name.name(), ce.dtype)); + } + k + } + + /// Get-or-compile the cached pipeline for this kernel + block. + fn get_pipeline(&self, kernel: &Kernel, block: [u32; 3]) -> Result> { + let key = Self::pipeline_key(kernel, block); + if let Some(p) = self.pipelines.lock().unwrap().get(&key) { + return Ok(p.clone()); + } + let pipeline = self.dev.compile_kernel(kernel, block).map_err(dispatch_err)?; + let arc = Arc::new(pipeline); + self.pipelines.lock().unwrap().insert(key, arc.clone()); + Ok(arc) + } + + /// Resident dispatch: bind pre-uploaded VRAM buffers to the cached + /// pipeline and launch — no weight re-upload, no recompile. When batching + /// is enabled the dispatch is recorded into the pending queue and submitted + /// later as part of a single multi-dispatch command buffer; otherwise it is + /// submitted immediately via `run_pipeline_bound`. + fn dispatch_resident(&self, kernel: &Kernel, bindings: &[Binding], grid: Grid) -> Result<()> { + let pipeline = self.get_pipeline(kernel, grid.block)?; + + // Build the binding list in plan order. Param data buffers come from + // the resident bindings; Strided companions are synthesized + uploaded + // as transient resident buffers (tiny, freed after the dispatch). + let mut bufs: Vec = Vec::new(); + // Transient companion buffers, owned so they live until the dispatch + // (or the whole batch) is submitted, then freed. + let mut transient: Vec = Vec::new(); + // Bound device buffers kept alive until submit so their `Drop` cannot + // free the underlying VRAM out from under an in-flight batch. + let mut keepalive: Vec> = Vec::new(); + + // Push-constant payload: constexprs in order, then `_n_elems` if the + // plan needs it (Elementwise) — mirrors metaltile's `run_kernel`. + let mut push: Vec = Vec::new(); + + let mut param_i = 0usize; + let mut const_i = 0usize; + let mut n_elems: u32 = 0; + for b in bindings { + match b { + Binding::Buffer(buf) => { + let p = kernel.params.get(param_i).ok_or_else(|| { + Error::Msg(format!( + "dispatch: more buffer bindings than kernel params ({})", + kernel.params.len() + )) + })?; + let vb = buf + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::Msg("dispatch: binding is not a VulkanBuffer".into()) + })?; + let raw = vb.resident().ok_or_else(|| { + Error::Msg("dispatch(resident): buffer has no resident handle".into()) + })?; + // Track _n_elems from the output param (element count). + if p.is_output { + let esz = p.dtype.size_bytes().max(1); + n_elems = (vb.len() / esz) as u32; + } + bufs.push(raw); + keepalive.push(buf.clone()); + + // Strided params carry shape/strides companion SSBOs. + if matches!(p.kind, ParamKind::Strided) { + for is_strides in [false, true] { + let meta = synth_strided_meta(&p.shape, is_strides); + let t = self.dev.alloc_raw(meta.len()).map_err(dispatch_err)?; + self.dev.htod_raw(&t, &meta).map_err(dispatch_err)?; + transient.push(t); + bufs.push(t); + } + } + param_i += 1; + } + Binding::Scalar(scalar_bytes) => { + // Scalars map to constexprs → push constants, in order. + let _ce = kernel.constexprs.get(const_i).ok_or_else(|| { + Error::Msg(format!( + "dispatch: more scalar bindings than kernel constexprs ({})", + kernel.constexprs.len() + )) + })?; + push.extend_from_slice(scalar_bytes); + const_i += 1; + } + } + } + + // Append synthetic `_n_elems` for Elementwise kernels (plan.has_n_elems). + if pipeline.plan.has_n_elems { + push.extend_from_slice(&n_elems.to_le_bytes()); + } + + if self.batch { + // Defer: queue the dispatch. Flush when the cap is hit so live + // descriptor sets + transient VRAM stay bounded. + let mut q = self.pending.lock().unwrap(); + q.push(QueuedDispatch { + pipeline, + bufs, + push, + grid: grid.grid, + transient, + _keepalive: keepalive, + }); + let full = q.len() >= self.batch_cap; + drop(q); + if full { + self.flush()?; + } + return Ok(()); + } + + // Per-op (unbatched) path. + let buf_refs: Vec<&VulkanRawBuffer> = bufs.iter().collect(); + let res = self + .dev + .run_pipeline_bound(&pipeline, &buf_refs, &push, grid.grid) + .map_err(dispatch_err); + for t in &transient { + self.dev.free_raw(t); + } + res + } + + /// Submit every queued dispatch in ONE command buffer (single + /// `vkQueueSubmit` + `vkQueueWaitIdle`), then free the batch's transient + /// companion buffers and release the kept-alive bound buffers. A no-op when + /// the queue is empty. + fn flush(&self) -> Result<()> { + let queued: Vec = { + let mut q = self.pending.lock().unwrap(); + if q.is_empty() { + return Ok(()); + } + std::mem::take(&mut *q) + }; + + let items: Vec = queued + .iter() + .map(|qd| BatchDispatch { + pipeline: &qd.pipeline, + bufs: qd.bufs.clone(), + push: qd.push.clone(), + grid: qd.grid, + }) + .collect(); + + let res = self.dev.run_pipeline_batch(&items).map_err(dispatch_err); + + // Free transient companions and drop keepalive Arcs after submit + // completed (run_pipeline_batch waits idle), regardless of outcome. + for qd in &queued { + for t in &qd.transient { + self.dev.free_raw(t); + } + } + drop(items); + drop(queued); + res + } + + /// Host-shadow dispatch (fallback): stream every bound buffer to VRAM, + /// recompile, run, read back. The original correctness path. + fn dispatch_host_shadow( + &self, + kernel: &Kernel, + bindings: &[Binding], + grid: Grid, + ) -> Result<()> { + let mut buffers: BTreeMap> = BTreeMap::new(); + let mut output_shadows: Vec<(String, Arc)> = Vec::new(); + + let mut param_i = 0usize; + let mut const_i = 0usize; + for b in bindings { + match b { + Binding::Buffer(buf) => { + let p = kernel.params.get(param_i).ok_or_else(|| { + Error::Msg(format!( + "dispatch: more buffer bindings than kernel params ({})", + kernel.params.len() + )) + })?; + let vb = buf + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::Msg("dispatch: binding is not a VulkanBuffer".into()) + })?; + buffers.insert(p.name.clone(), vb.snapshot(self.dev.as_ref())); + if p.is_output { + output_shadows.push((p.name.clone(), buf.clone())); + } + debug_assert!(p.kind != ParamKind::Scalar, "scalar param bound as a buffer"); + param_i += 1; + } + Binding::Scalar(scalar_bytes) => { + let ce = kernel.constexprs.get(const_i).ok_or_else(|| { + Error::Msg(format!( + "dispatch: more scalar bindings than kernel constexprs ({})", + kernel.constexprs.len() + )) + })?; + buffers.insert(ce.name.name().to_string(), scalar_bytes.clone()); + const_i += 1; + } + } + } + + let out = self + .dev + .run_kernel(kernel, &buffers, grid.grid, grid.block) + .map_err(dispatch_err)?; + + for (name, buf) in output_shadows { + if let Some(result) = out.get(&name) { + let vb = buf.as_any().downcast_ref::().unwrap(); + vb.store_host(self.dev.as_ref(), result); + } + } + Ok(()) + } +} + +/// A device buffer. In resident mode it wraps a persistent `VulkanRawBuffer` +/// in VRAM (uploaded once); in host-shadow mode it is a `Vec` in RAM that +/// is streamed per-dispatch. The two are mutually exclusive per device. +pub struct VulkanBuffer { + /// Resident VRAM handle (Some in resident mode). + raw: Option, + /// Host shadow bytes (Some in host-shadow mode). + shadow: Option>>, + len: usize, + /// Device kept alive so `Drop` can free the resident VRAM allocation. + /// `None` in host-shadow mode (nothing to free). Cloning the `Arc` here + /// guarantees the `VkDevice` outlives every buffer that owns a handle. + dev: Option>, +} + +impl Drop for VulkanBuffer { + fn drop(&mut self) { + // Free the resident VRAM allocation so transient per-token activation + // tensors don't leak (a 16 GB card would OOM in a long decode loop + // otherwise). Host-shadow buffers just drop their `Vec`. + if let (Some(raw), Some(dev)) = (&self.raw, &self.dev) { + dev.free_raw(raw); + } + } +} + +impl VulkanBuffer { + fn resident(&self) -> Option { + self.raw + } + + /// Host-shadow: snapshot current bytes. In resident mode, read them back + /// from VRAM (only used by the host-shadow dispatch, which never holds + /// resident buffers — this branch is defensive). + fn snapshot(&self, dev: &MtVulkanDevice) -> Vec { + if let Some(s) = &self.shadow { + return s.lock().unwrap().clone(); + } + let mut out = vec![0u8; self.len]; + if let Some(raw) = &self.raw { + let _ = dev.dtoh_raw(raw, &mut out); + } + out + } + + /// Host-shadow: write output bytes back into the shadow (or VRAM). + fn store_host(&self, dev: &MtVulkanDevice, data: &[u8]) { + if let Some(s) = &self.shadow { + let mut g = s.lock().unwrap(); + let n = g.len().min(data.len()); + g[..n].copy_from_slice(&data[..n]); + return; + } + if let Some(raw) = &self.raw { + let _ = dev.htod_raw(raw, data); + } + } +} + +impl DeviceBuffer for VulkanBuffer { + fn len(&self) -> usize { + self.len + } + fn as_any(&self) -> &dyn Any { + self + } +} + +impl Device for VulkanDevice { + fn backend(&self) -> Backend { + Backend::Vulkan + } + + fn name(&self) -> &str { + &self.name + } + + fn alloc(&self, len: usize) -> Result> { + if self.host_shadow { + return Ok(Arc::new(VulkanBuffer { + raw: None, + shadow: Some(Mutex::new(vec![0u8; len])), + len, + dev: None, + })); + } + let raw = self.dev.alloc_raw(len).map_err(dispatch_err)?; + // Zero-initialize so an unwritten alloc reads as zeros (matches the + // host-shadow `vec![0u8; len]` semantics some kernels rely on). + if len > 0 { + let zeros = vec![0u8; len]; + self.dev.htod_raw(&raw, &zeros).map_err(dispatch_err)?; + } + Ok(Arc::new(VulkanBuffer { + raw: Some(raw), + shadow: None, + len, + dev: Some(self.dev.clone()), + })) + } + + fn upload(&self, bytes: &[u8]) -> Result> { + if self.host_shadow { + return Ok(Arc::new(VulkanBuffer { + raw: None, + shadow: Some(Mutex::new(bytes.to_vec())), + len: bytes.len(), + dev: None, + })); + } + // Uploaded buffers are model inputs (weights, embeddings, token ids): + // written ONCE here, then read by many dispatches and never read back to + // the host. Put them in fast DEVICE_LOCAL VRAM (staged) rather than the + // host-visible heap `alloc_raw` uses — on a discrete GPU host-visible + // memory is system RAM / a slow PCIe window (~12 GB/s for shader reads), + // and the decode GEMVs are weight-bandwidth-bound, so this is the single + // biggest decode lever (~39x faster weight reads, ~470 GB/s on RDNA4). + let raw = self + .dev + .alloc_raw_device_local(bytes) + .map_err(dispatch_err)?; + Ok(Arc::new(VulkanBuffer { + raw: Some(raw), + shadow: None, + len: bytes.len(), + dev: Some(self.dev.clone()), + })) + } + + fn download(&self, buf: &dyn DeviceBuffer, out: &mut [u8]) -> Result<()> { + // A read must observe all prior dispatches, so flush any pending batch + // (its single submit waits idle) before touching VRAM. + if self.batch { + self.flush()?; + } + let vb = buf + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::Msg("download: buffer is not a VulkanBuffer".into()))?; + if let Some(s) = &vb.shadow { + let g = s.lock().unwrap(); + let n = out.len().min(g.len()); + out[..n].copy_from_slice(&g[..n]); + return Ok(()); + } + if let Some(raw) = &vb.raw { + self.dev.dtoh_raw(raw, out).map_err(dispatch_err)?; + } + Ok(()) + } + + fn dispatch(&self, kernel: &Kernel, bindings: &[Binding], grid: Grid) -> Result<()> { + if self.host_shadow { + self.dispatch_host_shadow(kernel, bindings, grid) + } else { + self.dispatch_resident(kernel, bindings, grid) + } + } + + fn synchronize(&self) -> Result<()> { + // In per-op mode every dispatch already waited idle. In batch mode the + // queued dispatches haven't been submitted yet — flush so the barrier + // semantics of `synchronize` hold (all prior work complete on return). + if self.batch { + self.flush()?; + } + Ok(()) + } +} + +impl Drop for VulkanDevice { + fn drop(&mut self) { + // Flush any dispatches still queued so their submit completes and their + // transient buffers are freed before the metaltile device tears down. + if self.batch { + let _ = self.flush(); + } + // Resident buffers (VulkanRawBuffer) are owned by the Tensors/engine + // above and are not tracked here, so they are not freed on device drop + // — the metaltile VulkanDevice's own Drop tears down the VkDevice, + // which releases all child objects (buffers + cached pipelines). + } +} diff --git a/rust/crates/backends/ffai-vulkan/src/lib.rs b/rust/crates/backends/ffai-vulkan/src/lib.rs new file mode 100644 index 00000000..68ba72cb --- /dev/null +++ b/rust/crates/backends/ffai-vulkan/src/lib.rs @@ -0,0 +1,36 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-vulkan +//! +//! Vulkan / SPIR-V backend — the portable GPU path (AMD / Intel / Android / +//! anything with a Vulkan 1.2+ driver). Under `--features vulkan` it wraps +//! `metaltile_runtime::VulkanDevice` (IR → GlslGenerator → shaderc → SPIR-V → +//! VkPipeline → dispatch) behind the shared [`ffai_core::Device`] trait — the +//! same seam the Metal / CUDA backends implement, so everything above it is +//! backend-agnostic. Without the feature it is a stub so non-Vulkan hosts +//! still build the workspace. + +#[cfg(feature = "vulkan")] +mod imp; +#[cfg(feature = "vulkan")] +pub use imp::{VulkanBuffer, VulkanDevice}; + +#[cfg(not(feature = "vulkan"))] +mod stub { + use ffai_core::{Device, Result}; + use std::sync::Arc; + + /// Stub: the crate builds on non-Vulkan hosts, but no device is available + /// until compiled with `--features vulkan`. + pub struct VulkanDevice; + + impl VulkanDevice { + pub fn create() -> Result>> { + Ok(None) + } + } +} + +#[cfg(not(feature = "vulkan"))] +pub use stub::VulkanDevice; diff --git a/rust/crates/backends/ffai-vulkan/tests/qwen25_first_logit_diag.rs b/rust/crates/backends/ffai-vulkan/tests/qwen25_first_logit_diag.rs new file mode 100644 index 00000000..525d050d --- /dev/null +++ b/rust/crates/backends/ffai-vulkan/tests/qwen25_first_logit_diag.rs @@ -0,0 +1,56 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Diagnostic: dump the top-K logits + the rank of " Paris" at the first +//! decode step, to tell a precision near-tie apart from a real kernel bug on +//! the Vulkan/RDNA4 path. Not a CI test — gated on QWEN25_GGUF + vulkan. + +use ffai_loader::gguf::Gguf; +use ffai_models::gguf_tokenizer::GgufTokenizer; +use ffai_models::llama::GgufModel; +use ffai_vulkan::VulkanDevice; + +#[test] +fn qwen25_first_logit_diag_vulkan() { + let Some(dev) = VulkanDevice::create().expect("vulkan init") else { + eprintln!("no Vulkan device — skipping"); + return; + }; + let path = std::env::var("QWEN25_GGUF") + .unwrap_or_else(|_| r"C:\models\qwen2.5-1.5b-instruct-q8_0.gguf".to_string()); + if !std::path::Path::new(&path).exists() { + eprintln!("model not found — skipping"); + return; + } + let g = Gguf::open(&path).expect("open gguf"); + let tok = GgufTokenizer::from_gguf(&g).expect("tok"); + let model = GgufModel::open(dev.as_ref(), &path, 256).expect("load"); + + let prompt = tok.encode("The capital of France is"); + eprintln!("[TOK] encoded ids = {prompt:?}"); + for &t in &prompt { + eprintln!("[TOK] id={t} -> {:?}", tok.decode(&[t])); + } + eprintln!("[CFG] hidden={} nq={} nkv={} hd={} n_layers={} vocab={}", + model.cfg.hidden, model.cfg.n_q_heads, model.cfg.n_kv_heads, + model.cfg.head_dim, model.n_layers, model.vocab); + // Run prefill, keep the logits from the LAST prompt token (the next-token dist). + let mut logits = vec![]; + for (pos, &t) in prompt.iter().enumerate() { + logits = model.step(dev.as_ref(), t, pos).expect("step"); + } + // Argmax + top-8. + let mut idx: Vec = (0..logits.len()).collect(); + idx.sort_unstable_by(|&a, &b| logits[b].partial_cmp(&logits[a]).unwrap()); + eprintln!("== top-8 next-token logits after prompt =="); + for &i in idx.iter().take(8) { + eprintln!(" id={i:>6} logit={:>10.4} tok={:?}", logits[i], tok.decode(&[i as u32])); + } + for cand in [" Paris", "Paris"] { + if let Some(pid) = tok.token_id(cand) { + let rank = idx.iter().position(|&i| i == pid as usize).unwrap(); + eprintln!(" candidate {cand:?}: id={pid} logit={:.4} rank={rank}", logits[pid as usize]); + } else { + eprintln!(" candidate {cand:?}: NOT a single token"); + } + } +} diff --git a/rust/crates/backends/ffai-vulkan/tests/qwen25_gguf_vulkan.rs b/rust/crates/backends/ffai-vulkan/tests/qwen25_gguf_vulkan.rs new file mode 100644 index 00000000..8f22d0bd --- /dev/null +++ b/rust/crates/backends/ffai-vulkan/tests/qwen25_gguf_vulkan.rs @@ -0,0 +1,96 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! End-to-end GGUF small-model load + generate on the **Vulkan** GPU (RDNA4). +//! Mirror of `ffai-metal/tests/qwen25_gguf_metal.rs` — the only difference is +//! the device constructor (`VulkanDevice::create()` instead of Metal). Proves +//! the backend-agnostic GGUF path (GgufModel::open + ffai_runtime::generate +//! over GgufModel::step) runs on the portable Vulkan backend. +//! +//! Loads Qwen2.5-1.5B-Instruct Q8_0 GGUF (geometry + Q8_0 weights + BPE +//! tokenizer from the one file), runs the KV-cache decode loop, and checks the +//! continuation of "The capital of France is" contains "Paris". +//! +//! Requires `--features vulkan`. Skips if no Vulkan device or model is absent. + +use ffai_loader::gguf::Gguf; +use ffai_models::gguf_tokenizer::GgufTokenizer; +use ffai_models::llama::GgufModel; +use ffai_runtime::{generate, Sampling, StopOn}; +use ffai_vulkan::VulkanDevice; +use std::time::Instant; + +#[test] +fn qwen25_1_5b_gguf_generate_on_vulkan() { + let Some(dev) = VulkanDevice::create().expect("vulkan init") else { + eprintln!("no Vulkan device — skipping"); + return; + }; + let path = std::env::var("QWEN25_GGUF") + .unwrap_or_else(|_| r"C:\models\qwen2.5-1.5b-instruct-q8_0.gguf".to_string()); + if !std::path::Path::new(&path).exists() { + eprintln!("model not found at {path} — skipping"); + return; + } + eprintln!("device: {} — loading GGUF {path}", dev.name()); + + // Parse once: tokenizer reads the same Gguf; model re-parses for weights. + let g = Gguf::open(&path).expect("open gguf"); + let tok = GgufTokenizer::from_gguf(&g).expect("tokenizer"); + eprintln!( + "arch={:?} vocab={}", + g.meta_str("general.architecture"), + tok.vocab_size() + ); + + let t_load = Instant::now(); + let model = GgufModel::open(dev.as_ref(), &path, 256).expect("load gguf model"); + eprintln!( + "model loaded in {:.2}s — layers={} hidden={} q_heads={} kv_heads={} head_dim={} bias={} theta={}", + t_load.elapsed().as_secs_f32(), + model.n_layers, + model.cfg.hidden, + model.cfg.n_q_heads, + model.cfg.n_kv_heads, + model.cfg.head_dim, + model.cfg.attn_bias, + model.cfg.rope_theta, + ); + + // Plain completion prompt (no chat template) — base-model style continuation. + let prompt_text = "The capital of France is"; + let prompt = tok.encode(prompt_text); + eprintln!("prompt {:?} -> {} ids: {:?}", prompt_text, prompt.len(), prompt); + assert!(!prompt.is_empty(), "tokenizer produced no ids"); + + let eos = tok.token_id("<|endoftext|>"); + // Short run keeps the capability demo quick (a few decode tokens is enough + // to prove the full prefill+decode loop is correct on the Vulkan backend). + let stop = StopOn { max_new: 6, eos }; + + let t_gen = Instant::now(); + let mut n_steps = 0usize; + let out = generate(&prompt, &stop, &Sampling::Greedy, 0, |token, pos| { + n_steps += 1; + model.step(dev.as_ref(), token, pos).expect("step") + }); + let secs = t_gen.elapsed().as_secs_f32(); + let total_steps = prompt.len() + out.len(); // prefill steps + decode steps + let _ = n_steps; + + let text = tok.decode(&out); + eprintln!( + "generated {} tokens in {:.2}s ({:.2} tok/s incl. prefill)", + out.len(), + secs, + total_steps as f32 / secs + ); + eprintln!("OUTPUT IDS: {:?}", out); + eprintln!("OUTPUT: {:?}", text); + eprintln!("FULL: {:?}{}", prompt_text, text); + + assert!( + text.contains("Paris"), + "expected 'Paris' in continuation, got {text:?}" + ); + eprintln!("Qwen2.5-1.5B-Q8 GGUF loaded + generated coherent output on Vulkan/RDNA4."); +} diff --git a/rust/crates/backends/ffai-vulkan/tests/qwen25_prefill_coopmat.rs b/rust/crates/backends/ffai-vulkan/tests/qwen25_prefill_coopmat.rs new file mode 100644 index 00000000..206c9f01 --- /dev/null +++ b/rust/crates/backends/ffai-vulkan/tests/qwen25_prefill_coopmat.rs @@ -0,0 +1,123 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Batched-prefill correctness + throughput on the Vulkan/RDNA4 backend. +//! +//! Drives `GgufModel::prefill` (the multi-token path that routes every +//! projection + FFN matmul through `ffai_ops::gemm_q8_mpp` — the SimdGroup +//! CoopTile GEMM) and: +//! 1. validates the next-token logits' argmax matches the sequential `step` +//! path AND that a short greedy continuation still says "Paris", and +//! 2. measures prefill throughput (prompt tokens / second) at a few prompt +//! lengths. +//! +//! Run it twice to A/B the gated `VK_KHR_cooperative_matrix` lever: +//! # scalar GEMM (SoftwareLocalC): cargo test ... -- --nocapture +//! # coopmat GEMM (fragment MMA): MT_VK_COOPMAT=1 cargo test ... -- --nocapture +//! Both must produce identical tokens (the gate is bit-for-bit transparent to +//! the model); only the prefill tok/s changes. +//! +//! Requires `--features vulkan`. Skips if no Vulkan device or model is absent. + +use ffai_loader::gguf::Gguf; +use ffai_models::gguf_tokenizer::GgufTokenizer; +use ffai_models::llama::GgufModel; +use ffai_vulkan::VulkanDevice; +use std::time::Instant; + +#[test] +fn qwen25_1_5b_prefill_coopmat_vulkan() { + let Some(dev) = VulkanDevice::create().expect("vulkan init") else { + eprintln!("no Vulkan device — skipping"); + return; + }; + let path = std::env::var("QWEN25_GGUF") + .unwrap_or_else(|_| r"C:\models\qwen2.5-1.5b-instruct-q8_0.gguf".to_string()); + if !std::path::Path::new(&path).exists() { + eprintln!("model not found at {path} — skipping"); + return; + } + let coopmat = std::env::var("MT_VK_COOPMAT").map(|v| v == "1").unwrap_or(false); + eprintln!("=== Qwen2.5-1.5B prefill — MT_VK_COOPMAT={} ===", coopmat as u8); + + let g = Gguf::open(&path).expect("open gguf"); + let tok = GgufTokenizer::from_gguf(&g).expect("tokenizer"); + + // KV cache must hold the longest prefill we time below + a short decode tail. + let cap = 640usize; + let model = GgufModel::load(dev.as_ref(), &path, cap).expect("load gguf model (resident-Q8)"); + eprintln!( + "loaded: layers={} hidden={} q_heads={} kv_heads={} head_dim={} bias={}", + model.n_layers, model.cfg.hidden, model.cfg.n_q_heads, model.cfg.n_kv_heads, + model.cfg.head_dim, model.cfg.attn_bias, + ); + + // ── (1) Correctness: batched prefill vs sequential step ────────────────── + let prompt_text = "The capital of France is"; + let prompt = tok.encode(prompt_text); + assert!(prompt.len() > 1, "need a multi-token prompt to exercise the batched path"); + + // Sequential reference next-token logits (the proven per-token path). + let mut seq_logits = Vec::new(); + for (i, &t) in prompt.iter().enumerate() { + seq_logits = model.step(dev.as_ref(), t, i).expect("step"); + } + let seq_argmax = argmax(&seq_logits); + + // Fresh model so the KV cache for the batched run is clean. + let model2 = GgufModel::load(dev.as_ref(), &path, cap).expect("reload"); + let pf_logits = model2.prefill(dev.as_ref(), &prompt, 0).expect("prefill"); + let pf_argmax = argmax(&pf_logits); + + eprintln!( + "next-token argmax — sequential={} ({:?}) batched-prefill={} ({:?})", + seq_argmax, tok.decode(&[seq_argmax as u32]), + pf_argmax, tok.decode(&[pf_argmax as u32]), + ); + assert_eq!( + seq_argmax, pf_argmax, + "batched prefill next-token must match the sequential step path" + ); + let first_tok_text = tok.decode(&[pf_argmax as u32]); + assert!( + first_tok_text.contains("Paris"), + "expected the prefill's first predicted token to be 'Paris', got {first_tok_text:?}" + ); + + // Continue greedily a few tokens from the prefilled cache (decode via step). + let mut next = pf_argmax as u32; + let mut produced = vec![next]; + for i in 0..5 { + let logits = model2.step(dev.as_ref(), next, prompt.len() + i).expect("decode step"); + next = argmax(&logits) as u32; + produced.push(next); + } + eprintln!("FULL: {:?}{}", prompt_text, tok.decode(&produced)); + + // ── (2) Throughput: prefill tok/s at a few prompt lengths ──────────────── + // Synthetic prompts (repeat the real prompt) so we can scale S without + // depending on a long corpus. Each timing reloads the model so the KV cache + // starts empty and the measurement is pure prefill compute (incl. dispatch). + eprintln!("--- prefill throughput (MT_VK_COOPMAT={}) ---", coopmat as u8); + for &s in &[64usize, 128, 256, 512] { + if s + 8 > cap { continue; } + let toks: Vec = (0..s).map(|i| prompt[i % prompt.len()]).collect(); + let m = GgufModel::load(dev.as_ref(), &path, cap).expect("reload for timing"); + // Warm once (first call JIT-compiles the kernels), then time. + let _ = m.prefill(dev.as_ref(), &toks[..prompt.len()], 0).expect("warm"); + let m2 = GgufModel::load(dev.as_ref(), &path, cap).expect("reload timed"); + let t = Instant::now(); + let _ = m2.prefill(dev.as_ref(), &toks, 0).expect("timed prefill"); + let secs = t.elapsed().as_secs_f32(); + eprintln!(" S={s:>4} {secs:7.3}s {:8.1} prompt-tok/s", s as f32 / secs); + } + eprintln!("Qwen2.5-1.5B batched prefill OK on Vulkan/RDNA4 (coopmat={}).", coopmat as u8); +} + +fn argmax(v: &[f32]) -> usize { + let mut bi = 0usize; + let mut bv = f32::NEG_INFINITY; + for (i, &x) in v.iter().enumerate() { + if x > bv { bv = x; bi = i; } + } + bi +} diff --git a/rust/crates/backends/ffai-vulkan/tests/vulkan_smoke.rs b/rust/crates/backends/ffai-vulkan/tests/vulkan_smoke.rs new file mode 100644 index 00000000..1dd95463 --- /dev/null +++ b/rust/crates/backends/ffai-vulkan/tests/vulkan_smoke.rs @@ -0,0 +1,97 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! End-to-end smoke for the ffai-vulkan Device: drive a real kernel through +//! the `ffai_core::Device` seam (upload → alloc → dispatch → download) on a +//! live Vulkan device and compare to a CPU oracle. Mirrors metaltile's +//! `vulkan_smoke.rs` but exercises the ffai-core trait, not run_kernel +//! directly. Skips cleanly if no Vulkan device is present. +#![cfg(feature = "vulkan")] + +use ffai_core::{Binding, Grid}; +use ffai_vulkan::VulkanDevice; +use metaltile_core::{ + dtype::DType, + ir::{BinOpKind, IndexExpr, Kernel, Op, Param, ParamKind, ValueId}, + shape::Shape, +}; + +fn vector_add_ir() -> Kernel { + let mut k = Kernel::new("vector_add"); + for (name, is_out) in [("a", false), ("b", false), ("c", true)] { + k.params.push(Param { + name: name.into(), + dtype: DType::F32, + shape: Shape::scalar(), + is_output: is_out, + kind: ParamKind::Tensor, + }); + } + k.body.push_op(Op::ProgramId { axis: 0 }, ValueId::new(0)); + k.body.name_value(ValueId::new(0), "idx"); + k.body.push_op( + Op::Load { src: "a".into(), indices: vec![IndexExpr::Value(ValueId::new(0))], mask: None, other: None }, + ValueId::new(1), + ); + k.body.name_value(ValueId::new(1), "x"); + k.body.push_op( + Op::Load { src: "b".into(), indices: vec![IndexExpr::Value(ValueId::new(0))], mask: None, other: None }, + ValueId::new(2), + ); + k.body.name_value(ValueId::new(2), "y"); + k.body.push_op( + Op::BinOp { op: BinOpKind::Add, lhs: ValueId::new(1), rhs: ValueId::new(2) }, + ValueId::new(3), + ); + k.body.name_value(ValueId::new(3), "sum"); + k.body.push_op_no_result(Op::Store { + dst: "c".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + value: ValueId::new(3), + mask: None, + }); + k +} + +fn to_bytes(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +#[test] +fn ffai_vulkan_vector_add_f32_bit_exact() { + let Some(dev) = VulkanDevice::create().expect("vulkan create") else { + eprintln!("ffai_vulkan_smoke: no Vulkan device — skipping"); + return; + }; + eprintln!("ffai_vulkan_smoke: device='{}'", dev.name()); + + const N: usize = 8 * 1024; + let a: Vec = (0..N).map(|i| (i as f32) * 0.5).collect(); + let b: Vec = (0..N).map(|i| (i as f32) * -0.25 + 7.0).collect(); + let oracle: Vec = a.iter().zip(&b).map(|(x, y)| x + y).collect(); + + let ba = dev.upload(&to_bytes(&a)).unwrap(); + let bb = dev.upload(&to_bytes(&b)).unwrap(); + let bc = dev.alloc(N * 4).unwrap(); + + let block = 256u32; + let grid = (N as u32).div_ceil(block); + let bindings = [Binding::Buffer(ba), Binding::Buffer(bb), Binding::Buffer(bc.clone())]; + dev.dispatch(&vector_add_ir(), &bindings, Grid::d1(grid, block)) + .expect("dispatch"); + dev.synchronize().unwrap(); + + let mut out = vec![0u8; N * 4]; + dev.download(bc.as_ref(), &mut out).unwrap(); + let c: Vec = out + .chunks_exact(4) + .map(|w| f32::from_le_bytes([w[0], w[1], w[2], w[3]])) + .collect(); + + let max_abs = c + .iter() + .zip(&oracle) + .map(|(g, w)| (g - w).abs()) + .fold(0.0f32, f32::max); + eprintln!("ffai_vulkan_smoke: max|Δ| = {max_abs:e}"); + assert!(max_abs == 0.0, "vector_add not bit-exact through ffai seam: {max_abs:e}"); +} diff --git a/rust/crates/ffai-cli/Cargo.toml b/rust/crates/ffai-cli/Cargo.toml new file mode 100644 index 00000000..88101014 --- /dev/null +++ b/rust/crates/ffai-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ffai-cli" +description = "FFAI command-line front end." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "ffai" +path = "src/main.rs" + +[dependencies] +ffai.workspace = true + +[features] +default = ["metal"] +metal = ["ffai/metal"] +cuda = ["ffai/cuda"] +vulkan = ["ffai/vulkan"] diff --git a/rust/crates/ffai-cli/src/main.rs b/rust/crates/ffai-cli/src/main.rs new file mode 100644 index 00000000..4236d210 --- /dev/null +++ b/rust/crates/ffai-cli/src/main.rs @@ -0,0 +1,20 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! FFAI CLI — skeleton. For now it reports the backends compiled into this +//! build and any live devices, so the modular feature wiring is visible. + +fn main() { + println!("FFAI — F*cking Fast AI (Rust engine, skeleton)"); + println!("compiled backends: {:?}", ffai::compiled_backends()); + + let devices = ffai::devices(); + if devices.is_empty() { + println!("live devices: none (backends are stubs — Device impls pending)"); + } else { + println!("live devices:"); + for d in &devices { + println!(" [{}] {}", d.backend().as_str(), d.name()); + } + } +} diff --git a/rust/crates/ffai-core/Cargo.toml b/rust/crates/ffai-core/Cargo.toml new file mode 100644 index 00000000..910f58d5 --- /dev/null +++ b/rust/crates/ffai-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ffai-core" +description = "FFAI backend-neutral core: the Device trait, Tensor, and dtype/buffer/binding types every backend shares." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +metaltile-core.workspace = true +thiserror.workspace = true diff --git a/rust/crates/ffai-core/src/error.rs b/rust/crates/ffai-core/src/error.rs new file mode 100644 index 00000000..25dd2917 --- /dev/null +++ b/rust/crates/ffai-core/src/error.rs @@ -0,0 +1,28 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +use thiserror::Error; + +/// Engine-wide error. Backends map their native failures (CUDA driver, +/// Metal, Vulkan) into these variants so the engine layer stays +/// backend-agnostic. +#[derive(Debug, Error)] +pub enum Error { + #[error("alloc failed: {0}")] + Alloc(String), + #[error("device dispatch failed: {0}")] + Dispatch(String), + #[error("codegen failed: {0}")] + Codegen(String), + /// A backend crate exists but was not compiled in (feature off) or no + /// matching hardware was found. + #[error("backend unavailable: {0}")] + BackendUnavailable(&'static str), + /// A path that is scaffolded but not yet implemented. + #[error("not yet implemented: {0}")] + Unimplemented(&'static str), + #[error("{0}")] + Msg(String), +} + +pub type Result = std::result::Result; diff --git a/rust/crates/ffai-core/src/lib.rs b/rust/crates/ffai-core/src/lib.rs new file mode 100644 index 00000000..5defce5c --- /dev/null +++ b/rust/crates/ffai-core/src/lib.rs @@ -0,0 +1,633 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-core +//! +//! Backend-neutral primitives for the FFAI inference engine. This crate +//! defines the single seam every backend implements — the [`Device`] +//! trait — plus the [`Tensor`] handle and the dtype/buffer/binding types +//! that flow through it unchanged on Metal, CUDA, Vulkan, or ROCm. +//! +//! ## How code is shared +//! +//! Kernels are shared via the **metaltile IR** ([`Kernel`], re-exported +//! from `metaltile-core`). A model op builds or looks up a `Kernel` and +//! hands it to [`Device::dispatch`]; the backend lowers that IR to its +//! target language (MSL / CUDA C++ / SPIR-V) and launches it. So: +//! +//! - **Above** this trait (models, ops, loaders, KV cache, sampler) is +//! plain Rust that never names a GPU API — written once, runs everywhere. +//! - **Below** it, each backend is a thin [`Device`] impl. +//! - The kernels themselves are generated once by metaltile. + +use std::any::Any; +use std::sync::Arc; + +mod error; +pub use error::{Error, Result}; + +// Re-export the shared kernel IR + dtype so the whole engine speaks one +// vocabulary and nothing downstream depends on metaltile-core directly. +pub use metaltile_core::dtype::DType; +pub use metaltile_core::ir::Kernel; + +/// Which accelerator family a [`Device`] targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Backend { + Metal, + Cuda, + Vulkan, + Rocm, + Cpu, +} + +impl Backend { + pub fn as_str(self) -> &'static str { + match self { + Backend::Metal => "metal", + Backend::Cuda => "cuda", + Backend::Vulkan => "vulkan", + Backend::Rocm => "rocm", + Backend::Cpu => "cpu", + } + } +} + +/// An opaque device-side allocation. Each backend returns its own concrete +/// type (an `MTLBuffer` wrapper, a `CUdeviceptr` wrapper, a `VkBuffer` +/// wrapper) behind this trait, so [`Tensor`] stays backend-agnostic. +pub trait DeviceBuffer: Send + Sync { + /// Length in bytes. + fn len(&self) -> usize; + fn is_empty(&self) -> bool { + self.len() == 0 + } + /// Escape hatch so backend code can downcast to its concrete buffer + /// type when it needs the native handle for a launch. + fn as_any(&self) -> &dyn Any; +} + +/// A single kernel argument in signature order: either a device buffer or a +/// small by-value scalar/constexpr (little-endian bytes). +#[derive(Clone)] +pub enum Binding { + Buffer(Arc), + Scalar(Vec), +} + +/// Launch geometry: grid (blocks) × block (threads-per-block), 3-D. Maps +/// onto both CUDA's grid/block and Metal's threadgroups / threads-per-tg. +#[derive(Debug, Clone, Copy)] +pub struct Grid { + pub grid: [u32; 3], + pub block: [u32; 3], +} + +impl Grid { + /// 1-D launch: `blocks` threadgroups of `threads` lanes each. + pub fn d1(blocks: u32, threads: u32) -> Self { + Grid { grid: [blocks, 1, 1], block: [threads, 1, 1] } + } +} + +/// The one interface every backend implements. Object-safe so the engine +/// holds `Arc` and dispatches without knowing the hardware. +pub trait Device: Send + Sync { + fn backend(&self) -> Backend; + /// Human-readable device name (e.g. `"Apple M5 Max"`, `"NVIDIA GB10"`). + fn name(&self) -> &str; + + /// Allocate `len` bytes of uninitialized device memory. + fn alloc(&self, len: usize) -> Result>; + + /// Allocate + upload host bytes in one shot. + fn upload(&self, bytes: &[u8]) -> Result>; + + /// Allocate `len` bytes of zero-initialized device memory. Backends + /// should override with a device-side fill — the default uploads a host + /// zero buffer, which for multi-MB accumulators is a pageable H2D copy. + fn alloc_zeroed(&self, len: usize) -> Result> { + self.upload(&vec![0u8; len]) + } + + /// Copy device memory back into a host slice. + fn download(&self, buf: &dyn DeviceBuffer, out: &mut [u8]) -> Result<()>; + + /// Lower `kernel` (shared metaltile IR) for this backend and launch it + /// over `grid` with `bindings` in signature order. + fn dispatch(&self, kernel: &Kernel, bindings: &[Binding], grid: Grid) -> Result<()>; + + /// Block until all submitted work has completed. + fn synchronize(&self) -> Result<()>; + + /// CUDA-graph capture (megakernel). `begin_capture` starts recording stream + /// work; run an all-device (no host-sync) sequence; `end_capture` returns an + /// opaque executable-graph handle; `graph_launch` replays it as ONE launch. + /// Default impls error (backend without graph support). Returns handle as u64. + fn begin_capture(&self) -> Result<()> { + Err(Error::Msg("graph capture unsupported on this backend".into())) + } + fn end_capture(&self) -> Result { + Err(Error::Msg("graph capture unsupported on this backend".into())) + } + fn graph_launch(&self, _exec: u64) -> Result<()> { + Err(Error::Msg("graph capture unsupported on this backend".into())) + } + /// Issue `n` sequential graph launches on the GPU stream without syncing + /// between them; sync once at the end. Eliminates per-token host-GPU + /// handoff for the throughput benchmark. Default impl falls back to + /// n individual `graph_launch` calls (correct but not pipelined). + fn graph_launch_batch(&self, exec: u64, n: usize) -> Result<()> { + for _ in 0..n { self.graph_launch(exec)?; } + Ok(()) + } + + /// Escape hatch: compile raw CUDA C++ via NVRTC and launch a single + /// function with the provided device-buffer pointer list (as `u64` + /// CUdeviceptr values) plus a small scalar-bytes trailer. + /// + /// - `src`: full CUDA C++ source + /// - `prog_name`: source file name hint (e.g. `"fused_moe.cu"`) + /// - `fn_name`: `__global__` function to call + /// - `ptrs`: ordered list of (CUdeviceptr, byte-offset) pairs + /// - `scalars`: packed scalar bytes appended after ptrs in kernel arg order + /// - `grid`/`block`/`shared_bytes`: launch geometry + /// - `cooperative`: use `cuLaunchCooperativeKernel` for global grid-sync. + /// Falls back to regular launch if a CUDA graph capture is in progress + /// (cooperative launches are not capturable). Caller must handle the + /// graph-mode fallback externally if correctness requires the barrier. + /// + /// Returns `Err(unsupported)` on backends that don't implement this. + fn dispatch_raw_cuda( + &self, + _src: &str, + _prog_name: &str, + _fn_name: &str, + _ptrs: &[(&dyn DeviceBuffer, usize)], // (buf, byte_offset) + _scalars: &[Vec], + _grid: [u32; 3], + _block: [u32; 3], + _shared_bytes: u32, + _cooperative: bool, + ) -> Result<()> { + Err(Error::Msg("dispatch_raw_cuda unsupported on this backend".into())) + } + + /// Tensor-core GEMM escape hatch (Path A). Computes the ROW-MAJOR product + /// `out[m,n] = x[m,k] · w[n,k]ᵀ` (the projection `out[r,o]=Σ_k w[o,k]·x[r,k]`) + /// via the backend's hardware matmul (cuBLAS tensor cores on CUDA). Inputs + + /// output are device buffers of `dtype` (f16/bf16); accumulation is f32. + /// Default impl errors on backends without a tensor-core matmul. + #[allow(clippy::too_many_arguments)] + fn gemm_tc( + &self, + _x: &dyn DeviceBuffer, + _w: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _m: usize, + _n: usize, + _k: usize, + _dtype: DType, + ) -> Result<()> { + Err(Error::Msg("gemm_tc (tensor-core GEMM) unsupported on this backend".into())) + } + + /// f16/bf16 inputs, **f32 output** — fuses the post-GEMM `cast_f16_f32` into + /// the matmul (cuBLAS writes the f32 result directly, accumulate is already + /// f32). `out` must be an f32 buffer of `[m,n]`. Default errors; CUDA + /// implements it via cublasLt with an f32 D layout. + #[allow(clippy::too_many_arguments)] + fn gemm_tc_out_f32( + &self, + _x: &dyn DeviceBuffer, + _w: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _m: usize, + _n: usize, + _k: usize, + _ab_dtype: DType, + ) -> Result<()> { + Err(Error::Msg("gemm_tc_out_f32 unsupported on this backend".into())) + } + + /// Block-scaled NVFP4 GEMM (Blackwell): `out[m,n](f16) = X·Wᵀ` with both + /// operands packed e2m1 (2/byte) + per-16-element ue4m3 scale tensors in + /// the hardware's swizzled block layout. Default errors; CUDA implements + /// it via cublasLt (measured ~4x the f16 tensor-core path on GB10). + #[allow(clippy::too_many_arguments)] + fn gemm_fp4( + &self, + _x_pack: &dyn DeviceBuffer, + _x_sf: &dyn DeviceBuffer, + _w_pack: &dyn DeviceBuffer, + _w_sf: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _m: usize, + _n: usize, + _k: usize, + _out_f32: bool, + _d_scale: Option<&dyn DeviceBuffer>, + ) -> Result<()> { + Err(Error::Msg("gemm_fp4 (block-scaled NVFP4 GEMM) unsupported on this backend".into())) + } + + /// FP8 (e4m3) GEMM with per-tensor f32 device scale pointers: + /// `out[m,n] = sx*sw * X·Wᵀ`. Default errors; CUDA implements it via + /// cublasLt (~2x the f16 tensor-core rate, far gentler quantization + /// than FP4). + #[allow(clippy::too_many_arguments)] + fn gemm_fp8( + &self, + _x: &dyn DeviceBuffer, + _x_scale: &dyn DeviceBuffer, + _w: &dyn DeviceBuffer, + _w_scale: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _m: usize, + _n: usize, + _k: usize, + _out_f32: bool, + ) -> Result<()> { + Err(Error::Msg("gemm_fp8 unsupported on this backend".into())) + } + + /// CUTLASS grouped MoE GEMM: `out[t,n] = Σ_k a[t,k]·w[eid(t)][n,k]` over + /// sorted-token groups. `a` f16 `[mt,K]`, `w` contiguous f16 expert slab + /// `[n_exp,N,K]`, `out` f16 `[mt,N]`; `group_rows`/`expert_ids` are host + /// slices. Default errors; CUDA implements it via the AOT CUTLASS lib (when + /// the runtime was built with CUTLASS_DIR). + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass( + &self, + _a: &dyn DeviceBuffer, + _w: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _group_rows: &[i32], + _expert_ids: &[i32], + _n: usize, + _k: usize, + ) -> Result<()> { + Err(Error::Msg("moe_grouped_cutlass unsupported on this backend".into())) + } + + /// CUTLASS grouped block-scaled NVFP4 MoE GEMM: `out[t,n] = Σ_k a[t,k]· + /// w[eid(t)][n,k]` over sorted-token groups, both operands packed e2m1 + + /// ue4m3 block-16 scales in the canonical 512B-block swizzle. `a` packed + /// `[mt,K/2]` bytes, `sfa` per-group scale pool (group g's blob at byte + /// `sfa_off[g]`, group-LOCAL rows), `w`/`sfw` per-expert packed/scale + /// slabs, `out` f16 `[mt,N]`. `group_rows`/`expert_ids`/`sfa_off` are host + /// slices. Default errors; CUDA implements it via the AOT CUTLASS lib + /// (sm_120a/121a builds with CUTLASS_DIR). + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_fp4( + &self, + _a: &dyn DeviceBuffer, + _sfa: &dyn DeviceBuffer, + _w: &dyn DeviceBuffer, + _sfw: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _group_rows: &[i32], + _expert_ids: &[i32], + _sfa_off: &[i64], + _alpha_vec: Option<&dyn DeviceBuffer>, // device f32[n_groups] per-group scales + _n: usize, + _k: usize, + ) -> Result<()> { + Err(Error::Msg("moe_grouped_cutlass_fp4 unsupported on this backend".into())) + } + + /// W4A8 grouped MoE GEMM: fp8 e4m3 acts (a + ue8m0 sfa) x mxfp4 weights + /// (w + ue8m0 sfw), cutlass mxf8f6f4 mixed block-scaled MMA. Mirrors + /// `moe_grouped_cutlass_fp4` but holds near-bf16 quality (256 act levels). + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_w4a8( + &self, + _a: &dyn DeviceBuffer, + _sfa: &dyn DeviceBuffer, + _w: &dyn DeviceBuffer, + _sfw: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _group_rows: &[i32], + _expert_ids: &[i32], + _sfa_off: &[i64], + _sfb_exp_bytes: i64, + _alpha_vec: Option<&dyn DeviceBuffer>, + _n: usize, + _k: usize, + ) -> Result<()> { + Err(Error::Msg("moe_grouped_cutlass_w4a8 unsupported on this backend".into())) + } + + /// W4A8 fp8 act-quant: x[mt,K] half -> out e4m3 + sf ue8m0 (per-32, + /// group-aware SFA; group_rows/sfa_off are HOST arrays). + fn w4a8_actquant( + &self, + _x: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _sf: &dyn DeviceBuffer, + _group_rows: &[i32], + _sfa_off: &[i64], + _n: usize, + _k: usize, + ) -> Result<()> { + Err(Error::Msg("w4a8_actquant unsupported on this backend".into())) + } + + /// W4A8 mxfp4 weight pack: w[n_exp,N,K] half -> outp e2m1 + sf ue8m0 + /// (per-32, SFB). Returns per-expert SFB element count. + fn w4a8_packw( + &self, + _w: &dyn DeviceBuffer, + _outp: &dyn DeviceBuffer, + _sf: &dyn DeviceBuffer, + _n_exp: usize, + _n: usize, + _k: usize, + ) -> Result { + Err(Error::Msg("w4a8_packw unsupported on this backend".into())) + } + + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_w8a8( + &self, + _a: &dyn DeviceBuffer, + _sfa: &dyn DeviceBuffer, + _w: &dyn DeviceBuffer, + _sfw: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _group_rows: &[i32], + _expert_ids: &[i32], + _sfa_off: &[i64], + _sfb_exp_bytes: i64, + _alpha_vec: Option<&dyn DeviceBuffer>, + _n: usize, + _k: usize, + ) -> Result<()> { + Err(Error::Msg("moe_grouped_cutlass_w8a8 unsupported on this backend".into())) + } + + /// W4A8 fp8 act-quant: x[mt,K] half -> out e4m3 + sf ue8m0 (per-32, + /// group-aware SFA; group_rows/sfa_off are HOST arrays). + fn w8a8_actquant( + &self, + _x: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _sf: &dyn DeviceBuffer, + _group_rows: &[i32], + _sfa_off: &[i64], + _n: usize, + _k: usize, + ) -> Result<()> { + Err(Error::Msg("w8a8_actquant unsupported on this backend".into())) + } + + /// W4A8 mxfp4 weight pack: w[n_exp,N,K] half -> outp e2m1 + sf ue8m0 + /// (per-32, SFB). Returns per-expert SFB element count. + fn w8a8_packw( + &self, + _w: &dyn DeviceBuffer, + _outp: &dyn DeviceBuffer, + _sf: &dyn DeviceBuffer, + _n_exp: usize, + _n: usize, + _k: usize, + ) -> Result { + Err(Error::Msg("w8a8_packw unsupported on this backend".into())) + } + + /// Prepare a persistent CUTLASS grouped-FP4 handle (device blob + + /// workspace + initialized GEMM; descriptors filled per call ON DEVICE). + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_fp4_prepare( + &self, + _w: &dyn DeviceBuffer, + _sfw: &dyn DeviceBuffer, + _alpha: &dyn DeviceBuffer, + _n_groups: usize, + _n: usize, + _k: usize, + _max_m_total: usize, + ) -> Result { + Err(Error::Msg("moe_grouped_cutlass_fp4_prepare unsupported on this backend".into())) + } + + /// Run a prepared CUTLASS grouped-FP4 handle: one device fill kernel from + /// the router offsets + gemm.run. No host work, no per-call allocs. + fn moe_grouped_cutlass_fp4_run( + &self, + _handle: u64, + _a: &dyn DeviceBuffer, + _sfa: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _off_dev: &dyn DeviceBuffer, + ) -> Result<()> { + Err(Error::Msg("moe_grouped_cutlass_fp4_run unsupported on this backend".into())) + } + + /// Fused-activation prepare: GEMM1 folds relu²·(1/256) + NVFP4 block-quant + /// into its epilogue, emitting e2m1 D + ue4m3 SFD. `norm_constant` = device + /// f32 ptr to the static gs (1/256). Gated behind NEMOTRON_FUSE_UPACT. + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_fp4_FUSEDACT_prepare( + &self, + _w: &dyn DeviceBuffer, + _sfw: &dyn DeviceBuffer, + _alpha: &dyn DeviceBuffer, + _norm_constant: &dyn DeviceBuffer, + _n_groups: usize, + _n: usize, + _k: usize, + _max_m_total: usize, + ) -> Result { + Err(Error::Msg("moe_grouped_cutlass_fp4_FUSEDACT_prepare unsupported on this backend".into())) + } + + /// Run a fused-activation handle: device fill kernel + gemm.run, emitting + /// `out` (e2m1) + `sfd` (ue4m3) for the down-GEMM input. No host work. + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_fp4_FUSEDACT_run( + &self, + _handle: u64, + _a: &dyn DeviceBuffer, + _sfa: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _sfd: &dyn DeviceBuffer, + _off_dev: &dyn DeviceBuffer, + ) -> Result<()> { + Err(Error::Msg("moe_grouped_cutlass_fp4_FUSEDACT_run unsupported on this backend".into())) + } + + /// Amax-in-epilogue prepare: GEMM1 emits f16 D = relu²·(1/256) AND a + /// per-group amax (`d_amax`, device f32[n_groups], persistent) of those + /// activated values. The down-quant reads the epilogue amax instead of + /// re-scanning. Gated behind NEMOTRON_AMAX_EPI. + #[allow(clippy::too_many_arguments)] + fn moe_grouped_cutlass_fp4_AMAX_prepare( + &self, + _w: &dyn DeviceBuffer, + _sfw: &dyn DeviceBuffer, + _d_amax: &dyn DeviceBuffer, + _n_groups: usize, + _n: usize, + _k: usize, + _max_m_total: usize, + ) -> Result { + Err(Error::Msg("moe_grouped_cutlass_fp4_AMAX_prepare unsupported on this backend".into())) + } + + /// Run an amax-in-epilogue handle: device fill kernel + gemm.run, emitting + /// `out` (f16 a2) and writing the per-group amax into the handle's d_amax + /// (zeroed on-stream inside the run). No host work. + fn moe_grouped_cutlass_fp4_AMAX_run( + &self, + _handle: u64, + _a: &dyn DeviceBuffer, + _sfa: &dyn DeviceBuffer, + _out: &dyn DeviceBuffer, + _off_dev: &dyn DeviceBuffer, + ) -> Result<()> { + Err(Error::Msg("moe_grouped_cutlass_fp4_AMAX_run unsupported on this backend".into())) + } + + /// Byte-offset variant of []: same semantics but each buffer + /// pointer is advanced by its byte offset before the GEMM call. Used to + /// write expert GEMM outputs into pre-allocated slabs without extra copies. + #[allow(clippy::too_many_arguments)] + fn gemm_tc_off( + &self, + _x: &dyn DeviceBuffer, _x_off: usize, + _w: &dyn DeviceBuffer, _w_off: usize, + _out: &dyn DeviceBuffer, _out_off: usize, + _m: usize, _n: usize, _k: usize, _dtype: DType, + ) -> Result<()> { + Err(Error::Msg("gemm_tc_off unsupported on this backend".into())) + } + + /// Strided-batched tensor-core GEMM: `batch_count` independent GEMMs in ONE cuBLAS call. + /// `C_i[m,n] = X_i[m,k] · W_i[n,k]^T`, each matrix at byte offsets `i*stride_{x,w,out}`. + /// x/w/out are device buffers of `dtype` (f16/bf16); strides in BYTES. + #[allow(clippy::too_many_arguments)] + fn gemm_strided_batched( + &self, + _x: &dyn DeviceBuffer, _stride_x: i64, + _w: &dyn DeviceBuffer, _stride_w: i64, + _out: &dyn DeviceBuffer, _stride_out: i64, + _m: usize, _n: usize, _k: usize, + _batch_count: usize, + _dtype: DType, + ) -> Result<()> { + Err(Error::Msg("gemm_strided_batched unsupported on this backend".into())) + } + + /// Like [gemm_strided_batched] but with explicit byte offsets into x/w/out. + /// Base pointers are advanced by the respective byte offset before the GEMM call. + /// Enables sliced-batch calls (e.g. broadcast K for GQA fan-out). + #[allow(clippy::too_many_arguments)] + fn gemm_strided_batched_off( + &self, + _x: &dyn DeviceBuffer, _x_off: usize, _stride_x: i64, + _w: &dyn DeviceBuffer, _w_off: usize, _stride_w: i64, + _out: &dyn DeviceBuffer, _out_off: usize, _stride_out: i64, + _m: usize, _n: usize, _k: usize, + _batch_count: usize, + _dtype: DType, + ) -> Result<()> { + Err(Error::Msg("gemm_strided_batched_off unsupported on this backend".into())) + } + + /// Pointer-array batched GEMM: `batch_count` independent GEMMs sharing + /// `(m,n,k)`, reading X/W/Out from per-batch byte offsets into the three + /// backing buffers. `C_i[m,n] = X_i[m,k] · W_i[n,k]^T`. Unlike + /// [gemm_strided_batched] the per-batch offsets are arbitrary, so one + /// operand can BROADCAST (multiple batches reuse the same slice) — used by + /// the SSD scan to fan a per-group B/C slice into every head's batch slot + /// without an 8× redundant gather. All operands f16/bf16; accumulate f32. + #[allow(clippy::too_many_arguments)] + fn gemm_batched( + &self, + _x_buf: &dyn DeviceBuffer, + _x_offsets: &[usize], // byte offset per batch into x_buf + _w_buf: &dyn DeviceBuffer, + _w_offsets: &[usize], // byte offset per batch into w_buf + _out_buf: &dyn DeviceBuffer, + _out_offsets: &[usize], // byte offset per batch into out_buf + _m: usize, _n: usize, _k: usize, + _dtype: DType, + ) -> Result<()> { + Err(Error::Msg("gemm_batched unsupported on this backend".into())) + } + + /// Grouped-batched GEMM (CUDA 13+ only): `group_count` independent GEMMs + /// where group `i` computes `C_i[m_i, n] = X_i[m_i, k] · W_i[n, k]^T`. + /// All inputs/outputs are f16; accumulate f32. + /// + /// `x_ptrs[i]`, `w_ptrs[i]`, `out_ptrs[i]` are raw device-pointer offsets + /// into the backing allocation (bytes from the start of `x_buf` / `w_buf` / + /// `out_buf` respectively). This avoids sub-allocating separate `DeviceBuffer` + /// handles for every expert — the caller passes one large contiguous buffer + /// for X and one for W, with per-expert byte offsets. + #[allow(clippy::too_many_arguments)] + fn gemm_grouped( + &self, + _x_buf: &dyn DeviceBuffer, + _x_offsets: &[usize], // byte offset per group into x_buf + _w_buf: &dyn DeviceBuffer, + _w_offsets: &[usize], // byte offset per group into w_buf + _out_buf: &dyn DeviceBuffer, + _out_offsets: &[usize], // byte offset per group into out_buf + _m_per_group: &[i32], + _n: usize, + _k: usize, + _dtype: DType, + ) -> Result<()> { + Err(Error::Msg("gemm_grouped unsupported on this backend".into())) + } +} + +/// A handle to a region of device memory + shape + dtype. Backend-neutral: +/// the buffer is an `Arc`, so one `Tensor` type flows +/// through every backend's code path unchanged. +#[derive(Clone)] +pub struct Tensor { + pub buffer: Arc, + /// Byte offset into `buffer` where this tensor begins (slices share the + /// parent allocation). + pub offset: usize, + pub shape: Vec, + pub dtype: DType, +} + +impl Tensor { + pub fn new(buffer: Arc, shape: Vec, dtype: DType) -> Self { + Tensor { buffer, offset: 0, shape, dtype } + } + + pub fn elem_count(&self) -> usize { + self.shape.iter().product() + } + + pub fn byte_count(&self) -> usize { + self.elem_count() * self.dtype.size_bytes() + } + + /// Allocate a fresh contiguous tensor on `dev`. + pub fn empty(dev: &dyn Device, shape: Vec, dtype: DType) -> Result { + let bytes = shape.iter().product::() * dtype.size_bytes(); + Ok(Tensor::new(dev.alloc(bytes)?, shape, dtype)) + } + + /// Reshape without copying. Element count must be preserved. + pub fn reshaped(&self, new_shape: Vec) -> Self { + debug_assert_eq!( + new_shape.iter().product::(), + self.elem_count(), + "reshape changes element count" + ); + Tensor { + buffer: self.buffer.clone(), + offset: self.offset, + shape: new_shape, + dtype: self.dtype, + } + } +} diff --git a/rust/crates/ffai-ffi/Cargo.toml b/rust/crates/ffai-ffi/Cargo.toml new file mode 100644 index 00000000..e42280c0 --- /dev/null +++ b/rust/crates/ffai-ffi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "ffai-ffi" +description = "C-ABI bridge over the shared FFAI engine layer — lets Swift (and any C caller) consume the same Device/ops the CUDA path uses." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["staticlib", "cdylib"] + +[dependencies] +ffai.workspace = true + +[features] +default = ["metal"] +metal = ["ffai/metal"] +cuda = ["ffai/cuda"] +vulkan = ["ffai/vulkan"] diff --git a/rust/crates/ffai-ffi/include/ffai.h b/rust/crates/ffai-ffi/include/ffai.h new file mode 100644 index 00000000..4594bb10 --- /dev/null +++ b/rust/crates/ffai-ffi/include/ffai.h @@ -0,0 +1,26 @@ +/* Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) + * SPDX-License-Identifier: Apache-2.0 + * + * C ABI for the shared FFAI Rust engine. Swift (and any C caller) links + * libffai_ffi and drives the same Device/ops layer the CUDA backend uses. + * Strings returned by ffai_* are heap-allocated; free them with + * ffai_string_free. */ +#ifndef FFAI_H +#define FFAI_H + +typedef struct FfaiDevice FfaiDevice; + +char *ffai_version(void); +/* Comma-separated list of backends compiled into this build. */ +char *ffai_compiled_backends(void); +void ffai_string_free(char *s); + +/* Open the first live device (NULL if none — e.g. on Apple, where the + * Swift-native Metal engine is the primary path). Close with + * ffai_close_device. */ +FfaiDevice *ffai_open_device(void); +char *ffai_device_backend(const FfaiDevice *dev); +char *ffai_device_name(const FfaiDevice *dev); +void ffai_close_device(FfaiDevice *dev); + +#endif /* FFAI_H */ diff --git a/rust/crates/ffai-ffi/src/lib.rs b/rust/crates/ffai-ffi/src/lib.rs new file mode 100644 index 00000000..4cfc2098 --- /dev/null +++ b/rust/crates/ffai-ffi/src/lib.rs @@ -0,0 +1,84 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-ffi +//! +//! C-ABI bridge over the shared FFAI engine. This is how **Swift consumes +//! the same Rust layer the CUDA backend uses**: build this crate as a +//! static/dynamic library, link it from Swift (or any C caller), and drive +//! the [`ffai_core::Device`] layer through these `extern "C"` entry points. +//! +//! On Apple hardware the Swift-native Metal engine remains the primary +//! path, so [`ffai_open_device`] returns null there until the Rust Metal +//! backend lands — but the bridge itself (link + call into the shared +//! engine) is proven, and on a CUDA host the same calls drive the live GB10 +//! through `ffai-cuda`. + +use ffai::Device; +use std::ffi::{CString, c_char}; +use std::sync::Arc; + +/// Heap-allocate a C string for return. Free with [`ffai_string_free`]. +fn cstr(s: String) -> *mut c_char { + CString::new(s).unwrap_or_default().into_raw() +} + +/// Engine version (the `ffai` crate version). +#[unsafe(no_mangle)] +pub extern "C" fn ffai_version() -> *mut c_char { + cstr(env!("CARGO_PKG_VERSION").to_string()) +} + +/// Comma-separated list of backends compiled into this build. +#[unsafe(no_mangle)] +pub extern "C" fn ffai_compiled_backends() -> *mut c_char { + cstr(ffai::compiled_backends().join(",")) +} + +/// Free a string returned by any `ffai_*` function. +#[unsafe(no_mangle)] +pub extern "C" fn ffai_string_free(s: *mut c_char) { + if !s.is_null() { + unsafe { drop(CString::from_raw(s)) }; + } +} + +/// Opaque handle to a live [`Device`] held across the FFI boundary. +pub struct FfaiDevice(Arc); + +/// Open the first live device, or null if none is available on this host. +#[unsafe(no_mangle)] +pub extern "C" fn ffai_open_device() -> *mut FfaiDevice { + match ffai::devices().into_iter().next() { + Some(d) => Box::into_raw(Box::new(FfaiDevice(d))), + None => std::ptr::null_mut(), + } +} + +/// Backend name of a device handle (e.g. `"cuda"`). +#[unsafe(no_mangle)] +pub extern "C" fn ffai_device_backend(dev: *const FfaiDevice) -> *mut c_char { + if dev.is_null() { + return std::ptr::null_mut(); + } + let d = unsafe { &*dev }; + cstr(d.0.backend().as_str().to_string()) +} + +/// Human-readable device name (e.g. `"CUDA device (sm_121)"`). +#[unsafe(no_mangle)] +pub extern "C" fn ffai_device_name(dev: *const FfaiDevice) -> *mut c_char { + if dev.is_null() { + return std::ptr::null_mut(); + } + let d = unsafe { &*dev }; + cstr(d.0.name().to_string()) +} + +/// Close a device handle opened by [`ffai_open_device`]. +#[unsafe(no_mangle)] +pub extern "C" fn ffai_close_device(dev: *mut FfaiDevice) { + if !dev.is_null() { + unsafe { drop(Box::from_raw(dev)) }; + } +} diff --git a/rust/crates/ffai-loader/Cargo.toml b/rust/crates/ffai-loader/Cargo.toml new file mode 100644 index 00000000..a63f55a7 --- /dev/null +++ b/rust/crates/ffai-loader/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ffai-loader" +description = "FFAI weight loaders: GGUF / SafeTensors / HF. Pure byte work, backend-neutral." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +ffai-core.workspace = true +serde_json.workspace = true +memmap2.workspace = true diff --git a/rust/crates/ffai-loader/src/gguf.rs b/rust/crates/ffai-loader/src/gguf.rs new file mode 100644 index 00000000..c6cb3073 --- /dev/null +++ b/rust/crates/ffai-loader/src/gguf.rs @@ -0,0 +1,674 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! GGUF v3 reader + dequant. Parses the header (metadata + tensor infos) and +//! the tightly-packed data section, and dequantizes tensors to f32. Supported +//! formats: F32, F16, Q8_0, the k-quant super-blocks (Q2_K, Q4_K, Q5_K, Q6_K), +//! and IQ2_XXS. Q4_K_M GGUFs (the common k-quant most real models ship) load +//! through the Q4_K/Q6_K arms. Split/multi-part GGUFs (`*-NNNNN-of-MMMMM.gguf`) +//! are opened transparently: pass any one part and all parts are mmapped and +//! their tensor tables merged. + +use ffai_core::{Error, Result}; +use std::collections::BTreeMap; + +use crate::iq2xxs_tables::{IQ2XXS_GRID, KSIGNS}; + +/// GGML tensor type (subset). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GgmlType { + F32, + F16, + Q8_0, + Q2K, + Q4K, + Q5K, + Q6K, + Iq2Xxs, + Other(u32), +} +impl GgmlType { + fn from_u32(t: u32) -> Self { + match t { + 0 => GgmlType::F32, + 1 => GgmlType::F16, + 8 => GgmlType::Q8_0, + 10 => GgmlType::Q2K, + 12 => GgmlType::Q4K, + 13 => GgmlType::Q5K, + 14 => GgmlType::Q6K, + 16 => GgmlType::Iq2Xxs, + o => GgmlType::Other(o), + } + } +} + +#[derive(Debug, Clone)] +pub struct GgufTensor { + pub name: String, + pub dims: Vec, + pub ggml_type: GgmlType, + pub offset: u64, // within the data section + /// Which file part this tensor's data lives in (0 for single-file GGUF; + /// the split index for llama.cpp multi-part `*-NNNNN-of-MMMMM.gguf`). + pub part: usize, +} + +/// A memory-mapped GGUF file (handles the 81GB DSv4 checkpoint without +/// reading it into RAM). For split GGUFs (`*-00001-of-00002.gguf`), `parts` +/// holds one mmap per file and each tensor records its part index; metadata +/// is taken from part 0 (the split header carries the full KV set). +pub struct Gguf { + parts: Vec, + /// Per-part data-section start offset (after that part's header). + data_starts: Vec, + tensors: BTreeMap, + pub metadata_u32: BTreeMap, + pub metadata_str: BTreeMap, + /// Scalar f32 metadata (e.g. `qwen2.rope.freq_base`, `*.rms_norm_eps`). + pub metadata_f32: BTreeMap, + /// String-array metadata (e.g. `tokenizer.ggml.tokens`, `.merges`). + pub metadata_arr_str: BTreeMap>, + /// Int-array metadata (e.g. `tokenizer.ggml.token_type`). + pub metadata_arr_i32: BTreeMap>, + /// f32-array metadata (e.g. `tokenizer.ggml.scores` for SentencePiece). + pub metadata_arr_f32: BTreeMap>, +} + +struct Cursor<'a> { + b: &'a [u8], + p: usize, +} +impl<'a> Cursor<'a> { + fn u32(&mut self) -> u32 { + let v = u32::from_le_bytes(self.b[self.p..self.p + 4].try_into().unwrap()); + self.p += 4; + v + } + fn u64(&mut self) -> u64 { + let v = u64::from_le_bytes(self.b[self.p..self.p + 8].try_into().unwrap()); + self.p += 8; + v + } + fn f32(&mut self) -> f32 { + let v = f32::from_le_bytes(self.b[self.p..self.p + 4].try_into().unwrap()); + self.p += 4; + v + } + fn gstr(&mut self) -> String { + let n = self.u64() as usize; + let s = String::from_utf8_lossy(&self.b[self.p..self.p + n]).into_owned(); + self.p += n; + s + } + /// Read a scalar metadata value of `vtype`, returning whichever of + /// {u32 (any scalar int/bool), f32, String} it decodes to. (Arrays are + /// handled separately by the caller so the elements can be collected.) + fn read_scalar(&mut self, vtype: u32) -> (Option, Option, Option) { + match vtype { + 0 | 1 => { self.p += 1; (Some(self.b[self.p - 1] as u32), None, None) } + 2 | 3 => { let v = u16::from_le_bytes(self.b[self.p..self.p + 2].try_into().unwrap()); self.p += 2; (Some(v as u32), None, None) } + 4 | 5 => (Some(self.u32()), None, None), + 6 => { let v = self.f32(); (None, Some(v), None) } // f32 + 7 => { self.p += 1; (Some(self.b[self.p - 1] as u32), None, None) } // bool + 8 => (None, None, Some(self.gstr())), + 10 | 11 => { let v = self.u64(); (Some(v as u32), None, None) } + 12 => { self.p += 8; (None, None, None) } // f64 + _ => (None, None, None), + } + } + /// Consume one metadata value of `vtype`, skipping its bytes (used to walk + /// past array elements we don't keep). + fn skip_one(&mut self, vtype: u32) { + match vtype { + 0 | 1 | 7 => self.p += 1, + 2 | 3 => self.p += 2, + 4 | 5 | 6 => self.p += 4, + 10 | 11 | 12 => self.p += 8, + 8 => { let _ = self.gstr(); } + 9 => { let et = self.u32(); let len = self.u64(); for _ in 0..len { self.skip_one(et); } } + _ => {} + } + } +} + +fn f16_to_f32(bits: u16) -> f32 { + let sign = ((bits >> 15) & 1) as u32; + let exp = ((bits >> 10) & 0x1f) as u32; + let mant = (bits & 0x3ff) as u32; + let out = if exp == 0 { + if mant == 0 { + sign << 31 + } else { + // Subnormal half: value = mant * 2^-24. Normalize the mantissa so the + // implicit leading 1 lands in bit 10, tracking the resulting f32 + // exponent. A subnormal half has unbiased exponent -14; each left + // shift to reach the leading 1 lowers it by one more. + let mut m = mant; + let mut e: i32 = -14; + while m & 0x400 == 0 { + m <<= 1; + e -= 1; + } + m &= 0x3ff; // drop the now-implicit leading 1 + (sign << 31) | (((e + 127) as u32) << 23) | (m << 13) + } + } else if exp == 0x1f { + (sign << 31) | (0xff << 23) | (mant << 13) + } else { + (sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13) + }; + f32::from_bits(out) +} + +/// Unpack the j-th 6-bit (scale, min) pair from a Q4_K/Q5_K block's packed +/// 12-byte `scales` array. This is the canonical GGML `get_scale_min_k4`: +/// the 8 sub-block scales/mins are stored 6 bits each, split across the first +/// 8 bytes (low 6 bits) and the last 4 bytes (high 2 bits + extra 4-bit field). +#[inline] +fn get_scale_min_k4(j: usize, q: &[u8]) -> (u8, u8) { + if j < 4 { + let sc = q[j] & 63; + let m = q[j + 4] & 63; + (sc, m) + } else { + let sc = (q[j + 4] & 0xF) | ((q[j - 4] >> 6) << 4); + let m = (q[j + 4] >> 4) | ((q[j] >> 6) << 4); + (sc, m) + } +} + +/// One parsed GGUF header (metadata + tensor infos + aligned data start). +struct ParsedHeader { + metadata_u32: BTreeMap, + metadata_str: BTreeMap, + metadata_f32: BTreeMap, + metadata_arr_str: BTreeMap>, + metadata_arr_i32: BTreeMap>, + metadata_arr_f32: BTreeMap>, + tensors: Vec, + data_start: usize, +} + +/// Parse a single GGUF v3 file's header (KV metadata + tensor infos) from its +/// mmapped bytes. Shared by the single-file and split-file open paths. +fn parse_header(bytes: &[u8], path: &str) -> Result { + let mut c = Cursor { b: bytes, p: 0 }; + let magic = c.u32(); + if magic != 0x4655_4747 { + return Err(Error::Msg(format!("{path}: not a GGUF file (magic {magic:#x})"))); + } + let version = c.u32(); + if version != 3 { + return Err(Error::Msg(format!("{path}: GGUF version {version} unsupported (need 3)"))); + } + let n_tensors = c.u64(); + let n_kv = c.u64(); + + let mut metadata_u32 = BTreeMap::new(); + let mut metadata_str = BTreeMap::new(); + let mut metadata_f32 = BTreeMap::new(); + let mut metadata_arr_str: BTreeMap> = BTreeMap::new(); + let mut metadata_arr_i32: BTreeMap> = BTreeMap::new(); + let mut metadata_arr_f32: BTreeMap> = BTreeMap::new(); + let mut alignment: u64 = 32; + for _ in 0..n_kv { + let key = c.gstr(); + let vtype = c.u32(); + if vtype == 9 { + // array: elem_type u32, len u64, then len elems. We collect + // string arrays (tokenizer vocab/merges) and int arrays + // (token_type/scores indices); everything else is skipped. + let et = c.u32(); + let len = c.u64() as usize; + match et { + 8 => { + let mut v = Vec::with_capacity(len); + for _ in 0..len { v.push(c.gstr()); } + metadata_arr_str.insert(key.clone(), v); + } + 0 | 1 | 2 | 3 | 4 | 5 | 7 | 10 | 11 => { + let mut v = Vec::with_capacity(len); + for _ in 0..len { + let (u, _, _) = c.read_scalar(et); + v.push(u.unwrap_or(0) as i32); + } + metadata_arr_i32.insert(key.clone(), v); + } + 6 => { + // f32 array (e.g. SentencePiece `tokenizer.ggml.scores`). + let mut v = Vec::with_capacity(len); + for _ in 0..len { + let (_, fl, _) = c.read_scalar(et); + v.push(fl.unwrap_or(0.0)); + } + metadata_arr_f32.insert(key.clone(), v); + } + _ => { for _ in 0..len { c.skip_one(et); } } + } + continue; + } + let (u, fl, s) = c.read_scalar(vtype); + if let Some(u) = u { + if key == "general.alignment" { + alignment = u as u64; + } + metadata_u32.insert(key.clone(), u); + } + if let Some(fl) = fl { + metadata_f32.insert(key.clone(), fl); + } + if let Some(s) = s { + metadata_str.insert(key.clone(), s); + } + } + + let mut tensors = Vec::with_capacity(n_tensors as usize); + for _ in 0..n_tensors { + let name = c.gstr(); + let n_dims = c.u32() as usize; + let dims: Vec = (0..n_dims).map(|_| c.u64()).collect(); + let ggml_type = GgmlType::from_u32(c.u32()); + let offset = c.u64(); + tensors.push(GgufTensor { name, dims, ggml_type, offset, part: 0 }); + } + + // Align the data section start. + let data_start = c.p.next_multiple_of(alignment as usize); + Ok(ParsedHeader { + metadata_u32, + metadata_str, + metadata_f32, + metadata_arr_str, + metadata_arr_i32, + metadata_arr_f32, + tensors, + data_start, + }) +} + +/// If `path` matches the llama.cpp split pattern `*-NNNNN-of-MMMMM.gguf`, +/// return the list of all part paths in order (1..=MMMMM). Otherwise return +/// `None` (single-file GGUF). The given `path` may be any one of the parts. +fn split_parts(path: &str) -> Option> { + // Match the trailing "-NNNNN-of-MMMMM.gguf". + let stripped = path.strip_suffix(".gguf")?; + let dash = stripped.rfind("-of-")?; + let (left, mmmmm) = stripped.split_at(dash); + let mmmmm = &mmmmm["-of-".len()..]; + let nnnnn_dash = left.rfind('-')?; + let (prefix, nnnnn) = left.split_at(nnnnn_dash); + let nnnnn = &nnnnn[1..]; + // Both index fields must be all-digits and the same width. + if nnnnn.is_empty() || mmmmm.is_empty() + || !nnnnn.bytes().all(|b| b.is_ascii_digit()) + || !mmmmm.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + let total: usize = mmmmm.parse().ok()?; + let width = mmmmm.len(); + if total <= 1 { + return None; + } + Some( + (1..=total) + .map(|i| format!("{prefix}-{i:0width$}-of-{mmmmm}.gguf")) + .collect(), + ) +} + +impl Gguf { + pub fn open(path: &str) -> Result { + // Detect a split GGUF (`*-00001-of-00002.gguf`) and gather all parts; + // a single-file GGUF is just a one-element list. + let part_paths = split_parts(path).unwrap_or_else(|| vec![path.to_string()]); + + let mut parts: Vec = Vec::with_capacity(part_paths.len()); + let mut data_starts: Vec = Vec::with_capacity(part_paths.len()); + let mut tensors: BTreeMap = BTreeMap::new(); + let mut meta: Option = None; + + for (pi, pp) in part_paths.iter().enumerate() { + let file = std::fs::File::open(pp) + .map_err(|e| Error::Msg(format!("open {pp}: {e}")))?; + // SAFETY: the file is read-only and outlives the mapping; we treat + // it as an immutable byte slice. + let bytes = unsafe { memmap2::Mmap::map(&file) } + .map_err(|e| Error::Msg(format!("mmap {pp}: {e}")))?; + let mut hdr = parse_header(&bytes, pp)?; + data_starts.push(hdr.data_start); + let part_tensors = std::mem::take(&mut hdr.tensors); + for mut t in part_tensors { + t.part = pi; + tensors.insert(t.name.clone(), t); + } + // Keep the full metadata from part 0 (the split header carries it). + if pi == 0 { + meta = Some(hdr); + } + parts.push(bytes); + } + + let m = meta.expect("at least one part"); + Ok(Gguf { + parts, + data_starts, + tensors, + metadata_u32: m.metadata_u32, + metadata_str: m.metadata_str, + metadata_f32: m.metadata_f32, + metadata_arr_str: m.metadata_arr_str, + metadata_arr_i32: m.metadata_arr_i32, + metadata_arr_f32: m.metadata_arr_f32, + }) + } + + /// Read a `u32`/int scalar from metadata under `key`. + pub fn meta_u32(&self, key: &str) -> Option { + self.metadata_u32.get(key).copied() + } + /// Read an `f32` scalar from metadata under `key` (e.g. rope freq base, eps). + pub fn meta_f32(&self, key: &str) -> Option { + self.metadata_f32.get(key).copied() + } + /// Read a string scalar from metadata under `key`. + pub fn meta_str(&self, key: &str) -> Option<&str> { + self.metadata_str.get(key).map(|s| s.as_str()) + } + + pub fn tensor_names(&self) -> impl Iterator { + self.tensors.keys() + } + pub fn tensor(&self, name: &str) -> Option<&GgufTensor> { + self.tensors.get(name) + } + + fn raw(&self, t: &GgufTensor, n_bytes: usize) -> &[u8] { + let s = self.data_starts[t.part] + t.offset as usize; + &self.parts[t.part][s..s + n_bytes] + } + + /// Dequantize a tensor to f32. Supports F32, F16, Q8_0, the k-quants + /// (Q2_K, Q4_K, Q5_K, Q6_K) and IQ2_XXS. + pub fn dequant_f32(&self, name: &str) -> Result> { + let t = self.tensor(name).ok_or_else(|| Error::Msg(format!("tensor '{name}' not found")))?; + let n: usize = t.dims.iter().product::() as usize; + match t.ggml_type { + GgmlType::F32 => { + let b = self.raw(t, n * 4); + Ok(b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect()) + } + GgmlType::F16 => { + let b = self.raw(t, n * 2); + Ok(b.chunks_exact(2).map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()) + } + GgmlType::Q8_0 => { + // block of 32: f16 scale (2 bytes) + 32 int8. + let nblocks = n / 32; + let b = self.raw(t, nblocks * 34); + let mut out = Vec::with_capacity(n); + for blk in 0..nblocks { + let base = blk * 34; + let scale = f16_to_f32(u16::from_le_bytes([b[base], b[base + 1]])); + for i in 0..32 { + let q = b[base + 2 + i] as i8; + out.push(scale * q as f32); + } + } + Ok(out) + } + GgmlType::Q2K => { + // block_q2_K (QK_K=256, 84 bytes): scales[16] + qs[64] + d(f16) + dmin(f16). + const QK: usize = 256; + let nblocks = n / QK; + let b = self.raw(t, nblocks * 84); + let mut out = Vec::with_capacity(n); + for blk in 0..nblocks { + let base = blk * 84; + let scales = &b[base..base + 16]; + let qs = &b[base + 16..base + 80]; + let d = f16_to_f32(u16::from_le_bytes([b[base + 80], b[base + 81]])); + let dmin = f16_to_f32(u16::from_le_bytes([b[base + 82], b[base + 83]])); + let mut is = 0usize; + let mut q_off = 0usize; + for _n in (0..QK).step_by(128) { + let mut shift = 0u8; + for _j in 0..4 { + for half in 0..2 { + let sc = scales[is]; + is += 1; + let dl = d * (sc & 0xF) as f32; + let ml = dmin * (sc >> 4) as f32; + for l in 0..16 { + let q = (qs[q_off + half * 16 + l] >> shift) & 3; + out.push(dl * q as f32 - ml); + } + } + shift += 2; + } + q_off += 32; + } + } + Ok(out) + } + GgmlType::Q4K => { + // block_q4_K (QK_K=256, 144 bytes): + // d(f16) + dmin(f16) + scales[12] (6-bit packed, 8 sub-blocks) + // + qs[128] (4-bit codes; low nibbles then high nibbles). + // 8 sub-blocks of 32; sub-blocks 2j/2j+1 share the 64-byte qs + // chunk q (low nibble = even, high nibble = odd sub-block). + const QK: usize = 256; + let nblocks = n / QK; + let b = self.raw(t, nblocks * 144); + let mut out = Vec::with_capacity(n); + for blk in 0..nblocks { + let base = blk * 144; + let d = f16_to_f32(u16::from_le_bytes([b[base], b[base + 1]])); + let dmin = f16_to_f32(u16::from_le_bytes([b[base + 2], b[base + 3]])); + let scales = &b[base + 4..base + 16]; // 12 bytes + let qs = &b[base + 16..base + 144]; // 128 bytes + for j in 0..(QK / 64) { + // pair of sub-blocks (low/high nibble) over a 32-byte qs chunk + let q = &qs[j * 32..j * 32 + 32]; + let (sc1, m1) = get_scale_min_k4(2 * j, scales); + let d1 = d * sc1 as f32; + let min1 = dmin * m1 as f32; + let (sc2, m2) = get_scale_min_k4(2 * j + 1, scales); + let d2 = d * sc2 as f32; + let min2 = dmin * m2 as f32; + for l in 0..32 { + out.push(d1 * (q[l] & 0xF) as f32 - min1); + } + for l in 0..32 { + out.push(d2 * (q[l] >> 4) as f32 - min2); + } + } + } + Ok(out) + } + GgmlType::Q5K => { + // block_q5_K (QK_K=256, 176 bytes): + // d(f16) + dmin(f16) + scales[12] + qh[32] (1 high bit/elem) + // + qs[128] (4-bit low codes). Value = 4-bit low | (high bit << 4). + const QK: usize = 256; + let nblocks = n / QK; + let b = self.raw(t, nblocks * 176); + let mut out = Vec::with_capacity(n); + for blk in 0..nblocks { + let base = blk * 176; + let d = f16_to_f32(u16::from_le_bytes([b[base], b[base + 1]])); + let dmin = f16_to_f32(u16::from_le_bytes([b[base + 2], b[base + 3]])); + let scales = &b[base + 4..base + 16]; // 12 bytes + let qh = &b[base + 16..base + 48]; // 32 bytes + let qs = &b[base + 48..base + 176]; // 128 bytes + for j in 0..(QK / 64) { + let q = &qs[j * 32..j * 32 + 32]; + let (sc1, m1) = get_scale_min_k4(2 * j, scales); + let d1 = d * sc1 as f32; + let min1 = dmin * m1 as f32; + let (sc2, m2) = get_scale_min_k4(2 * j + 1, scales); + let d2 = d * sc2 as f32; + let min2 = dmin * m2 as f32; + // The two high-bit planes for this 64-elem chunk select + // bit 2j (low sub-block) and 2j+1 (high sub-block). + let bit_lo = 1u8 << (2 * j); + let bit_hi = 1u8 << (2 * j + 1); + for l in 0..32 { + let hi = if qh[l] & bit_lo != 0 { 16 } else { 0 }; + out.push(d1 * ((q[l] & 0xF) as i32 + hi) as f32 - min1); + } + for l in 0..32 { + let hi = if qh[l] & bit_hi != 0 { 16 } else { 0 }; + out.push(d2 * ((q[l] >> 4) as i32 + hi) as f32 - min2); + } + } + } + Ok(out) + } + GgmlType::Q6K => { + // block_q6_K (QK_K=256, 210 bytes): + // ql[128] (low 4 bits) + qh[64] (high 2 bits) + scales[16] + // (int8) + d(f16). 6-bit signed codes (q - 32) × per-16 scale. + const QK: usize = 256; + let nblocks = n / QK; + let b = self.raw(t, nblocks * 210); + let mut out = vec![0.0f32; n]; + for blk in 0..nblocks { + let base = blk * 210; + let ql = &b[base..base + 128]; + let qh = &b[base + 128..base + 192]; + let scales = &b[base + 192..base + 208]; // 16 int8 + let d = f16_to_f32(u16::from_le_bytes([b[base + 208], b[base + 209]])); + let out_blk = &mut out[blk * QK..blk * QK + QK]; + // Two 128-elem halves; each processes 32 lanes over 4 groups. + for n2 in 0..(QK / 128) { + let ql_h = &ql[n2 * 64..n2 * 64 + 64]; + let qh_h = &qh[n2 * 32..n2 * 32 + 32]; + let sc = &scales[n2 * 8..n2 * 8 + 8]; + let dst = &mut out_blk[n2 * 128..n2 * 128 + 128]; + for l in 0..32 { + let is = l / 16; + let q1 = ((ql_h[l] & 0xF) as i32 | (((qh_h[l] >> 0) & 3) as i32) << 4) - 32; + let q2 = ((ql_h[l + 32] & 0xF) as i32 | (((qh_h[l] >> 2) & 3) as i32) << 4) - 32; + let q3 = ((ql_h[l] >> 4) as i32 | (((qh_h[l] >> 4) & 3) as i32) << 4) - 32; + let q4 = ((ql_h[l + 32] >> 4) as i32 | (((qh_h[l] >> 6) & 3) as i32) << 4) - 32; + dst[l] = d * sc[is] as i8 as f32 * q1 as f32; + dst[l + 32] = d * sc[is + 2] as i8 as f32 * q2 as f32; + dst[l + 64] = d * sc[is + 4] as i8 as f32 * q3 as f32; + dst[l + 96] = d * sc[is + 6] as i8 as f32 * q4 as f32; + } + } + } + Ok(out) + } + GgmlType::Iq2Xxs => { + // block_iq2_xxs (256-elem, 66 bytes): d(f16) + qs[64]. qs is + // 8 groups of 8 bytes: 4 grid indices + a u32 of scale(>>28) + // and 4×7-bit sign indices into KSIGNS. + const QK: usize = 256; + let nblocks = n / QK; + let b = self.raw(t, nblocks * 66); + let mut out = vec![0.0f32; n]; + for blk in 0..nblocks { + let base = blk * 66; + let d = f16_to_f32(u16::from_le_bytes([b[base], b[base + 1]])); + let qs = &b[base + 2..base + 66]; // 64 bytes + for ib32 in 0..8 { + let g = ib32 * 8; + let aux8 = [qs[g], qs[g + 1], qs[g + 2], qs[g + 3]]; + let aux32 = u32::from_le_bytes([qs[g + 4], qs[g + 5], qs[g + 6], qs[g + 7]]); + let db = d * 0.25 * (0.5 + (aux32 >> 28) as f32); + for l in 0..4 { + let grid = &IQ2XXS_GRID[aux8[l] as usize]; + let signs = KSIGNS[((aux32 >> (7 * l)) & 127) as usize]; + for j in 0..8 { + let s = if signs & (1 << j) != 0 { -1.0 } else { 1.0 }; + out[blk * QK + ib32 * 32 + l * 8 + j] = db * grid[j] as f32 * s; + } + } + } + } + Ok(out) + } + other => Err(Error::Msg(format!("dequant '{name}': {other:?} not supported"))), + } + } + + /// Repack a 2-D weight tensor into the resident-Q8 layout `ffai_ops::gemv_q8` + /// consumes: `(qs, scales, m, k)` where `m`/`k` are the [out, in] matrix dims, + /// `scales` is `[m * k/32]` f32 (one per 32-block), and `qs` is the int8 + /// codes packed 4-per-u32 (8 u32/block) at `qs[r*(k/32)*8 + b*8 + i/4]`. + /// + /// For a Q8_0 tensor this is a *lossless* repack of the on-disk blocks (the + /// f16 block scale is widened to f32, the 32 int8 are re-packed) — no second + /// quantization. F16/F32 tensors are quantized per-32-block via amax/127, the + /// identical scheme to `ffai_ops::quantize_q8`. `k` (the in-dim, fastest GGUF + /// dim) must be a multiple of 32. + pub fn q8_repack(&self, name: &str) -> Result<(Vec, Vec, usize, usize)> { + let t = self.tensor(name).ok_or_else(|| Error::Msg(format!("tensor '{name}' not found")))?; + if t.dims.len() != 2 { + return Err(Error::Msg(format!("q8_repack '{name}': expected 2-D, got {:?}", t.dims))); + } + // GGUF dims are fastest-first: a [out, in] matrix is listed as [in, out]. + let k = t.dims[0] as usize; // in (fastest, row stride) + let m = t.dims[1] as usize; // out (rows) + if k % 32 != 0 { + return Err(Error::Msg(format!("q8_repack '{name}': in-dim {k} not a multiple of 32"))); + } + let bpr = k / 32; + let mut qs = vec![0u32; m * bpr * 8]; + let mut scales = vec![0f32; m * bpr]; + match t.ggml_type { + GgmlType::Q8_0 => { + // On-disk block of 32: f16 scale (2 bytes) + 32 int8. The block + // order is row-major over [out, in], so block (r,b) is at linear + // block index r*bpr + b — exactly the gemv_q8 ordering. Lossless. + let nblocks = m * bpr; + let b = self.raw(t, nblocks * 34); + for blk in 0..nblocks { + let base = blk * 34; + scales[blk] = f16_to_f32(u16::from_le_bytes([b[base], b[base + 1]])); + for w_i in 0..8 { + let mut packed = 0u32; + for i in 0..4 { + let byte = b[base + 2 + w_i * 4 + i]; + packed |= (byte as u32) << (i * 8); + } + qs[blk * 8 + w_i] = packed; + } + } + } + // F16/F32 and the k-quants (Q2_K/Q4_K/Q5_K/Q6_K/IQ2_XXS) are first + // dequantized to f32, then re-quantized per-32-block to Q8 (amax/127). + GgmlType::F32 + | GgmlType::F16 + | GgmlType::Q2K + | GgmlType::Q4K + | GgmlType::Q5K + | GgmlType::Q6K + | GgmlType::Iq2Xxs => { + let w = self.dequant_f32(name)?; // row-major [out, in] + for r in 0..m { + for b in 0..bpr { + let base = r * k + b * 32; + let amax = (0..32).fold(0f32, |a, i| a.max(w[base + i].abs())); + let d = amax / 127.0; + scales[r * bpr + b] = d; + let inv = if d > 0.0 { 1.0 / d } else { 0.0 }; + for w_i in 0..8 { + let mut packed = 0u32; + for i in 0..4 { + let q = (w[base + w_i * 4 + i] * inv).round().clamp(-127.0, 127.0) as i32; + packed |= ((q as u8) as u32) << (i * 8); + } + qs[r * bpr * 8 + b * 8 + w_i] = packed; + } + } + } + } + other => return Err(Error::Msg(format!("q8_repack '{name}': {other:?} not supported"))), + } + Ok((qs, scales, m, k)) + } +} diff --git a/rust/crates/ffai-loader/src/iq2xxs_tables.rs b/rust/crates/ffai-loader/src/iq2xxs_tables.rs new file mode 100644 index 00000000..7ad9ff5c --- /dev/null +++ b/rust/crates/ffai-loader/src/iq2xxs_tables.rs @@ -0,0 +1,260 @@ +// IQ2_XXS codebook (extracted from gguf-py). 256 entries x 8 int8. +pub(crate) static IQ2XXS_GRID: [[i8; 8]; 256] = [ + [8,8,8,8,8,8,8,8], + [43,8,8,8,8,8,8,8], + [25,25,8,8,8,8,8,8], + [8,43,8,8,8,8,8,8], + [43,43,8,8,8,8,8,8], + [25,8,25,8,8,8,8,8], + [8,25,25,8,8,8,8,8], + [8,8,43,8,8,8,8,8], + [43,8,43,8,8,8,8,8], + [8,43,43,8,8,8,8,8], + [43,43,43,8,8,8,8,8], + [25,8,8,25,8,8,8,8], + [8,25,8,25,8,8,8,8], + [8,8,25,25,8,8,8,8], + [8,43,25,25,8,8,8,8], + [25,8,43,25,8,8,8,8], + [8,25,43,25,8,8,8,8], + [8,8,8,43,8,8,8,8], + [43,8,8,43,8,8,8,8], + [43,43,8,43,8,8,8,8], + [43,8,43,43,8,8,8,8], + [25,8,8,8,25,8,8,8], + [8,25,8,8,25,8,8,8], + [8,8,25,8,25,8,8,8], + [25,25,25,8,25,8,8,8], + [8,8,8,25,25,8,8,8], + [8,25,8,43,25,8,8,8], + [8,43,25,43,25,8,8,8], + [8,8,8,8,43,8,8,8], + [43,8,8,8,43,8,8,8], + [43,8,43,8,43,8,8,8], + [43,8,8,43,43,8,8,8], + [25,8,8,8,8,25,8,8], + [8,25,8,8,8,25,8,8], + [8,8,25,8,8,25,8,8], + [25,8,43,8,8,25,8,8], + [8,25,43,8,8,25,8,8], + [8,8,8,25,8,25,8,8], + [43,8,8,25,8,25,8,8], + [8,43,8,25,8,25,8,8], + [8,8,43,25,8,25,8,8], + [25,8,8,43,8,25,8,8], + [8,25,8,43,8,25,8,8], + [8,8,25,43,8,25,8,8], + [8,25,43,43,8,25,8,8], + [8,8,8,8,25,25,8,8], + [43,8,8,8,25,25,8,8], + [8,43,8,8,25,25,8,8], + [8,8,43,8,25,25,8,8], + [43,25,8,25,25,25,8,8], + [25,43,43,25,25,25,8,8], + [8,8,8,43,25,25,8,8], + [25,8,25,43,25,25,8,8], + [25,43,8,8,43,25,8,8], + [8,8,25,8,43,25,8,8], + [8,8,8,25,43,25,8,8], + [8,25,8,43,43,25,8,8], + [8,25,43,43,43,25,8,8], + [8,8,8,8,8,43,8,8], + [25,25,8,8,8,43,8,8], + [8,43,8,8,8,43,8,8], + [8,25,25,8,8,43,8,8], + [8,43,43,8,8,43,8,8], + [25,8,8,25,8,43,8,8], + [8,25,8,25,8,43,8,8], + [8,8,25,25,8,43,8,8], + [43,8,25,25,8,43,8,8], + [8,43,8,43,8,43,8,8], + [8,25,8,8,25,43,8,8], + [8,8,8,25,25,43,8,8], + [43,8,8,8,43,43,8,8], + [8,25,25,8,43,43,8,8], + [25,8,8,8,8,8,25,8], + [8,25,8,8,8,8,25,8], + [8,8,25,8,8,8,25,8], + [25,8,43,8,8,8,25,8], + [8,8,8,25,8,8,25,8], + [8,8,43,25,8,8,25,8], + [8,25,8,43,8,8,25,8], + [8,8,25,43,8,8,25,8], + [25,25,25,43,8,8,25,8], + [8,8,8,8,25,8,25,8], + [8,43,8,8,25,8,25,8], + [8,8,43,8,25,8,25,8], + [8,8,25,25,25,8,25,8], + [43,43,25,25,25,8,25,8], + [8,8,8,43,25,8,25,8], + [8,25,43,8,43,8,25,8], + [25,25,8,25,43,8,25,8], + [8,8,8,8,8,25,25,8], + [8,43,8,8,8,25,25,8], + [8,8,43,8,8,25,25,8], + [25,25,43,8,8,25,25,8], + [25,43,8,25,8,25,25,8], + [8,8,8,43,8,25,25,8], + [8,43,25,8,25,25,25,8], + [43,8,43,25,25,25,25,8], + [8,8,8,8,43,25,25,8], + [43,25,25,8,43,25,25,8], + [25,8,8,8,8,43,25,8], + [8,25,8,8,8,43,25,8], + [8,8,25,8,8,43,25,8], + [8,8,8,25,8,43,25,8], + [25,8,8,43,8,43,25,8], + [8,8,8,8,25,43,25,8], + [25,25,8,8,25,43,25,8], + [8,8,43,43,25,43,25,8], + [25,8,25,25,43,43,25,8], + [8,8,8,8,8,8,43,8], + [43,8,8,8,8,8,43,8], + [43,43,8,8,8,8,43,8], + [8,25,8,25,8,8,43,8], + [25,8,43,25,8,8,43,8], + [8,8,8,43,8,8,43,8], + [43,8,8,43,8,8,43,8], + [25,43,43,8,25,8,43,8], + [8,43,8,25,25,8,43,8], + [8,8,8,8,43,8,43,8], + [43,8,8,8,43,8,43,8], + [25,8,8,8,8,25,43,8], + [8,25,8,8,8,25,43,8], + [8,8,25,8,8,25,43,8], + [8,8,8,25,8,25,43,8], + [43,25,25,25,8,25,43,8], + [8,8,8,8,25,25,43,8], + [25,8,8,25,25,25,43,8], + [8,25,43,25,25,25,43,8], + [8,8,25,43,43,25,43,8], + [8,43,8,8,8,43,43,8], + [8,8,43,8,8,43,43,8], + [8,25,25,43,8,43,43,8], + [8,25,8,25,43,43,43,8], + [25,8,8,8,8,8,8,25], + [8,25,8,8,8,8,8,25], + [8,8,25,8,8,8,8,25], + [8,43,25,8,8,8,8,25], + [25,8,43,8,8,8,8,25], + [8,25,43,8,8,8,8,25], + [8,8,8,25,8,8,8,25], + [8,43,8,25,8,8,8,25], + [43,25,25,25,8,8,8,25], + [8,8,43,25,8,8,8,25], + [25,8,8,43,8,8,8,25], + [8,25,8,43,8,8,8,25], + [8,8,25,43,8,8,8,25], + [8,8,8,8,25,8,8,25], + [8,8,43,8,25,8,8,25], + [25,8,43,25,25,8,8,25], + [8,8,8,43,25,8,8,25], + [25,25,8,43,25,8,8,25], + [25,8,8,8,43,8,8,25], + [8,8,25,8,43,8,8,25], + [8,43,8,25,43,8,8,25], + [43,25,25,25,43,8,8,25], + [8,43,43,25,43,8,8,25], + [8,8,8,8,8,25,8,25], + [8,43,8,8,8,25,8,25], + [8,8,43,8,8,25,8,25], + [8,8,8,43,8,25,8,25], + [25,43,25,43,8,25,8,25], + [43,8,25,8,25,25,8,25], + [8,25,43,8,25,25,8,25], + [8,8,8,8,43,25,8,25], + [25,8,8,8,8,43,8,25], + [8,25,8,8,8,43,8,25], + [8,8,25,8,8,43,8,25], + [8,8,8,25,8,43,8,25], + [25,25,8,25,8,43,8,25], + [8,8,8,8,25,43,8,25], + [8,43,25,25,25,43,8,25], + [25,8,43,25,25,43,8,25], + [43,8,8,43,25,43,8,25], + [25,25,8,25,43,43,8,25], + [8,8,25,43,43,43,8,25], + [8,8,8,8,8,8,25,25], + [8,43,8,8,8,8,25,25], + [25,8,25,8,8,8,25,25], + [25,43,25,8,8,8,25,25], + [8,8,43,8,8,8,25,25], + [8,8,8,43,8,8,25,25], + [8,43,8,43,8,8,25,25], + [8,25,8,8,25,8,25,25], + [43,8,8,25,25,8,25,25], + [8,25,43,43,25,8,25,25], + [25,8,25,43,43,8,25,25], + [8,8,25,43,8,25,25,25], + [43,8,25,43,8,25,25,25], + [43,43,8,8,25,25,25,25], + [25,8,8,8,43,25,25,25], + [8,25,25,25,43,25,25,25], + [8,8,8,8,8,43,25,25], + [25,8,25,8,8,43,25,25], + [25,43,25,8,8,43,25,25], + [8,25,43,25,8,43,25,25], + [8,8,8,25,25,43,25,25], + [8,43,8,8,43,43,25,25], + [8,25,8,8,8,8,43,25], + [8,8,25,8,8,8,43,25], + [8,8,8,25,8,8,43,25], + [8,43,43,25,8,8,43,25], + [8,8,8,8,25,8,43,25], + [25,25,25,25,25,8,43,25], + [8,43,25,8,43,8,43,25], + [8,8,43,25,43,8,43,25], + [8,8,8,8,8,25,43,25], + [25,25,8,8,8,25,43,25], + [8,8,25,8,25,25,43,25], + [43,8,25,8,25,25,43,25], + [8,25,8,43,25,25,43,25], + [43,8,8,25,8,43,43,25], + [8,8,8,8,8,8,8,43], + [43,8,8,8,8,8,8,43], + [43,43,8,8,8,8,8,43], + [25,8,8,25,8,8,8,43], + [43,8,8,43,8,8,8,43], + [8,25,8,8,25,8,8,43], + [8,43,25,8,25,8,8,43], + [8,8,8,25,25,8,8,43], + [25,8,25,8,43,8,8,43], + [25,8,8,8,8,25,8,43], + [8,25,8,8,8,25,8,43], + [8,8,25,8,8,25,8,43], + [25,25,25,8,8,25,8,43], + [8,8,8,25,8,25,8,43], + [8,8,43,25,8,25,8,43], + [8,8,8,8,25,25,8,43], + [43,25,8,25,25,25,8,43], + [8,25,25,43,25,25,8,43], + [25,43,8,8,43,25,8,43], + [8,8,8,25,43,25,8,43], + [8,8,43,25,43,25,8,43], + [43,8,8,8,8,43,8,43], + [8,25,8,8,25,43,8,43], + [25,8,25,8,43,43,8,43], + [8,25,8,8,8,8,25,43], + [8,8,25,8,8,8,25,43], + [8,25,43,8,8,8,25,43], + [8,8,8,25,8,8,25,43], + [25,8,43,43,8,8,25,43], + [43,25,25,8,25,8,25,43], + [8,8,8,43,25,8,25,43], + [25,25,8,25,43,8,25,43], + [8,8,8,8,8,25,25,43], + [43,8,43,8,8,25,25,43], + [8,25,8,25,8,25,25,43], + [25,8,25,25,25,25,25,43], + [25,8,8,43,8,43,25,43], + [8,8,43,8,25,43,25,43], + [43,8,8,8,8,8,43,43], + [8,8,25,25,8,8,43,43], + [25,25,8,43,8,8,43,43], + [25,43,8,8,25,8,43,43], + [8,8,8,8,43,8,43,43], + [8,43,25,8,8,25,43,43], + [8,8,25,25,8,43,43,43], + [8,25,8,8,25,43,43,43], +]; +pub(crate) static KSIGNS: [u8; 128] = [0,129,130,3,132,5,6,135,136,9,10,139,12,141,142,15,144,17,18,147,20,149,150,23,24,153,154,27,156,29,30,159,160,33,34,163,36,165,166,39,40,169,170,43,172,45,46,175,48,177,178,51,180,53,54,183,184,57,58,187,60,189,190,63,192,65,66,195,68,197,198,71,72,201,202,75,204,77,78,207,80,209,210,83,212,85,86,215,216,89,90,219,92,221,222,95,96,225,226,99,228,101,102,231,232,105,106,235,108,237,238,111,240,113,114,243,116,245,246,119,120,249,250,123,252,125,126,255]; diff --git a/rust/crates/ffai-loader/src/lib.rs b/rust/crates/ffai-loader/src/lib.rs new file mode 100644 index 00000000..95acc49c --- /dev/null +++ b/rust/crates/ffai-loader/src/lib.rs @@ -0,0 +1,494 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-loader +//! +//! Weight loaders. Pure CPU byte-parsing + upload through the +//! [`Device`](ffai_core::Device) trait — no GPU API, fully shared across +//! backends. SafeTensors is implemented; GGUF / HF follow. + +use ffai_core::{DType, Error, Result}; +use std::collections::BTreeMap; + +/// One tensor's location + metadata inside a SafeTensors blob. +#[derive(Debug, Clone)] +pub struct TensorInfo { + pub dtype: DType, + pub shape: Vec, + shard: usize, + begin: usize, + end: usize, +} + +/// One mmap'd `.safetensors` shard: the map + where its data section starts. +struct Shard { + map: memmap2::Mmap, + data_start: usize, +} + +/// One or more mmap'd `.safetensors` files (single or sharded). `tensor()` +/// returns a zero-copy slice routed to the right shard. mmap keeps the 14GB+ +/// sharded checkpoints off the heap. +pub struct SafeTensors { + shards: Vec, + index: BTreeMap, +} + +/// IEEE half → f32. +fn f16_to_f32(bits: u16) -> f32 { + let sign = ((bits >> 15) & 1) as u32; + let exp = ((bits >> 10) & 0x1f) as u32; + let mant = (bits & 0x3ff) as u32; + let out = if exp == 0 { + if mant == 0 { sign << 31 } else { + let mut e = -1i32; let mut m = mant; + while m & 0x400 == 0 { m <<= 1; e -= 1; } + (sign << 31) | (((e + 127 - 15) as u32) << 23) | ((m & 0x3ff) << 13) + } + } else if exp == 0x1f { + (sign << 31) | (0xff << 23) | (mant << 13) + } else { + (sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13) + }; + f32::from_bits(out) +} + +fn parse_dtype(s: &str) -> Result { + Ok(match s { + "F32" => DType::F32, + "F16" => DType::F16, + "BF16" => DType::BF16, + "I32" => DType::I32, + "U32" => DType::U32, + "I8" => DType::I8, + "U8" => DType::U8, + other => return Err(Error::Msg(format!("safetensors: unsupported dtype {other}"))), + }) +} + +impl SafeTensors { + /// mmap + parse one `.safetensors` shard, merging its tensors into `index` + /// tagged with `shard_idx`. Returns the constructed [`Shard`]. + fn open_shard(path: &str, shard_idx: usize, index: &mut BTreeMap) -> Result { + let file = std::fs::File::open(path).map_err(|e| Error::Msg(format!("open {path}: {e}")))?; + // SAFETY: read-only file outlives the mapping; treated as immutable bytes. + let map = unsafe { memmap2::Mmap::map(&file) } + .map_err(|e| Error::Msg(format!("mmap {path}: {e}")))?; + if map.len() < 8 { + return Err(Error::Msg("safetensors: file too small".into())); + } + let header_len = u64::from_le_bytes(map[..8].try_into().unwrap()) as usize; + let header_end = 8 + header_len; + let header: serde_json::Value = serde_json::from_slice(&map[8..header_end]) + .map_err(|e| Error::Msg(format!("safetensors header JSON: {e}")))?; + let obj = header + .as_object() + .ok_or_else(|| Error::Msg("safetensors: header not an object".into()))?; + for (name, v) in obj { + if name == "__metadata__" { + continue; + } + // Omni/multimodal checkpoints mix in dtypes we don't decode (I64 + // position buffers, F8 vision scales, …). Skip those tensors rather + // than failing the whole load — callers only ask for the ones they need. + let dtype = match parse_dtype(v["dtype"].as_str().ok_or_else(|| Error::Msg("missing dtype".into()))?) { + Ok(dt) => dt, + Err(_) => continue, + }; + let shape: Vec = v["shape"] + .as_array() + .ok_or_else(|| Error::Msg("missing shape".into()))? + .iter() + .map(|x| x.as_u64().unwrap_or(0) as usize) + .collect(); + let off = v["data_offsets"] + .as_array() + .ok_or_else(|| Error::Msg("missing data_offsets".into()))?; + let begin = off[0].as_u64().unwrap() as usize; + let end = off[1].as_u64().unwrap() as usize; + index.insert(name.clone(), TensorInfo { dtype, shape, shard: shard_idx, begin, end }); + } + Ok(Shard { map, data_start: header_end }) + } + + /// Open + parse a single `.safetensors` file. + pub fn open(path: &str) -> Result { + let mut index = BTreeMap::new(); + let shard = Self::open_shard(path, 0, &mut index)?; + Ok(SafeTensors { shards: vec![shard], index }) + } + + /// Open a model directory: sharded (`model-XXXXX-of-YYYYY.safetensors` per + /// `model.safetensors.index.json`) or single (`model.safetensors`). All + /// shards are mmap'd and merged into one tensor index. + pub fn open_dir(dir: &str) -> Result { + let idx_path = format!("{dir}/model.safetensors.index.json"); + let files: Vec = if std::path::Path::new(&idx_path).exists() { + let txt = std::fs::read_to_string(&idx_path) + .map_err(|e| Error::Msg(format!("read {idx_path}: {e}")))?; + let j: serde_json::Value = serde_json::from_str(&txt) + .map_err(|e| Error::Msg(format!("index json: {e}")))?; + let wm = j["weight_map"].as_object() + .ok_or_else(|| Error::Msg("index: no weight_map".into()))?; + let mut set: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for v in wm.values() { + if let Some(f) = v.as_str() { set.insert(format!("{dir}/{f}")); } + } + set.into_iter().collect() + } else { + vec![format!("{dir}/model.safetensors")] + }; + let mut index = BTreeMap::new(); + let mut shards = Vec::with_capacity(files.len()); + for (i, f) in files.iter().enumerate() { + shards.push(Self::open_shard(f, i, &mut index)?); + } + Ok(SafeTensors { shards, index }) + } + + pub fn names(&self) -> impl Iterator { + self.index.keys() + } + + pub fn info(&self, name: &str) -> Option<&TensorInfo> { + self.index.get(name) + } + + /// Tensor decoded to `f32` (handles F32 / F16 / BF16 on disk) + its shape. + /// The convenience every host-orchestrated model test wants — checkpoints + /// ship in any of the three float widths. + /// BF16 tensors are decoded in parallel across all available CPU cores to + /// reduce the 62GB Nemotron load from ~110s to ~15-20s (stdlib threads only). + // ── MLX 4-bit affine quant support ─────────────────────────────────────── + // MLX stores a quantized matrix as three tensors: + // .weight U32 [rows, in/8] — 8 nibbles packed per u32, element j at + // bits 4*j (low nibble = element 0) + // .scales BF16 [rows, in/gs] — per-group scale + // .biases BF16 [rows, in/gs] — per-group bias + // Dequant (mode="affine"): w = q * scale + bias, group_size gs along `in`. + // This lets the BF16-expecting model tests consume an MLX 4-bit checkpoint + // transparently — `tensor_f32` returns the dense [rows, in] f32 weight. + + /// Decode a BF16/F16/F32 tensor's raw bytes to an f32 vec (small aux tensors). + fn bytes_to_f32(b: &[u8], dt: DType) -> Vec { + match dt { + DType::F32 => b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(), + DType::F16 => b.chunks_exact(2).map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect(), + DType::BF16 => b.chunks_exact(2).map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect(), + _ => Vec::new(), + } + } + + /// True if `.scales` exists — i.e. `.weight` is MLX-quantized. + fn is_mlx_quant(&self, base: &str) -> bool { + self.index.contains_key(&format!("{base}.scales")) + } + + /// MLX-affine-dequantize `.weight` (+ `.scales`/`.biases`) to a dense + /// f32 matrix `[rows, in]` where `in = packed_cols * 8`. Handles both 2-D + /// `[rows, in/8]` and 3-D `[E, rows, in/8]` (flattened to `[E*rows, in]`). + fn mlx_dequant_f32(&self, base: &str) -> Result<(Vec, Vec)> { + let (wb, wdt, wsh) = self.tensor(&format!("{base}.weight"))?; + if wdt != DType::U32 { + return Err(Error::Msg(format!("mlx_dequant '{base}': weight dtype {wdt} not U32"))); + } + let (sb, sdt, ssh) = self.tensor(&format!("{base}.scales"))?; + let scales = Self::bytes_to_f32(sb, sdt); + // biases optional (affine without bias is rare here, but guard anyway). + let biases = match self.tensor(&format!("{base}.biases")) { + Ok((bb, bdt, _)) => Self::bytes_to_f32(bb, bdt), + Err(_) => vec![0.0f32; scales.len()], + }; + // Collapse leading expert dim if 3-D: [E, rows, packed] → rows' = E*rows. + let packed_cols = *wsh.last().unwrap(); + let rows: usize = wsh[..wsh.len() - 1].iter().product(); + let in_dim = packed_cols * 8; + let groups = *ssh.last().unwrap(); // scales last dim = in_dim / group_size + let group_size = in_dim / groups; + let words: Vec = wb.chunks_exact(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect(); + // Parallel dequant across rows (rows are independent; ~52×128 expert rows). + let mut out = vec![0f32; rows * in_dim]; + let n_threads = std::thread::available_parallelism().map(|p| p.get()).unwrap_or(1).min(rows.max(1)); + let chunk_rows = rows.div_ceil(n_threads); + let out_base = out.as_mut_ptr() as usize; + let words_ptr = words.as_ptr() as usize; + let scales_ptr = scales.as_ptr() as usize; + let biases_ptr = biases.as_ptr() as usize; + let words_len = words.len(); + std::thread::scope(|scope| { + for t in 0..n_threads { + let r0 = t * chunk_rows; + let r1 = (r0 + chunk_rows).min(rows); + if r0 >= r1 { continue; } + scope.spawn(move || { + // SAFETY: disjoint row ranges into `out`; read-only shared inputs. + let out_s = unsafe { std::slice::from_raw_parts_mut(out_base as *mut f32, rows * in_dim) }; + let words_s = unsafe { std::slice::from_raw_parts(words_ptr as *const u32, words_len) }; + let scales_s = unsafe { std::slice::from_raw_parts(scales_ptr as *const f32, rows * groups) }; + let biases_s = unsafe { std::slice::from_raw_parts(biases_ptr as *const f32, rows * groups) }; + for r in r0..r1 { + let wrow = r * packed_cols; + let grow = r * groups; + let orow = r * in_dim; + for col in 0..in_dim { + let word = words_s[wrow + col / 8]; + let q = (word >> (4 * (col % 8))) & 0xf; + let g = col / group_size; + out_s[orow + col] = q as f32 * scales_s[grow + g] + biases_s[grow + g]; + } + } + }); + } + }); + Ok((out, vec![rows, in_dim])) + } + + /// Resolve a model-test tensor NAME against this checkpoint, transparently + /// handling two real-world skews so the dense BF16-oriented model tests can + /// load an MLX 4-bit Nemotron-Cascade checkpoint unchanged: + /// 1. `language_model.` prefix present in the Omni naming but absent here. + /// 2. routed experts requested per-expert (`...experts.{e}.up_proj.weight`) + /// but packed here as `...switch_mlp.fc1.weight[e]` (up) / `.fc2`(down). + /// Returns the dense f32 weight + shape, MLX-dequantizing on the fly when the + /// resolved tensor is MLX-quantized. Returns None if no remap applies (caller + /// falls back to the plain path). + fn resolve_f32(&self, name: &str) -> Option, Vec)>> { + // Candidate names: as-is, then with the `language_model.` prefix stripped. + let stripped = name.strip_prefix("language_model.").map(|s| s.to_string()); + for cand in [Some(name.to_string()), stripped].into_iter().flatten() { + // (a) per-expert → packed switch_mlp slice. + if let Some(rest) = cand.strip_suffix(".weight") { + // ...mixer.experts.{e}.up_proj / .down_proj + if let Some(idx) = rest.find(".experts.") { + let prefix = &rest[..idx]; // ...mixer + let tail = &rest[idx + ".experts.".len()..]; // {e}.up_proj or {e}.down_proj + if let Some((e_str, proj)) = tail.split_once('.') { + if let Ok(e) = e_str.parse::() { + let fc = if proj == "up_proj" { "fc1" } else { "fc2" }; + let packed = format!("{prefix}.switch_mlp.{fc}"); + if self.is_mlx_quant(&packed) { + return Some(self.mlx_expert_slice_f32(&packed, e)); + } + } + } + } + // (b) plain MLX-quantized weight. + if self.is_mlx_quant(rest) { + return Some(self.mlx_dequant_f32(rest)); + } + } + // (c) plain present tensor under the candidate name (e.g. prefix strip + // for non-quantized tensors: norms, conv1d, A_log, D, gate bias…). + if cand != name && self.index.contains_key(&cand) { + return Some(self.tensor_f32_raw(&cand)); + } + } + None + } + + /// Dequant ONLY expert `e`'s slab from a packed MLX `switch_mlp.fcN` tensor + /// (3-D `[E, rows, packed]`) → dense `[rows, in]` f32. Touches only expert e's + /// bytes (not all E experts) so the per-expert MoE setup loop stays O(E), not + /// O(E²). + fn mlx_expert_slice_f32(&self, packed: &str, e: usize) -> Result<(Vec, Vec)> { + let (wb, wdt, wsh) = self.tensor(&format!("{packed}.weight"))?; // [E, rows, packed] + if wdt != DType::U32 { + return Err(Error::Msg(format!("mlx_expert_slice '{packed}': weight dtype {wdt} not U32"))); + } + if wsh.len() != 3 { + return Err(Error::Msg(format!("mlx_expert_slice '{packed}': expected 3-D weight, got {wsh:?}"))); + } + let (n_exp, rows, packed_cols) = (wsh[0], wsh[1], wsh[2]); + let in_dim = packed_cols * 8; + let (sb, sdt, ssh) = self.tensor(&format!("{packed}.scales"))?; // [E, rows, groups] + let groups = *ssh.last().unwrap(); + let group_size = in_dim / groups; + let scales_all = Self::bytes_to_f32(sb, sdt); + let biases_all = match self.tensor(&format!("{packed}.biases")) { + Ok((bb, bdt, _)) => Self::bytes_to_f32(bb, bdt), + Err(_) => vec![0.0f32; scales_all.len()], + }; + if e >= n_exp { + return Err(Error::Msg(format!("mlx_expert_slice '{packed}': expert {e} >= {n_exp}"))); + } + // Byte/element offsets for expert e. + let words_per_exp = rows * packed_cols; + let w_off = e * words_per_exp; // in u32 words + let g_off = e * rows * groups; // in scale/bias elements + let w_bytes = &wb[w_off * 4..(w_off + words_per_exp) * 4]; + let words: Vec = w_bytes.chunks_exact(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect(); + let scales = &scales_all[g_off..g_off + rows * groups]; + let biases = &biases_all[g_off..g_off + rows * groups]; + let mut out = vec![0f32; rows * in_dim]; + for r in 0..rows { + let wrow = r * packed_cols; + let grow = r * groups; + let orow = r * in_dim; + for col in 0..in_dim { + let word = words[wrow + col / 8]; + let q = (word >> (4 * (col % 8))) & 0xf; + let g = col / group_size; + out[orow + col] = q as f32 * scales[grow + g] + biases[grow + g]; + } + } + Ok((out, vec![rows, in_dim])) + } + + /// The original plain decode path (F32/F16/BF16 only), no MLX/remap. + fn tensor_f32_raw(&self, name: &str) -> Result<(Vec, Vec)> { + let (b, dt, shape) = self.tensor(name)?; + Self::decode_f32(b, dt, shape) + } + + fn decode_f32(b: &[u8], dt: DType, shape: &[usize]) -> Result<(Vec, Vec)> { + let v = match dt { + DType::F32 => b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(), + DType::F16 => b.chunks_exact(2).map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect(), + DType::BF16 => { + let n_elems = b.len() / 2; + let n_threads = std::thread::available_parallelism().map(|p| p.get()).unwrap_or(1).min(n_elems.max(1)); + if n_threads <= 1 || n_elems < 4096 { + b.chunks_exact(2).map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect() + } else { + let chunk_elems = (n_elems + n_threads - 1) / n_threads; + let mut out = vec![0f32; n_elems]; + let out_base = out.as_mut_ptr(); + let b_base = b.as_ptr(); + let jobs: Vec<(usize, usize, usize)> = (0..n_threads).filter_map(|t| { + let start = t * chunk_elems; + let len = chunk_elems.min(n_elems.saturating_sub(start)); + if len == 0 { return None; } + Some((out_base as usize + start * 4, b_base as usize + start * 2, len)) + }).collect(); + std::thread::scope(|scope| { + for (dst_addr, src_addr, len) in &jobs { + let (dst_addr, src_addr, len) = (*dst_addr, *src_addr, *len); + scope.spawn(move || { + let d: &mut [f32] = unsafe { std::slice::from_raw_parts_mut(dst_addr as *mut f32, len) }; + let s: &[u8] = unsafe { std::slice::from_raw_parts(src_addr as *const u8, len * 2) }; + for (di, c) in d.iter_mut().zip(s.chunks_exact(2)) { + *di = f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16); + } + }); + } + }); + out + } + } + other => return Err(Error::Msg(format!("decode_f32: dtype {other} unsupported"))), + }; + Ok((v, shape.to_vec())) + } + + pub fn tensor_f32(&self, name: &str) -> Result<(Vec, Vec)> { + // Fast path: tensor present verbatim AND not MLX-quantized. + if let Some(info) = self.index.get(name) { + if info.dtype != DType::U32 || !name.ends_with(".weight") + || !self.is_mlx_quant(name.strip_suffix(".weight").unwrap_or(name)) + { + let (b, dt, shape) = self.tensor(name)?; + return Self::decode_f32(b, dt, shape); + } + } + // Remap / MLX-dequant path (name skew or 4-bit checkpoint). + if let Some(r) = self.resolve_f32(name) { + return r; + } + let (b, dt, shape) = self.tensor(name)?; + Self::decode_f32(b, dt, shape) + } + + /// Raw bytes + dtype + shape of a tensor, or an error if absent. + pub fn tensor(&self, name: &str) -> Result<(&[u8], DType, &[usize])> { + let info = self + .index + .get(name) + .ok_or_else(|| Error::Msg(format!("safetensors: tensor '{name}' not found")))?; + let sh = &self.shards[info.shard]; + let s = sh.data_start + info.begin; + let e = sh.data_start + info.end; + Ok((&sh.map[s..e], info.dtype, &info.shape)) + } +} +mod iq2xxs_tables; +pub mod gguf; + +// ── Capability probe ──────────────────────────────────────────────────── +// +// Derive what a checkpoint can ACTUALLY do from the tensors present, not from +// the model card / config (which lie — quants silently strip vision/audio +// towers, repos reuse llama/qwen backbones + bolt on towers, names are a mess). +// Cross-checks the config's *claims* against the tensors and flags the gap. + +/// What a checkpoint can do (each flag = the relevant tensors are present). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Caps { + pub text: bool, + pub vision: bool, + pub audio: bool, +} + +/// Grounded inspection of a model directory. +#[derive(Debug, Clone)] +pub struct Probe { + /// `model_type` from config.json (the card's claim). + pub config_model_type: Option, + pub config_architectures: Vec, + /// Capabilities **detected from the tensors** — the truth. + pub detected: Caps, + /// Capabilities the config **claims** (vision_config / audio_config keys). + pub declared: Caps, + pub n_tensors: usize, + pub n_params: u64, + /// Mismatches between claim and reality — e.g. a quant that stripped vision. + pub warnings: Vec, +} + +fn any_name(names: &[String], pats: &[&str]) -> bool { + names.iter().any(|n| pats.iter().any(|p| n.contains(p))) +} + +/// Probe a model dir: read config.json (if any) + scan the SafeTensors tensor +/// names, and report what it can really do — independent of the card. +pub fn probe(dir: &str) -> Result { + let st = SafeTensors::open_dir(dir)?; + let names: Vec = st.names().cloned().collect(); + let n_params: u64 = names.iter().filter_map(|n| st.info(n)).map(|i| i.shape.iter().product::() as u64).sum(); + + // detected from tensors (the truth) + let detected = Caps { + text: any_name(&names, &["embed_tokens", "wte", "embed_in", "token_embedding"]), + vision: any_name(&names, &["vision_model", "vision_tower", "visual.", "patch_embedding", "patch_embed", "vision_encoder"]), + audio: any_name(&names, &["audio_tower", "audio_encoder", "encoder.conv1", "feature_extractor", "audio_model"]), + }; + + // config.json claims (the card) + let mut config_model_type = None; + let mut config_architectures = Vec::new(); + let mut declared = Caps::default(); + if let Ok(txt) = std::fs::read_to_string(format!("{dir}/config.json")) { + if let Ok(j) = serde_json::from_str::(&txt) { + config_model_type = j["model_type"].as_str().map(String::from); + if let Some(a) = j["architectures"].as_array() { + config_architectures = a.iter().filter_map(|x| x.as_str().map(String::from)).collect(); + } + declared.vision = !j["vision_config"].is_null(); + declared.audio = !j["audio_config"].is_null() || !j["audio_encoder_config"].is_null(); + declared.text = !j["text_config"].is_null() || config_model_type.is_some(); + } + } + + // claim-vs-reality gaps (Eric's "the quant stripped vision but the card didn't say so") + let mut warnings = Vec::new(); + if declared.vision && !detected.vision { + warnings.push("config declares a vision tower but NO vision tensors are present — this checkpoint/quant stripped it (text-only despite the card)".into()); + } + if declared.audio && !detected.audio { + warnings.push("config declares audio but NO audio tensors are present — stripped from this checkpoint".into()); + } + if detected.vision && !declared.vision { + warnings.push("vision tensors present but config has no vision_config — a tower was bolted on / the card under-reports".into()); + } + + Ok(Probe { config_model_type, config_architectures, detected, declared, n_tensors: names.len(), n_params, warnings }) +} diff --git a/rust/crates/ffai-loader/tests/gguf_test.rs b/rust/crates/ffai-loader/tests/gguf_test.rs new file mode 100644 index 00000000..638bf534 --- /dev/null +++ b/rust/crates/ffai-loader/tests/gguf_test.rs @@ -0,0 +1,396 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! GGUF v3 parser + Q8_0 dequant vs gguf-py reference (DSv4-Flash checkpoint). +use ffai_loader::gguf::Gguf; + +#[test] +fn gguf_parse_and_q8_0_dequant_match_ggufpy() { + let path = std::env::var("GGUF_PATH").unwrap_or_else(|_| { + "/Users/tom/models/ds4-model/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf".to_string() + }); + let Ok(g) = Gguf::open(&path) else { + eprintln!("no GGUF at {path} — skipping"); + return; + }; + let bc = g.metadata_u32.get("deepseek4.block_count").copied(); + eprintln!("deepseek4.block_count = {bc:?}, tensors = {}", g.tensor_names().count()); + assert_eq!(bc, Some(43), "block_count metadata"); + + let d = g.dequant_f32("blk.0.attn_kv.weight").expect("dequant attn_kv"); + let want = [-0.002768f32, 0.039214, 0.010611, 0.004613, -0.000923, 0.05859, 0.014763, 0.042905]; + eprintln!("rust first8 = {:?}", &d[..8]); + let mut e = 0.0f32; + for i in 0..8 { + e = e.max((d[i] - want[i]).abs()); + } + assert!(e <= 1e-4, "Q8_0 dequant mismatch vs gguf-py: max|Δ|={e:.3e}"); + eprintln!("✅ GGUF v3 parse + Q8_0 dequant match gguf-py (max|Δ|={e:.1e})"); + + // Q2_K dequant vs gguf-py. + let q2 = g.dequant_f32("blk.0.ffn_down_exps.weight").expect("dequant down_exps"); + let want2 = [-0.017595f32, 0.015516, 0.032072, -0.017595, 0.015516, -0.017595, 0.015516, -0.00104]; + eprintln!("rust Q2_K first8 = {:?}", &q2[..8]); + let mut e2 = 0.0f32; + for i in 0..8 { + e2 = e2.max((q2[i] - want2[i]).abs()); + } + assert!(e2 <= 1e-4, "Q2_K dequant mismatch vs gguf-py: max|Δ|={e2:.3e}"); + eprintln!("✅ GGUF Q2_K dequant matches gguf-py (max|Δ|={e2:.1e})"); + + // IQ2_XXS dequant vs gguf-py. + let iq = g.dequant_f32("blk.0.ffn_gate_exps.weight").expect("dequant gate_exps"); + let want3 = [-0.006656f32, -0.006656, 0.006656, 0.020801, 0.006656, 0.006656, 0.006656, 0.020801]; + eprintln!("rust IQ2_XXS first8 = {:?}", &iq[..8]); + let mut e3 = 0.0f32; + for i in 0..8 { + e3 = e3.max((iq[i] - want3[i]).abs()); + } + assert!(e3 <= 1e-4, "IQ2_XXS dequant mismatch vs gguf-py: max|Δ|={e3:.3e}"); + eprintln!("✅ GGUF IQ2_XXS dequant matches gguf-py (max|Δ|={e3:.1e}) — all DSv4 quant types covered"); +} + +// ── Synthetic round-trip unit tests for the k-quants (no model file needed) ── +// +// We construct a one-block GGUF in memory whose data section holds a single +// Q4_K/Q5_K/Q6_K super-block built with the canonical GGML packing, then assert +// `dequant_f32` reproduces the values implied by that packing. This pins the +// bit layout (sub-block scale unpack, nibble order, high-bit planes) without an +// external reference. + +/// f32 → IEEE-754 half (round-to-nearest-even), enough for test scales. +fn f32_to_f16(x: f32) -> u16 { + let bits = x.to_bits(); + let sign = ((bits >> 16) & 0x8000) as u16; + let exp = ((bits >> 23) & 0xff) as i32 - 127 + 15; + let mant = bits & 0x7f_ffff; + if exp <= 0 { + return sign; // flush tiny to signed zero (test values avoid this) + } + if exp >= 0x1f { + return sign | 0x7c00; + } + sign | ((exp as u16) << 10) | ((mant >> 13) as u16) +} + +/// Build a minimal GGUF v3 byte image with a single named tensor whose data is +/// `data` and type id `ggml_type`, returning the bytes to write to a temp file. +fn build_gguf(name: &str, dims: &[u64], ggml_type: u32, data: &[u8]) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(&0x4655_4747u32.to_le_bytes()); // magic "GGUF" + b.extend_from_slice(&3u32.to_le_bytes()); // version + b.extend_from_slice(&1u64.to_le_bytes()); // n_tensors + b.extend_from_slice(&0u64.to_le_bytes()); // n_kv + // tensor info + b.extend_from_slice(&(name.len() as u64).to_le_bytes()); + b.extend_from_slice(name.as_bytes()); + b.extend_from_slice(&(dims.len() as u32).to_le_bytes()); + for d in dims { + b.extend_from_slice(&d.to_le_bytes()); + } + b.extend_from_slice(&ggml_type.to_le_bytes()); + b.extend_from_slice(&0u64.to_le_bytes()); // offset within data section + // align data section to 32 + while b.len() % 32 != 0 { + b.push(0); + } + b.extend_from_slice(data); + b +} + +fn write_temp(bytes: &[u8], stem: &str) -> String { + let path = std::env::temp_dir().join(format!("ffai_kquant_{stem}.gguf")); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() +} + +#[test] +fn q4_k_dequant_synthetic_roundtrip() { + // One QK_K=256 super-block. d/dmin and 8 (scale,min) 6-bit pairs chosen + // small/representable; 4-bit codes = (index % 16). + let d = 0.5f32; + let dmin = 0.125f32; + let sc: [u8; 8] = [3, 7, 12, 1, 20, 33, 5, 9]; + let mn: [u8; 8] = [2, 5, 8, 0, 14, 21, 4, 6]; + + // Pack the 12-byte scales array per GGML get_scale_min_k4 inverse. + let mut scales = [0u8; 12]; + for j in 0..4 { + scales[j] = sc[j] & 63; + scales[j + 4] = mn[j] & 63; + } + for j in 4..8 { + scales[j + 4] = (sc[j] & 0xF) | ((mn[j] & 0xF) << 4); + scales[j - 4] |= (sc[j] >> 4) << 6; + scales[j] |= (mn[j] >> 4) << 6; + } + + // 4-bit codes: element i in sub-block s has code (i % 16). qs holds 128 + // bytes: chunk j (32 bytes) packs sub-block 2j in low nibbles, 2j+1 in high. + let code = |elem: usize| (elem % 16) as u8; + let mut qs = [0u8; 128]; + for j in 0..4 { + for l in 0..32 { + let lo = code(2 * j * 32 + l); + let hi = code((2 * j + 1) * 32 + l); + qs[j * 32 + l] = (lo & 0xF) | (hi << 4); + } + } + + let mut data = Vec::new(); + data.extend_from_slice(&f32_to_f16(d).to_le_bytes()); + data.extend_from_slice(&f32_to_f16(dmin).to_le_bytes()); + data.extend_from_slice(&scales); + data.extend_from_slice(&qs); + assert_eq!(data.len(), 144); + + let bytes = build_gguf("w", &[256], 12, &data); + let path = write_temp(&bytes, "q4k"); + let g = Gguf::open(&path).unwrap(); + let out = g.dequant_f32("w").unwrap(); + std::fs::remove_file(&path).ok(); + + let df = f16_to_f32_t(f32_to_f16(d)); + let dminf = f16_to_f32_t(f32_to_f16(dmin)); + let mut maxe = 0.0f32; + for sb in 0..8 { + for l in 0..32 { + let elem = sb * 32 + l; + let want = df * sc[sb] as f32 * code(elem) as f32 - dminf * mn[sb] as f32; + maxe = maxe.max((out[elem] - want).abs()); + } + } + assert!(maxe < 1e-3, "Q4_K synthetic mismatch max|Δ|={maxe:.3e}"); + eprintln!("✅ Q4_K synthetic round-trip ok (max|Δ|={maxe:.1e})"); +} + +#[test] +fn q5_k_dequant_synthetic_roundtrip() { + let d = 0.25f32; + let dmin = 0.0625f32; + let sc: [u8; 8] = [4, 9, 15, 2, 30, 40, 7, 11]; + let mn: [u8; 8] = [1, 6, 10, 3, 18, 25, 5, 8]; + + let mut scales = [0u8; 12]; + for j in 0..4 { + scales[j] = sc[j] & 63; + scales[j + 4] = mn[j] & 63; + } + for j in 4..8 { + scales[j + 4] = (sc[j] & 0xF) | ((mn[j] & 0xF) << 4); + scales[j - 4] |= (sc[j] >> 4) << 6; + scales[j] |= (mn[j] >> 4) << 6; + } + + // 5-bit codes: low 4 bits = (elem % 16), high bit alternates by elem parity. + let lo_code = |elem: usize| (elem % 16) as u8; + let hi_bit = |elem: usize| (elem % 3 == 0) as u8; // 0 or 1 + + let mut qs = [0u8; 128]; + let mut qh = [0u8; 32]; + for j in 0..4 { + for l in 0..32 { + let e_lo = 2 * j * 32 + l; + let e_hi = (2 * j + 1) * 32 + l; + qs[j * 32 + l] = (lo_code(e_lo) & 0xF) | (lo_code(e_hi) << 4); + // high-bit plane: bit (2j) for low sub-block lane l, bit (2j+1) for high. + if hi_bit(e_lo) != 0 { + qh[l] |= 1 << (2 * j); + } + if hi_bit(e_hi) != 0 { + qh[l] |= 1 << (2 * j + 1); + } + } + } + + let mut data = Vec::new(); + data.extend_from_slice(&f32_to_f16(d).to_le_bytes()); + data.extend_from_slice(&f32_to_f16(dmin).to_le_bytes()); + data.extend_from_slice(&scales); + data.extend_from_slice(&qh); + data.extend_from_slice(&qs); + assert_eq!(data.len(), 176); + + let bytes = build_gguf("w", &[256], 13, &data); + let path = write_temp(&bytes, "q5k"); + let g = Gguf::open(&path).unwrap(); + let out = g.dequant_f32("w").unwrap(); + std::fs::remove_file(&path).ok(); + + let df = f16_to_f32_t(f32_to_f16(d)); + let dminf = f16_to_f32_t(f32_to_f16(dmin)); + let mut maxe = 0.0f32; + for sb in 0..8 { + for l in 0..32 { + let elem = sb * 32 + l; + let q = lo_code(elem) as i32 + 16 * hi_bit(elem) as i32; + let want = df * sc[sb] as f32 * q as f32 - dminf * mn[sb] as f32; + maxe = maxe.max((out[elem] - want).abs()); + } + } + assert!(maxe < 1e-3, "Q5_K synthetic mismatch max|Δ|={maxe:.3e}"); + eprintln!("✅ Q5_K synthetic round-trip ok (max|Δ|={maxe:.1e})"); +} + +#[test] +fn q6_k_dequant_synthetic_roundtrip() { + // 16 int8 scales, d(f16). 6-bit codes 0..63 stored as ql(low4)+qh(high2). + let d = 0.03125f32; + let scales: [i8; 16] = [1, -2, 3, -4, 5, -6, 7, -8, 2, -3, 4, -5, 6, -7, 8, -1]; + + // We choose, for output element index `e`, a 6-bit code = (e % 64). + // Reconstruct the on-disk ql/qh from the canonical interleave the dequant + // uses (two 128-halves; 4 groups per lane). + let mut ql = [0u8; 128]; + let mut qh = [0u8; 64]; + // Mirror the reader's index math so the round-trip is exact. + for n2 in 0..2 { + for l in 0..32 { + // group base output indices in this half + let e1 = n2 * 128 + l; + let e2 = n2 * 128 + 32 + l; + let e3 = n2 * 128 + 64 + l; + let e4 = n2 * 128 + 96 + l; + let c1 = (e1 % 64) as u8; // 0..63 + let c2 = (e2 % 64) as u8; + let c3 = (e3 % 64) as u8; + let c4 = (e4 % 64) as u8; + // ql_h[l] low nibble = c1&0xF ; high nibble = c3&0xF + // ql_h[l+32] low nibble = c2&0xF; high nibble = c4&0xF + ql[n2 * 64 + l] = (c1 & 0xF) | ((c3 & 0xF) << 4); + ql[n2 * 64 + l + 32] = (c2 & 0xF) | ((c4 & 0xF) << 4); + // qh_h[l]: bits0-1=c1>>4, bits2-3=c2>>4, bits4-5=c3>>4, bits6-7=c4>>4 + qh[n2 * 32 + l] = ((c1 >> 4) & 3) + | (((c2 >> 4) & 3) << 2) + | (((c3 >> 4) & 3) << 4) + | (((c4 >> 4) & 3) << 6); + } + } + + let mut data = Vec::new(); + data.extend_from_slice(&ql); + data.extend_from_slice(&qh); + for s in scales { + data.push(s as u8); + } + data.extend_from_slice(&f32_to_f16(d).to_le_bytes()); + assert_eq!(data.len(), 210); + + let bytes = build_gguf("w", &[256], 14, &data); + let path = write_temp(&bytes, "q6k"); + let g = Gguf::open(&path).unwrap(); + let out = g.dequant_f32("w").unwrap(); + std::fs::remove_file(&path).ok(); + + let df = f16_to_f32_t(f32_to_f16(d)); + // scale index `is` = l/16, offset by group within the half; matches reader. + let mut maxe = 0.0f32; + for n2 in 0..2 { + for l in 0..32 { + let is = l / 16; + let groups = [ + (n2 * 128 + l, scales[n2 * 8 + is]), + (n2 * 128 + 32 + l, scales[n2 * 8 + is + 2]), + (n2 * 128 + 64 + l, scales[n2 * 8 + is + 4]), + (n2 * 128 + 96 + l, scales[n2 * 8 + is + 6]), + ]; + for (e, sca) in groups { + let code = (e % 64) as i32 - 32; + let want = df * sca as f32 * code as f32; + maxe = maxe.max((out[e] - want).abs()); + } + } + } + assert!(maxe < 1e-3, "Q6_K synthetic mismatch max|Δ|={maxe:.3e}"); + eprintln!("✅ Q6_K synthetic round-trip ok (max|Δ|={maxe:.1e})"); +} + +#[test] +fn split_gguf_reads_tensors_across_parts() { + // Build a 2-part split GGUF (`*-00001-of-00002.gguf` / `*-00002-...`) where + // part 1 holds tensor "a" and part 2 holds tensor "b", then confirm a single + // `Gguf::open` on part 1 exposes BOTH tensors with correct data. + let a: Vec = (0..4u32).flat_map(|i| (i as f32).to_le_bytes()).collect(); + let b: Vec = (0..4u32).flat_map(|i| ((i + 10) as f32).to_le_bytes()).collect(); + let p1 = build_gguf("a", &[4], 0, &a); // F32 + let p2 = build_gguf("b", &[4], 0, &b); + + let dir = std::env::temp_dir(); + let path1 = dir.join("ffai_split_test-00001-of-00002.gguf"); + let path2 = dir.join("ffai_split_test-00002-of-00002.gguf"); + std::fs::write(&path1, &p1).unwrap(); + std::fs::write(&path2, &p2).unwrap(); + + let g = Gguf::open(&path1.to_string_lossy()).unwrap(); + let da = g.dequant_f32("a").unwrap(); + let db = g.dequant_f32("b").unwrap(); + std::fs::remove_file(&path1).ok(); + std::fs::remove_file(&path2).ok(); + + assert_eq!(da, vec![0.0, 1.0, 2.0, 3.0], "part-1 tensor"); + assert_eq!(db, vec![10.0, 11.0, 12.0, 13.0], "part-2 tensor"); + eprintln!("✅ split GGUF: opened part 1, read tensors from BOTH parts"); +} + +/// Local copy of the half→f32 conversion (the loader's is private). +fn f16_to_f32_t(bits: u16) -> f32 { + let sign = ((bits >> 15) & 1) as u32; + let exp = ((bits >> 10) & 0x1f) as u32; + let mant = (bits & 0x3ff) as u32; + let out = if exp == 0 { + if mant == 0 { + sign << 31 + } else { + let mut e = -1i32; + let mut m = mant; + while m & 0x400 == 0 { + m <<= 1; + e -= 1; + } + (sign << 31) | (((e + 127 - 15) as u32) << 23) | ((m & 0x3ff) << 13) + } + } else if exp == 0x1f { + (sign << 31) | (0xff << 23) | (mant << 13) + } else { + (sign << 31) | ((exp + 127 - 15) << 23) | (mant << 13) + }; + f32::from_bits(out) +} + + +/// Q4_K + Q6_K dequant vs gguf-py reference, on a real Qwen2.5-7B-Q4_K_M GGUF +/// (the common k-quant). Reference first-16 values were dumped with +/// `gguf.quants.dequantize`. Also exercises the split-GGUF open path (the model +/// ships as `*-00001-of-00002.gguf`). Skips if the model isn't present. +#[test] +fn q4k_q6k_dequant_match_ggufpy_qwen7b() { + let path = std::env::var("QWEN25_7B_GGUF").unwrap_or_else(|_| { + "/Users/tom/models/qwen2.5-7b-instruct-q4_k_m-00001-of-00002.gguf".to_string() + }); + if !std::path::Path::new(&path).exists() { + eprintln!("model not found at {path} — skipping"); + return; + } + let g = Gguf::open(&path).expect("open split gguf"); + + // gguf-py reference (gguf 0.18.0, dequantize()). + let q4k_ref: [f32; 16] = [ + -0.000382, -0.004206, 0.011090, 0.001530, -0.002294, -0.006118, 0.003442, -0.008030, + 0.001530, -0.002294, -0.008030, -0.004206, -0.008030, 0.013002, -0.002294, 0.001530, + ]; + let q6k_ref: [f32; 16] = [ + 0.000422, 0.008435, 0.003796, -0.004218, 0.000422, 0.013496, 0.001265, -0.004218, + -0.000422, -0.009279, 0.003796, 0.004639, 0.009279, 0.002109, -0.003796, 0.000422, + ]; + + let q4 = g.dequant_f32("blk.0.attn_q.weight").expect("dequant Q4_K"); + let q6 = g.dequant_f32("blk.0.ffn_down.weight").expect("dequant Q6_K"); + + let max_dev = |out: &[f32], want: &[f32; 16]| { + (0..16).fold(0.0f32, |a, i| a.max((out[i] - want[i]).abs())) + }; + let e4 = max_dev(&q4, &q4k_ref); + let e6 = max_dev(&q6, &q6k_ref); + assert!(e4 < 1e-5, "Q4_K vs gguf-py mismatch: max|Δ|={e4:.3e}"); + assert!(e6 < 1e-5, "Q6_K vs gguf-py mismatch: max|Δ|={e6:.3e}"); + eprintln!("✅ Q4_K (max|Δ|={e4:.1e}) + Q6_K (max|Δ|={e6:.1e}) dequant match gguf-py on real Qwen2.5-7B-Q4_K_M split GGUF"); +} diff --git a/rust/crates/ffai-loader/tests/mlx_dequant_test.rs b/rust/crates/ffai-loader/tests/mlx_dequant_test.rs new file mode 100644 index 00000000..7321d7c2 --- /dev/null +++ b/rust/crates/ffai-loader/tests/mlx_dequant_test.rs @@ -0,0 +1,46 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Validate the MLX 4-bit affine dequant + name-remap path against MLX ground +//! truth captured from `mx.dequantize(...)` on the Nemotron-Cascade checkpoint. +//! Needs the local MLX dir (skips if absent). +use ffai_loader::SafeTensors; + +const DIR: &str = "/Users/tom/models/Nemotron-Cascade-2-30B-A3B-4bit"; + +#[test] +fn mlx_in_proj_row0_matches_mlx_groundtruth() { + let Ok(st) = SafeTensors::open_dir(DIR) else { eprintln!("no MLX dir — skip"); return; }; + // backbone.layers.0.mixer.in_proj.weight → [10304, 2688] dense f32. + let (w, sh) = st.tensor_f32("backbone.layers.0.mixer.in_proj.weight").unwrap(); + assert_eq!(sh, vec![10304, 2688], "dequant shape"); + // MLX ground truth: mx.dequantize(W,S,B,gs=64,bits=4,affine) row0[:8]. + let gt = [ + -0.0361328125f32, 0.030029296875, 0.0419921875, 0.01806640625, + 0.01806640625, -0.048095703125, 0.0240478515625, 0.0361328125, + ]; + for (i, &g) in gt.iter().enumerate() { + // Our scales/biases are bf16-decoded → tiny diffs vs MLX's bf16 path; 1e-3 is plenty. + assert!((w[i] - g).abs() < 1e-3, "col {i}: got {} want {g}", w[i]); + } +} + +#[test] +fn mlx_prefix_strip_and_expert_slice() { + let Ok(st) = SafeTensors::open_dir(DIR) else { eprintln!("no MLX dir — skip"); return; }; + // language_model. prefix strip + MLX dequant. + let (_w, sh) = st.tensor_f32("language_model.backbone.layers.0.mixer.in_proj.weight").unwrap(); + assert_eq!(sh, vec![10304, 2688]); + // Per-expert remap: layer 8 is an E-layer. experts.{e}.up_proj → switch_mlp.fc1[e]. + let (up0, ush) = st.tensor_f32("language_model.backbone.layers.8.mixer.experts.0.up_proj.weight").unwrap(); + assert_eq!(ush, vec![1856, 2688], "expert up slab shape [inter, hid]"); + let (dn0, dsh) = st.tensor_f32("language_model.backbone.layers.8.mixer.experts.0.down_proj.weight").unwrap(); + assert_eq!(dsh, vec![2688, 1856], "expert down slab shape [hid, inter]"); + // Expert 0 slab must equal the first slab of the full packed dequant. + let (full_up, fsh) = st.tensor_f32("backbone.layers.8.mixer.switch_mlp.fc1.weight").unwrap(); + assert_eq!(fsh[0], 128 * 1856); + let n = 1856 * 2688; + for i in (0..n).step_by(9973) { + assert!((up0[i] - full_up[i]).abs() < 1e-6, "expert0 up mismatch at {i}"); + } + let _ = dn0; +} diff --git a/rust/crates/ffai-loader/tests/probe_test.rs b/rust/crates/ffai-loader/tests/probe_test.rs new file mode 100644 index 00000000..88b20495 --- /dev/null +++ b/rust/crates/ffai-loader/tests/probe_test.rs @@ -0,0 +1,37 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Capability probe demo — what a checkpoint can ACTUALLY do, from its tensors. +use ffai_loader::probe; + +fn snap(repo: &str) -> Option { + let base = format!("{}/.cache/huggingface/hub/models--{repo}/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} + +#[test] +fn probe_real_models() { + let models = [ + ("gpt2", "gpt2"), + ("HuggingFaceTB--SmolVLM-256M-Instruct", "SmolVLM"), + ("openai--whisper-base", "Whisper"), + ("unsloth--gemma-2-2b-it", "Gemma-2"), + ("microsoft--phi-1_5", "Phi-1.5"), + ]; + println!("\n{:<12} {:>6} {:>10} detected(text/vis/aud) declared notes", "model", "tens", "params"); + println!("{}", "-".repeat(96)); + for (repo, label) in models { + let Some(dir) = snap(repo) else { println!("{label:<12} (not cached)"); continue }; + match probe(&dir) { + Ok(p) => { + let det = format!("{}/{}/{}", b(p.detected.text), b(p.detected.vision), b(p.detected.audio)); + let dec = p.config_model_type.clone().unwrap_or_else(|| "?".into()); + println!("{label:<12} {:>6} {:>9.0}M {det:<22} {dec:<16} {}", + p.n_tensors, p.n_params as f64 / 1e6, p.warnings.first().cloned().unwrap_or_default()); + } + Err(e) => println!("{label:<12} probe error: {e}"), + } + } + println!(); +} + +fn b(v: bool) -> &'static str { if v { "✓" } else { "·" } } diff --git a/rust/crates/ffai-models/Cargo.toml b/rust/crates/ffai-models/Cargo.toml new file mode 100644 index 00000000..ef80d790 --- /dev/null +++ b/rust/crates/ffai-models/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ffai-models" +description = "FFAI model definitions (forward passes) as backend-neutral Rust. Ported from FFAI-Swift Models/, validated against it as the oracle." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +ffai-core.workspace = true +ffai-ops.workspace = true +ffai-loader.workspace = true +serde_json.workspace = true diff --git a/rust/crates/ffai-models/src/dsv4.rs b/rust/crates/ffai-models/src/dsv4.rs new file mode 100644 index 00000000..2514c083 --- /dev/null +++ b/rust/crates/ffai-models/src/dsv4.rs @@ -0,0 +1,221 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! DeepSeek-V4 Multi-head Latent Attention (decode), assembled from the +//! verified DSv4 ops in [`ffai_ops`]. Single-token, single-position KV +//! (`n_kv = 1`). Covers the full-attention layers; the CSA/HCA sparse path +//! (compressor + Lightning indexer) is a separate track (WIP in the +//! reference too). The mHC residual wrapping is the model loop's job. + +use ffai_core::{DType, Device, Result, Tensor}; +use ffai_ops as ops; + +#[derive(Debug, Clone, Copy)] +pub struct MlaConfig { + pub hidden: usize, + pub n_heads: usize, + pub head_dim: usize, // 512 (the d512 sink-SDPA kernel) + pub q_lora_rank: usize, + pub n_nope: usize, // 448 + pub half_rot: usize, // 32 (rope tail = 64) + pub o_lora_rank: usize, + pub o_groups: usize, + pub rope_theta: f32, + pub eps: f32, +} + +/// MLA attention weights (one full-attention layer). +pub struct MlaWeights { + pub attn_norm: Tensor, // [hidden] + pub q_a: Tensor, // [q_lora_rank, hidden] + pub q_a_norm: Tensor, // [q_lora_rank] + pub q_b: Tensor, // [n_heads*head_dim, q_lora_rank] + pub kv: Tensor, // [head_dim, hidden] + pub kv_a_norm: Tensor, // [head_dim] + pub sink: Tensor, // [n_heads] f32 + pub output_a: Vec, // o_groups × [o_lora_rank, gsize] + pub output_b: Tensor, // [hidden, o_groups*o_lora_rank] +} + +fn fb(b: &[u8]) -> Vec { + b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() +} +fn tb(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// Run MLA attention for a single token: `x [hidden]` → `blockOut [hidden]`. +pub fn mla_attention( + dev: &dyn Device, + cfg: &MlaConfig, + w: &MlaWeights, + x: &Tensor, + position: u32, +) -> Result { + let hd = cfg.head_dim; + let scale = 1.0 / (hd as f32).sqrt(); + + let xn = ops::rms_norm(dev, x, &w.attn_norm, cfg.eps)?; + + // Q low-rank path + per-head unit-RMS norm + partial RoPE. + let qa = ops::gemv(dev, &w.q_a, &xn)?; + let qan = ops::rms_norm(dev, &qa, &w.q_a_norm, cfg.eps)?; + let q = ops::gemv(dev, &w.q_b, &qan)?; // [n_heads*head_dim] + let ones = Tensor::new(dev.upload(&tb(&vec![1.0f32; hd]))?, vec![hd], DType::F32); + let q = ops::rms_norm(dev, &q.reshaped(vec![cfg.n_heads, hd]), &ones, cfg.eps)?; + let q = ops::dsv4_partial_rope( + dev, &q, cfg.n_heads as u32, hd as u32, cfg.n_nope as u32, cfg.half_rot as u32, + position, cfg.rope_theta, false, + )?; + + // KV latent path + norm + partial RoPE (single kv head). + let kv = ops::gemv(dev, &w.kv, &xn)?; // [head_dim] + let kvn = ops::rms_norm(dev, &kv, &w.kv_a_norm, cfg.eps)?; + let kvn = ops::dsv4_partial_rope( + dev, &kvn.reshaped(vec![1, hd]), 1, hd as u32, cfg.n_nope as u32, cfg.half_rot as u32, + position, cfg.rope_theta, false, + )?; + + // MQA sink-SDPA over the single-position cache (n_kv=1), then inverse RoPE. + let attn = + ops::sdpa_decode_sink(dev, &q, &kvn, &kvn, &w.sink, 1, 1, cfg.n_heads as u32, scale)?; + let attn = ops::dsv4_partial_rope( + dev, &attn, cfg.n_heads as u32, hd as u32, cfg.n_nope as u32, cfg.half_rot as u32, + position, cfg.rope_theta, true, + )?; + + // Grouped O-LoRA: attn [n_heads*head_dim] → o_groups groups; per-group + // low-rank, concat, then the dense up-projection. The group slicing is + // host-side (single token) since gemv consumes whole buffers. + let qd = cfg.n_heads * hd; + let gsize = qd / cfg.o_groups; + let mut ab = vec![0u8; qd * 4]; + dev.synchronize()?; + dev.download(attn.buffer.as_ref(), &mut ab)?; + let attn_h = fb(&ab); + + let mut o_low = vec![0.0f32; cfg.o_groups * cfg.o_lora_rank]; + for g in 0..cfg.o_groups { + let slice = &attn_h[g * gsize..(g + 1) * gsize]; + let tslice = Tensor::new(dev.upload(&tb(slice))?, vec![gsize], DType::F32); + let og = ops::gemv(dev, &w.output_a[g], &tslice)?; // [o_lora_rank] + dev.synchronize()?; + let mut ogb = vec![0u8; cfg.o_lora_rank * 4]; + dev.download(og.buffer.as_ref(), &mut ogb)?; + o_low[g * cfg.o_lora_rank..(g + 1) * cfg.o_lora_rank].copy_from_slice(&fb(&ogb)); + } + let t_olow = Tensor::new(dev.upload(&tb(&o_low))?, vec![o_low.len()], DType::F32); + ops::gemv(dev, &w.output_b, &t_olow) +} + +// ── DeepSeek-V4 MoE feed-forward ──────────────────────────────────────── + +/// One expert's SwiGLU MLP (gate/up/down). +pub struct Dsv4Expert { + pub gate: Tensor, // [intermediate, hidden] + pub up: Tensor, // [intermediate, hidden] + pub down: Tensor, // [hidden, intermediate] +} + +/// DSv4 routed MoE: sqrt(softplus) router (+bias) → top-k clamped-SwiGLU +/// experts (weighted by normalized unbiased score × routed_scaling) + an +/// always-on shared expert. +pub struct Dsv4Moe { + pub router: Tensor, // [n_experts, hidden] (f32 here) + pub bias: Vec, // [n_experts] + pub experts: Vec, + pub shared: Dsv4Expert, + pub top_k: usize, + pub routed_scaling: f32, // 1.5 for DSv4 + pub swiglu_limit: f32, // 10.0 for DSv4 +} + +/// Run the DSv4 MoE feed-forward for a single token `x [hidden]`. +pub fn dsv4_moe(dev: &dyn Device, w: &Dsv4Moe, x: &Tensor) -> Result { + let hidden = x.elem_count(); + + // Router logits → host → sqrt(softplus) scores → top-k by biased. + let logits_t = ops::gemv(dev, &w.router, x)?; + dev.synchronize()?; + let mut lb = vec![0u8; w.experts.len() * 4]; + dev.download(logits_t.buffer.as_ref(), &mut lb)?; + let logits = fb(&lb); + let (unbiased, biased) = ops::sqrtsoftplus_route(&logits, &w.bias); + + let mut order: Vec = (0..w.experts.len()).collect(); + order.sort_by(|&a, &b| biased[b].total_cmp(&biased[a])); + let top: Vec = order.into_iter().take(w.top_k).collect(); + let denom: f32 = top.iter().map(|&e| unbiased[e]).sum(); + let weights: Vec = + top.iter().map(|&e| unbiased[e] / denom * w.routed_scaling).collect(); + + let mut acc = vec![0.0f32; hidden]; + let run_expert = |dev: &dyn Device, ex: &Dsv4Expert| -> Result> { + let gate = ops::gemv(dev, &ex.gate, x)?; + let up = ops::gemv(dev, &ex.up, x)?; + let inner = ops::swiglu_limit(dev, &gate, &up, w.swiglu_limit)?; + let out = ops::gemv(dev, &ex.down, &inner)?; + dev.synchronize()?; + let mut ob = vec![0u8; hidden * 4]; + dev.download(out.buffer.as_ref(), &mut ob)?; + Ok(fb(&ob)) + }; + for (&e, &gw) in top.iter().zip(&weights) { + let out = run_expert(dev, &w.experts[e])?; + for i in 0..hidden { + acc[i] += gw * out[i]; + } + } + let shared = run_expert(dev, &w.shared)?; + for i in 0..hidden { + acc[i] += shared[i]; + } + + Ok(Tensor::new(dev.upload(&tb(&acc))?, vec![hidden], x.dtype)) +} + +// ── DeepSeek-V4 mHC layer wrapping ────────────────────────────────────── + +/// mHC (hyper-connection) weights for one subblock. +pub struct MhcWeights { + pub hc_fn: Tensor, // [24, n_hc*hidden] — the mix projection + pub hc_scale: [f32; 3], // pre / post / comb scales + pub hc_base: Vec, // [24] +} + +/// Full DSv4 attention subblock with the 4-channel mHC residual: +/// `mixes = hc_fn · flatten(state)` → sinkhorn split → collapse(state, pre) → +/// MLA(x) → expand(blockOut, post, comb, state) → new state `[n_hc, hidden]`. +/// `n_hc = 4`, `eps`/`iters` are the Sinkhorn params. +#[allow(clippy::too_many_arguments)] +pub fn dsv4_attn_subblock( + dev: &dyn Device, + cfg: &MlaConfig, + mhc: &MhcWeights, + mla: &MlaWeights, + hc_state: &Tensor, // [n_hc, hidden] + position: u32, + eps: f32, + iters: u32, +) -> Result { + const N_HC: usize = 4; + let hidden = cfg.hidden; + + // mHC mix → sinkhorn split (host). + let flat = hc_state.reshaped(vec![N_HC * hidden]); + let mixes_t = ops::gemv(dev, &mhc.hc_fn, &flat)?; // [24] + dev.synchronize()?; + let mut mb = vec![0u8; 24 * 4]; + dev.download(mixes_t.buffer.as_ref(), &mut mb)?; + let (pre, post, comb) = + ops::dsv4_mhc_sinkhorn_split(&fb(&mb), mhc.hc_scale, &mhc.hc_base, eps, iters); + + let pre_t = Tensor::new(dev.upload(&tb(&pre))?, vec![N_HC], DType::F32); + let post_t = Tensor::new(dev.upload(&tb(&post))?, vec![N_HC], DType::F32); + let comb_t = Tensor::new(dev.upload(&tb(&comb))?, vec![N_HC * N_HC], DType::F32); + + // collapse → MLA → expand. + let x = ops::dsv4_mhc_collapse(dev, hc_state, &pre_t, hidden as u32, N_HC as u32)?; + let block_out = mla_attention(dev, cfg, mla, &x, position)?; + ops::dsv4_mhc_expand(dev, &block_out, &post_t, &comb_t, hc_state, hidden as u32, N_HC as u32) +} diff --git a/rust/crates/ffai-models/src/gguf_tokenizer.rs b/rust/crates/ffai-models/src/gguf_tokenizer.rs new file mode 100644 index 00000000..c17a3735 --- /dev/null +++ b/rust/crates/ffai-models/src/gguf_tokenizer.rs @@ -0,0 +1,310 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal GPT-2/Qwen byte-level BPE tokenizer, read straight from GGUF +//! metadata (`tokenizer.ggml.tokens` + `.merges`). Pure CPU, no deps — the +//! same vocab the model was trained with, so encode/decode round-trips the +//! exact ids llama.cpp would produce for ASCII prompts. +//! +//! Byte-level BPE: input bytes are first mapped through the GPT-2 +//! "bytes_to_unicode" table into printable Unicode chars (so e.g. a space +//! becomes 'Ġ'), then merged greedily by merge rank. Tokens in the GGUF vocab +//! are stored in that same mapped-char space, so a token string is matched +//! directly against the merged symbols. + +use ffai_core::{Error, Result}; +use ffai_loader::gguf::Gguf; +use std::collections::HashMap; + +/// Which on-disk tokenizer the GGUF carries. GPT-2/Qwen ship byte-level BPE +/// (`tokenizer.ggml.merges`); Llama/Phi-3 ship SentencePiece ("llama" model, +/// `tokenizer.ggml.scores`, `▁`-space marker, no merges). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokKind { + Bpe, + Spm, +} + +pub struct GgufTokenizer { + kind: TokKind, + /// id → token string (BPE: GPT-2 mapped-char space; SPM: raw with `▁`). + tokens: Vec, + /// token string → id. + token_to_id: HashMap, + /// "a b" merge pair → rank (lower = applied first). BPE only. + merge_rank: HashMap<(String, String), usize>, + /// id → SentencePiece score (higher = preferred merge). SPM only. + scores: Vec, + /// byte (0..256) → mapped char, and the inverse. BPE only. + byte_to_char: [char; 256], + char_to_byte: HashMap, +} + +/// SentencePiece space marker (U+2581 "▁"). +const SPM_SPACE: char = '\u{2581}'; + +/// GPT-2 reversible byte→unicode table: map all 256 bytes to printable chars so +/// BPE operates on text without control/whitespace ambiguity. +fn bytes_to_unicode() -> [char; 256] { + let mut bs: Vec = Vec::new(); + bs.extend(b'!' as u32..=b'~' as u32); + bs.extend(0xA1u32..=0xAC); + bs.extend(0xAEu32..=0xFF); + let mut cs: Vec = bs.clone(); + let mut n = 0u32; + for b in 0u32..256 { + if !bs.contains(&b) { + bs.push(b); + cs.push(256 + n); + n += 1; + } + } + let mut table = ['\0'; 256]; + for (b, c) in bs.iter().zip(cs.iter()) { + table[*b as usize] = char::from_u32(*c).unwrap(); + } + table +} + +impl GgufTokenizer { + /// Build the tokenizer from a parsed GGUF's metadata arrays. + pub fn from_gguf(g: &Gguf) -> Result { + let tokens = g + .metadata_arr_str + .get("tokenizer.ggml.tokens") + .ok_or_else(|| Error::Msg("gguf: tokenizer.ggml.tokens missing".into()))? + .clone(); + + let mut token_to_id = HashMap::with_capacity(tokens.len()); + for (i, t) in tokens.iter().enumerate() { + token_to_id.insert(t.clone(), i as u32); + } + let byte_to_char = bytes_to_unicode(); + let mut char_to_byte = HashMap::with_capacity(256); + for (b, &c) in byte_to_char.iter().enumerate() { + char_to_byte.insert(c, b as u8); + } + + // Detect the tokenizer model: Llama/Phi-3 SentencePiece ("llama", scores, + // no merges) vs GPT-2/Qwen byte-level BPE (merges present). + let model = g.meta_str("tokenizer.ggml.model").unwrap_or("gpt2"); + let has_merges = g.metadata_arr_str.contains_key("tokenizer.ggml.merges"); + let kind = if model == "llama" || (!has_merges) { TokKind::Spm } else { TokKind::Bpe }; + + let (merge_rank, scores) = match kind { + TokKind::Bpe => { + let merges = g + .metadata_arr_str + .get("tokenizer.ggml.merges") + .ok_or_else(|| Error::Msg("gguf: tokenizer.ggml.merges missing".into()))?; + let mut mr = HashMap::with_capacity(merges.len()); + for (rank, m) in merges.iter().enumerate() { + if let Some((a, b)) = m.split_once(' ') { + mr.insert((a.to_string(), b.to_string()), rank); + } + } + (mr, Vec::new()) + } + TokKind::Spm => { + // SentencePiece merge scores live in `tokenizer.ggml.scores` + // (f32 array). The loader keeps int arrays; scores are f32, so + // they may be absent — fall back to rank-by-vocab-position + // (longer/earlier ids preferred) if so. + let sc = g + .metadata_arr_f32 + .get("tokenizer.ggml.scores") + .cloned() + .unwrap_or_default(); + (HashMap::new(), sc) + } + }; + + Ok(GgufTokenizer { + kind, + tokens, + token_to_id, + merge_rank, + scores, + byte_to_char, + char_to_byte, + }) + } + + pub fn vocab_size(&self) -> usize { + self.tokens.len() + } + + /// Look up a special-token id by its literal token string (e.g. + /// "<|im_start|>", "<|endoftext|>"). + pub fn token_id(&self, s: &str) -> Option { + self.token_to_id.get(s).copied() + } + + /// BPE-merge one whitespace-delimited "word" (already mapped to GPT-2 + /// char space) into the fewest tokens by merge rank. + fn bpe(&self, word: &str) -> Vec { + let mut symbols: Vec = word.chars().map(|c| c.to_string()).collect(); + if symbols.len() < 2 { + return symbols; + } + loop { + // Find the adjacent pair with the lowest merge rank. + let mut best: Option<(usize, usize)> = None; // (rank, index) + for i in 0..symbols.len() - 1 { + if let Some(&r) = self.merge_rank.get(&(symbols[i].clone(), symbols[i + 1].clone())) { + if best.map(|(br, _)| r < br).unwrap_or(true) { + best = Some((r, i)); + } + } + } + let Some((_, i)) = best else { break }; + let merged = format!("{}{}", symbols[i], symbols[i + 1]); + symbols.splice(i..i + 2, [merged]); + } + symbols + } + + /// Encode UTF-8 `text` into token ids via byte-level BPE. ASCII prompts + /// round-trip exactly to llama.cpp's ids; this does NOT apply the GPT-2 + /// pre-tokenizer regex (word/space splitting) — instead it splits on the + /// leading-space convention, which suffices for plain prompts. + pub fn encode(&self, text: &str) -> Vec { + if self.kind == TokKind::Spm { + return self.encode_spm(text); + } + let mapped: String = text.bytes().map(|b| self.byte_to_char[b as usize]).collect(); + // Split into "words" the way GPT-2 BPE groups them: a run that starts + // at the mapped-space char ('Ġ') and continues until the next one. + let space = self.byte_to_char[b' ' as usize]; + let mut words: Vec = Vec::new(); + let mut cur = String::new(); + for c in mapped.chars() { + if c == space && !cur.is_empty() { + words.push(std::mem::take(&mut cur)); + } + cur.push(c); + } + if !cur.is_empty() { + words.push(cur); + } + + let mut ids = Vec::new(); + for w in &words { + for sym in self.bpe(w) { + if let Some(&id) = self.token_to_id.get(&sym) { + ids.push(id); + } else { + // Fall back to per-char ids (every single mapped char is in + // the byte-level base vocab, so this always resolves). + for ch in sym.chars() { + let s = ch.to_string(); + if let Some(&id) = self.token_to_id.get(&s) { + ids.push(id); + } + } + } + } + } + ids + } + + /// SentencePiece (Llama/Phi-3) encode. Maps spaces to `▁`, prepends a + /// leading `▁` (the SPM "add dummy prefix" convention llama.cpp uses), seeds + /// one symbol per UTF-8 char, then greedily merges the adjacent pair whose + /// merged token has the highest SentencePiece score — the standard + /// `llm_tokenizer_spm` bigram merge. Any leftover symbol absent from the + /// vocab is emitted as its raw UTF-8 bytes via the `<0xXX>` byte tokens. + fn encode_spm(&self, text: &str) -> Vec { + // SPM works on the text with spaces replaced by ▁ and a leading ▁. + let mut norm = String::with_capacity(text.len() + 3); + norm.push(SPM_SPACE); + for c in text.chars() { + if c == ' ' { + norm.push(SPM_SPACE); + } else { + norm.push(c); + } + } + + // Seed: one symbol per char. + let mut symbols: Vec = norm.chars().map(|c| c.to_string()).collect(); + let score_of = |s: &str| -> Option { + self.token_to_id + .get(s) + .map(|&id| self.scores.get(id as usize).copied().unwrap_or(0.0)) + }; + loop { + // Best adjacent merge by SentencePiece score (must be in vocab). + let mut best: Option<(f32, usize)> = None; + for i in 0..symbols.len().saturating_sub(1) { + let cand = format!("{}{}", symbols[i], symbols[i + 1]); + if let Some(sc) = score_of(&cand) { + if best.map(|(bs, _)| sc > bs).unwrap_or(true) { + best = Some((sc, i)); + } + } + } + let Some((_, i)) = best else { break }; + let merged = format!("{}{}", symbols[i], symbols[i + 1]); + symbols.splice(i..i + 2, [merged]); + } + + // Resolve each final symbol to ids, falling back to <0xXX> byte tokens. + let mut ids = Vec::new(); + for sym in &symbols { + if let Some(&id) = self.token_to_id.get(sym) { + ids.push(id); + } else { + for b in sym.bytes() { + let byte_tok = format!("<0x{b:02X}>"); + if let Some(&id) = self.token_to_id.get(&byte_tok) { + ids.push(id); + } + } + } + } + ids + } + + /// Decode token ids back to a UTF-8 string (inverting the byte map). + pub fn decode(&self, ids: &[u32]) -> String { + if self.kind == TokKind::Spm { + return self.decode_spm(ids); + } + let mut bytes: Vec = Vec::new(); + for &id in ids { + if let Some(tok) = self.tokens.get(id as usize) { + for c in tok.chars() { + if let Some(&b) = self.char_to_byte.get(&c) { + bytes.push(b); + } + } + } + } + String::from_utf8_lossy(&bytes).into_owned() + } + + /// SentencePiece decode: concatenate token strings, turn `▁` back into a + /// space, and expand `<0xXX>` byte tokens to their raw byte. + fn decode_spm(&self, ids: &[u32]) -> String { + let mut bytes: Vec = Vec::new(); + for &id in ids { + let Some(tok) = self.tokens.get(id as usize) else { continue }; + if tok.len() == 6 && tok.starts_with("<0x") && tok.ends_with('>') { + if let Ok(b) = u8::from_str_radix(&tok[3..5], 16) { + bytes.push(b); + continue; + } + } + for c in tok.chars() { + if c == SPM_SPACE { + bytes.push(b' '); + } else { + let mut buf = [0u8; 4]; + bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + } + String::from_utf8_lossy(&bytes).into_owned() + } +} diff --git a/rust/crates/ffai-models/src/lib.rs b/rust/crates/ffai-models/src/lib.rs new file mode 100644 index 00000000..dec04bb0 --- /dev/null +++ b/rust/crates/ffai-models/src/lib.rs @@ -0,0 +1,26 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-models +//! +//! Model forward passes as plain Rust over [`ffai_ops`]. This is the big +//! surface ported from FFAI-Swift `Models/` (~35 families) — written ONCE +//! and run on every backend via the shared [`ffai_core::Device`] trait. The +//! 35 families collapse to a handful of *builders* parameterized by config; +//! this module starts with the transformer-LLM builder (Llama / Qwen / +//! Gemma / Mistral / Yi / Phi / SmolLM / …). + +use ffai_core::{Device, Result, Tensor}; + +/// A loaded model: weights resident on some [`Device`], able to run a +/// forward pass. Backend-neutral — the same impl runs on Metal/CUDA/Vulkan. +pub trait Model: Send + Sync { + fn name(&self) -> &str; + /// Run one forward pass over `tokens`, returning next-token logits. + fn forward(&self, dev: &dyn Device, tokens: &[u32]) -> Result; +} + +pub mod dsv4; +pub mod gguf_tokenizer; +pub mod llama; +pub mod moe; diff --git a/rust/crates/ffai-models/src/llama.rs b/rust/crates/ffai-models/src/llama.rs new file mode 100644 index 00000000..7613ebac --- /dev/null +++ b/rust/crates/ffai-models/src/llama.rs @@ -0,0 +1,1166 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! Transformer-LLM builder (Llama / Qwen / Mistral / Yi / Phi / SmolLM …). +//! One decode layer assembled entirely from [`ffai_ops`] — so it runs on any +//! backend that implements [`ffai_core::Device`]. Config-parameterized: the +//! same code serves every model in this family by varying [`LlamaConfig`]. + +use ffai_core::{Device, Error, Result, Tensor}; +use ffai_ops as ops; + +/// Architecture config shared by the transformer-LLM family. +#[derive(Debug, Clone, Copy)] +pub struct LlamaConfig { + pub hidden: usize, + pub n_q_heads: usize, + pub n_kv_heads: usize, + pub head_dim: usize, + pub intermediate: usize, + pub rope_theta: f32, + pub eps: f32, + /// Qwen3-style per-head RMSNorm on Q and K before RoPE. + pub qk_norm: bool, + /// QKV projection bias (Qwen2/Qwen2.5). + pub attn_bias: bool, +} + +impl LlamaConfig { + pub fn heads_per_group(&self) -> u32 { + (self.n_q_heads / self.n_kv_heads) as u32 + } + pub fn attn_scale(&self) -> f32 { + 1.0 / (self.head_dim as f32).sqrt() + } +} + +/// Per-layer weights (dense). Row-major: a projection `[out, in]` is applied +/// as `gemv(W, x)`. +pub struct LayerWeights { + pub attn_norm: Tensor, // [hidden] + pub wq: Tensor, // [n_q_heads*head_dim, hidden] + pub wk: Tensor, // [n_kv_heads*head_dim, hidden] + pub wv: Tensor, // [n_kv_heads*head_dim, hidden] + pub wo: Tensor, // [hidden, n_q_heads*head_dim] + /// QKV biases `[*_heads*head_dim]` (None unless attn_bias). + pub bias_q: Option, + pub bias_k: Option, + pub bias_v: Option, + /// Qwen3 per-head Q/K RMSNorm weights `[head_dim]` (None unless qk_norm). + pub q_norm: Option, + pub k_norm: Option, + pub mlp_norm: Tensor, // [hidden] + pub w_gate: Tensor, // [intermediate, hidden] + pub w_up: Tensor, // [intermediate, hidden] + pub w_down: Tensor, // [hidden, intermediate] +} + +/// One transformer decode step for a single token, attending only to itself +/// (single-position KV — `n_kv = 1`). This exercises the full layer pipeline +/// — RMSNorm → QKV proj → RoPE → SDPA → O proj → residual → RMSNorm → +/// SwiGLU MLP → residual — through the shared op layer. The multi-position +/// KV cache (write new k/v at `pos`, attend over `[0, pos]`) is the model +/// loop's responsibility; this is the per-layer compute. +/// +/// `x` is the `[hidden]` residual stream for the current token. +pub fn decode_layer_self( + dev: &dyn Device, + cfg: &LlamaConfig, + w: &LayerWeights, + x: &Tensor, + pos: u32, +) -> Result { + let hd = cfg.head_dim; + let theta = cfg.rope_theta; + + // ── attention ──────────────────────────────────────────────────── + let h = ops::rms_norm(dev, x, &w.attn_norm, cfg.eps)?; + let mut q = ops::gemv(dev, &w.wq, &h)?; + let mut k = ops::gemv(dev, &w.wk, &h)?; + let mut v = ops::gemv(dev, &w.wv, &h)?; + if cfg.attn_bias { + q = ops::add(dev, &q, w.bias_q.as_ref().ok_or_else(|| Error::Msg("attn_bias set but bias_q missing".into()))?)?; + k = ops::add(dev, &k, w.bias_k.as_ref().ok_or_else(|| Error::Msg("attn_bias set but bias_k missing".into()))?)?; + v = ops::add(dev, &v, w.bias_v.as_ref().ok_or_else(|| Error::Msg("attn_bias set but bias_v missing".into()))?)?; + } + + // Reshape to heads; optional Qwen3 per-head Q/K RMSNorm before RoPE. + let q = q.reshaped(vec![cfg.n_q_heads, hd]); + let k = k.reshaped(vec![cfg.n_kv_heads, hd]); + let (q, k) = if cfg.qk_norm { + let qn = w.q_norm.as_ref().ok_or_else(|| Error::Msg("qk_norm set but q_norm missing".into()))?; + let kn = w.k_norm.as_ref().ok_or_else(|| Error::Msg("qk_norm set but k_norm missing".into()))?; + (ops::rms_norm(dev, &q, qn, cfg.eps)?, ops::rms_norm(dev, &k, kn, cfg.eps)?) + } else { + (q, k) + }; + + // RoPE on Q and K (vanilla — freq-band scaling disabled). + let q = ops::rope_llama(dev, &q, pos, theta, 1.0, 1.0, 1.0, 1e9)?; + let k = ops::rope_llama(dev, &k, pos, theta, 1.0, 1.0, 1.0, 1e9)?; + + // Single-position attention: KV cache is just this token (n_kv=1, stride=1). + let attn = ops::sdpa_decode( + dev, + &q, + &k, + &v.reshaped(vec![cfg.n_kv_heads, hd]), + hd, + 1, + 1, + cfg.heads_per_group(), + cfg.attn_scale(), + )?; + + let o = ops::gemv(dev, &w.wo, &attn.reshaped(vec![cfg.n_q_heads * hd]))?; + let x1 = ops::add(dev, x, &o)?; + + // ── MLP (SwiGLU) ───────────────────────────────────────────────── + let h2 = ops::rms_norm(dev, &x1, &w.mlp_norm, cfg.eps)?; + let gate = ops::gemv(dev, &w.w_gate, &h2)?; + let up = ops::gemv(dev, &w.w_up, &h2)?; + let act = ops::swiglu(dev, &gate, &up)?; + let down = ops::gemv(dev, &w.w_down, &act)?; + let x2 = ops::add(dev, &x1, &down)?; + + Ok(x2) +} + +/// Full model weights for the transformer-LLM family. +pub struct ModelWeights { + pub embed: Tensor, // [vocab, hidden] + pub layers: Vec, + pub final_norm: Tensor, // [hidden] + pub lm_head: Tensor, // [vocab, hidden] (untied; tie = embed) +} + +/// Single-token forward: embed → every decode layer (self-attention) → +/// final RMSNorm → lm_head → next-token logits `[vocab]`. The whole model +/// graph on the shared op layer — embedding, all layers, and the head. (The +/// multi-position KV cache for sequences ≥ 2 is the decode loop's job; this +/// is the per-token compute that proves the full graph.) +pub fn forward_single( + dev: &dyn Device, + cfg: &LlamaConfig, + w: &ModelWeights, + token_id: u32, +) -> Result { + let ids = Tensor::new( + dev.upload(&token_id.to_le_bytes())?, + vec![1], + ffai_core::DType::U32, + ); + let mut x = ops::gather(dev, &w.embed, &ids)?.reshaped(vec![cfg.hidden]); + for layer in &w.layers { + x = decode_layer_self(dev, cfg, layer, &x, 0)?; + } + let xn = ops::rms_norm(dev, &x, &w.final_norm, cfg.eps)?; + ops::gemv(dev, &w.lm_head, &xn) +} + +/// Load a Qwen3 (or any HF-named transformer-LLM) checkpoint from a parsed +/// SafeTensors blob, uploading every weight to `dev`. Maps the HF names +/// (`model.layers.N.self_attn.q_proj.weight`, …) to [`ModelWeights`]. Falls +/// back to tied embeddings when `lm_head.weight` is absent. +pub fn load_qwen3( + dev: &dyn Device, + st: &ffai_loader::SafeTensors, + cfg: &LlamaConfig, + n_layers: usize, +) -> Result { + let up = |name: &str| -> Result { + let (bytes, dt, shape) = st.tensor(name)?; + Ok(Tensor::new(dev.upload(bytes)?, shape.to_vec(), dt)) + }; + + let embed = up("model.embed_tokens.weight")?; + let final_norm = up("model.norm.weight")?; + let lm_head = match up("lm_head.weight") { + Ok(t) => t, + Err(_) => up("model.embed_tokens.weight")?, // tied + }; + + let mut layers = Vec::with_capacity(n_layers); + for l in 0..n_layers { + let p = format!("model.layers.{l}"); + layers.push(LayerWeights { + attn_norm: up(&format!("{p}.input_layernorm.weight"))?, + wq: up(&format!("{p}.self_attn.q_proj.weight"))?, + wk: up(&format!("{p}.self_attn.k_proj.weight"))?, + wv: up(&format!("{p}.self_attn.v_proj.weight"))?, + wo: up(&format!("{p}.self_attn.o_proj.weight"))?, + bias_q: if cfg.attn_bias { Some(up(&format!("{p}.self_attn.q_proj.bias"))?) } else { None }, + bias_k: if cfg.attn_bias { Some(up(&format!("{p}.self_attn.k_proj.bias"))?) } else { None }, + bias_v: if cfg.attn_bias { Some(up(&format!("{p}.self_attn.v_proj.bias"))?) } else { None }, + q_norm: if cfg.qk_norm { + Some(up(&format!("{p}.self_attn.q_norm.weight"))?) + } else { + None + }, + k_norm: if cfg.qk_norm { + Some(up(&format!("{p}.self_attn.k_norm.weight"))?) + } else { + None + }, + mlp_norm: up(&format!("{p}.post_attention_layernorm.weight"))?, + w_gate: up(&format!("{p}.mlp.gate_proj.weight"))?, + w_up: up(&format!("{p}.mlp.up_proj.weight"))?, + w_down: up(&format!("{p}.mlp.down_proj.weight"))?, + }); + } + + Ok(ModelWeights { embed, layers, final_norm, lm_head }) +} + +/// A model loaded from an HF directory, with its derived config. +pub struct LoadedModel { + pub cfg: LlamaConfig, + pub weights: ModelWeights, + pub n_layers: usize, + pub vocab: usize, +} + +/// Load any dense transformer-LLM straight from an HF directory: parse +/// `config.json` for the geometry, detect arch flags from the tensor names +/// (qk-norm by `q_norm.weight`, QKV bias by `q_proj.bias`), and upload the +/// weights. This is what makes the whole Llama/Qwen/Mistral/Yi/Phi/SmolLM +/// family load with one code path — no per-model hardcoding. +pub fn load_hf(dev: &dyn Device, dir: &str) -> Result { + let cfg_txt = std::fs::read_to_string(format!("{dir}/config.json")) + .map_err(|e| Error::Msg(format!("read config.json: {e}")))?; + let j: serde_json::Value = + serde_json::from_str(&cfg_txt).map_err(|e| Error::Msg(format!("config.json: {e}")))?; + let u = |k: &str| j[k].as_u64().map(|x| x as usize); + let hidden = u("hidden_size").ok_or_else(|| Error::Msg("config: hidden_size".into()))?; + let n_q_heads = u("num_attention_heads").ok_or_else(|| Error::Msg("config: n_heads".into()))?; + let n_kv_heads = u("num_key_value_heads").unwrap_or(n_q_heads); + let head_dim = u("head_dim").unwrap_or(hidden / n_q_heads); + let intermediate = u("intermediate_size").ok_or_else(|| Error::Msg("config: inter".into()))?; + let n_layers = u("num_hidden_layers").ok_or_else(|| Error::Msg("config: n_layers".into()))?; + let vocab = u("vocab_size").ok_or_else(|| Error::Msg("config: vocab".into()))?; + let rope_theta = j["rope_theta"].as_f64().unwrap_or(10000.0) as f32; + let eps = j["rms_norm_eps"].as_f64().unwrap_or(1e-6) as f32; + + let st = ffai_loader::SafeTensors::open(&format!("{dir}/model.safetensors"))?; + let qk_norm = st.info("model.layers.0.self_attn.q_norm.weight").is_some(); + let attn_bias = st.info("model.layers.0.self_attn.q_proj.bias").is_some(); + + let cfg = LlamaConfig { + hidden, + n_q_heads, + n_kv_heads, + head_dim, + intermediate, + rope_theta, + eps, + qk_norm, + attn_bias, + }; + let weights = load_qwen3(dev, &st, &cfg, n_layers)?; + Ok(LoadedModel { cfg, weights, n_layers, vocab }) +} + +// ════════════════════════════════════════════════════════════════════════ +// GGUF loader + KV-cache decode (Qwen2.5 / llama.cpp tensor naming) +// ════════════════════════════════════════════════════════════════════════ +// +// The SafeTensors path above expects HF-named, float-on-disk tensors. GGUF +// ships quantized (Q8_0 here) under llama.cpp names (`blk.N.attn_q.weight`…). +// `load_qwen_gguf` dequantizes every weight to f32 on the host and uploads it, +// so the whole forward graph runs in f32 — backend-agnostic (the f32 gemv / +// rms_norm / sdpa kernels are registry-tested on Metal, CUDA, and Vulkan). +// +// Geometry is read from GGUF metadata (`qwen2.*` / `general.*`), so the same +// builder serves any GGUF dense LLM in the Llama/Qwen family with this layout. + +/// Read geometry from a GGUF's metadata and derive the [`LlamaConfig`]. +/// Probes for the `qwen2`/`llama` arch prefix and detects the QKV-bias flag +/// from the presence of `blk.0.attn_q.bias`. +pub fn gguf_config(g: &ffai_loader::gguf::Gguf) -> Result<(LlamaConfig, usize, usize)> { + // Architecture prefix for the geometry keys (e.g. "qwen2", "llama"). + let arch = g.meta_str("general.architecture").unwrap_or("qwen2").to_string(); + let mu = |suffix: &str| -> Option { g.meta_u32(&format!("{arch}.{suffix}")) }; + let need = |suffix: &str| -> Result { + mu(suffix).ok_or_else(|| Error::Msg(format!("gguf: missing metadata {arch}.{suffix}"))) + }; + + let hidden = need("embedding_length")? as usize; + let n_layers = need("block_count")? as usize; + let n_q_heads = need("attention.head_count")? as usize; + let n_kv_heads = mu("attention.head_count_kv").unwrap_or(n_q_heads as u32) as usize; + let intermediate = need("feed_forward_length")? as usize; + let head_dim = mu("attention.key_length").map(|v| v as usize).unwrap_or(hidden / n_q_heads); + let rope_theta = g.meta_f32(&format!("{arch}.rope.freq_base")).unwrap_or(10000.0); + let eps = g + .meta_f32(&format!("{arch}.attention.layer_norm_rms_epsilon")) + .unwrap_or(1e-6); + // GGUF stores vocab as the token-list length; fall back to the embedding rows. + let vocab = g + .metadata_arr_str + .get("tokenizer.ggml.tokens") + .map(|v| v.len()) + .or_else(|| g.tensor("token_embd.weight").map(|t| t.dims[t.dims.len() - 1] as usize)) + .ok_or_else(|| Error::Msg("gguf: cannot determine vocab size".into()))?; + + // Qwen2.5 has QKV projection bias and no q/k-norm (vs Qwen3). + let attn_bias = g.tensor("blk.0.attn_q.bias").is_some(); + let qk_norm = g.tensor("blk.0.attn_q_norm.weight").is_some(); + + let cfg = LlamaConfig { + hidden, + n_q_heads, + n_kv_heads, + head_dim, + intermediate, + rope_theta, + eps, + qk_norm, + attn_bias, + }; + Ok((cfg, n_layers, vocab)) +} + +/// Build [`ModelWeights`] from a GGUF file (Q8_0/F16/F32 dequantized to f32 and +/// uploaded). Maps the llama.cpp tensor names to the builder's slots and wires +/// the Qwen2.5 QKV biases when `cfg.attn_bias`. Parallel to [`load_qwen3`]. +pub fn load_qwen_gguf( + dev: &dyn Device, + g: &ffai_loader::gguf::Gguf, + cfg: &LlamaConfig, + n_layers: usize, +) -> Result { + // Dequant a GGUF tensor → f32, upload, tag with its on-disk dims. + let up = |name: &str| -> Result { + let t = g + .tensor(name) + .ok_or_else(|| Error::Msg(format!("gguf: tensor '{name}' not found")))?; + let data = g.dequant_f32(name)?; + // GGUF dims are stored fastest-first (col-major-ish): a [out,in] matrix + // is listed as dims=[in, out]. The f32 buffer is already row-major + // [out, in] (block r·in .. (r+1)·in), so report shape [out, in]. + let shape: Vec = t.dims.iter().rev().map(|&d| d as usize).collect(); + let bytes: &[u8] = bytemuck_cast(&data); + Ok(Tensor::new(dev.upload(bytes)?, shape, ffai_core::DType::F32)) + }; + + let embed = up("token_embd.weight")?; + let final_norm = up("output_norm.weight")?; + let lm_head = match up("output.weight") { + Ok(t) => t, + Err(_) => up("token_embd.weight")?, // tied embeddings + }; + + let mut layers = Vec::with_capacity(n_layers); + for l in 0..n_layers { + let p = format!("blk.{l}"); + layers.push(LayerWeights { + attn_norm: up(&format!("{p}.attn_norm.weight"))?, + wq: up(&format!("{p}.attn_q.weight"))?, + wk: up(&format!("{p}.attn_k.weight"))?, + wv: up(&format!("{p}.attn_v.weight"))?, + wo: up(&format!("{p}.attn_output.weight"))?, + bias_q: if cfg.attn_bias { Some(up(&format!("{p}.attn_q.bias"))?) } else { None }, + bias_k: if cfg.attn_bias { Some(up(&format!("{p}.attn_k.bias"))?) } else { None }, + bias_v: if cfg.attn_bias { Some(up(&format!("{p}.attn_v.bias"))?) } else { None }, + q_norm: if cfg.qk_norm { Some(up(&format!("{p}.attn_q_norm.weight"))?) } else { None }, + k_norm: if cfg.qk_norm { Some(up(&format!("{p}.attn_k_norm.weight"))?) } else { None }, + mlp_norm: up(&format!("{p}.ffn_norm.weight"))?, + w_gate: up(&format!("{p}.ffn_gate.weight"))?, + w_up: up(&format!("{p}.ffn_up.weight"))?, + w_down: up(&format!("{p}.ffn_down.weight"))?, + }); + } + + Ok(ModelWeights { embed, layers, final_norm, lm_head }) +} + +// ════════════════════════════════════════════════════════════════════════ +// Phi-3 GGUF path: FUSED attention/MLP tensors split at load. +// ════════════════════════════════════════════════════════════════════════ +// +// Phi-3 (arch="phi3") is a standard RMSNorm + RoPE + GQA + SwiGLU transformer — +// the same forward graph as Qwen — but llama.cpp ships two of its projections +// FUSED into single tensors: +// * `blk.N.attn_qkv.weight` — Q‖K‖V stacked [out=(nq+2·nkv)·hd, in=hidden] +// * `blk.N.ffn_up.weight` — gate‖up stacked [out=2·intermediate, in=hidden] +// (no separate `attn_q/attn_k/attn_v` or `ffn_gate`). Phi-3 has NO QKV bias and +// NO q/k-norm, so once the fused tensors are split into the builder's q/k/v + +// gate/up slots, the existing Qwen f32 forward path (`GgufModel::step`) runs it +// unchanged. The split is a pure row-range slice of the row-major [out,in] f32 +// buffer (fused weights are simple row concatenations), so it is exact — no +// re-quant, backend-agnostic (host-side slice → f32 upload → registry ops). + +/// True when the GGUF declares the Phi-3 architecture (fused qkv/ffn_up). +pub fn gguf_is_phi3(g: &ffai_loader::gguf::Gguf) -> bool { + g.meta_str("general.architecture") == Some("phi3") + || g.tensor("blk.0.attn_qkv.weight").is_some() +} + +/// Dequant a 2-D GGUF weight to a row-major f32 `[out, in]` buffer plus its +/// `(out, in)` dims. GGUF stores dims fastest-first, so a logical `[out, in]` +/// matrix is listed as `dims=[in, out]`; the dequantized buffer is row-major +/// `[out, in]`. Shared by the fused-tensor split below. +fn dequant_2d(g: &ffai_loader::gguf::Gguf, name: &str) -> Result<(Vec, usize, usize)> { + let t = g + .tensor(name) + .ok_or_else(|| Error::Msg(format!("gguf: tensor '{name}' not found")))?; + if t.dims.len() != 2 { + return Err(Error::Msg(format!("gguf: '{name}' expected 2-D, got {:?}", t.dims))); + } + let in_dim = t.dims[0] as usize; // fastest (row stride) + let out_dim = t.dims[1] as usize; // rows + let data = g.dequant_f32(name)?; + Ok((data, out_dim, in_dim)) +} + +/// Upload a contiguous row slice `[r0, r1)` of a row-major `[out, in]` f32 buffer +/// as a `[r1-r0, in]` weight tensor — the per-projection slab carved from a +/// fused GGUF tensor. +fn upload_rows( + dev: &dyn Device, + data: &[f32], + in_dim: usize, + r0: usize, + r1: usize, +) -> Result { + let slab = &data[r0 * in_dim..r1 * in_dim]; + let bytes: &[u8] = bytemuck_cast(slab); + Ok(Tensor::new(dev.upload(bytes)?, vec![r1 - r0, in_dim], ffai_core::DType::F32)) +} + +/// Build [`ModelWeights`] from a **Phi-3** GGUF (f32 dequant-to-upload), +/// splitting the fused `attn_qkv` → q/k/v and `ffn_up` → gate/up at load. +/// Parallel to [`load_qwen_gguf`]; the rest of the graph is identical (Phi-3 has +/// no QKV bias, no q/k-norm). +pub fn load_phi3_gguf( + dev: &dyn Device, + g: &ffai_loader::gguf::Gguf, + cfg: &LlamaConfig, + n_layers: usize, +) -> Result { + // Generic upload (handles 1-D norms and 2-D weights), matching + // [`load_qwen_gguf`]'s `up`: GGUF dims are fastest-first, so reverse them. + let up = |name: &str| -> Result { + let t = g + .tensor(name) + .ok_or_else(|| Error::Msg(format!("gguf: tensor '{name}' not found")))?; + let data = g.dequant_f32(name)?; + let shape: Vec = t.dims.iter().rev().map(|&d| d as usize).collect(); + let bytes: &[u8] = bytemuck_cast(&data); + Ok(Tensor::new(dev.upload(bytes)?, shape, ffai_core::DType::F32)) + }; + + let embed = up("token_embd.weight")?; + let final_norm = up("output_norm.weight")?; + let lm_head = match up("output.weight") { + Ok(t) => t, + Err(_) => up("token_embd.weight")?, // tied embeddings + }; + + let q_out = cfg.n_q_heads * cfg.head_dim; + let kv_out = cfg.n_kv_heads * cfg.head_dim; + + let mut layers = Vec::with_capacity(n_layers); + for l in 0..n_layers { + let p = format!("blk.{l}"); + + // Fused QKV: rows [0,q_out) = Q, [q_out,q_out+kv_out) = K, then V. + let (qkv, qkv_out, qkv_in) = dequant_2d(g, &format!("{p}.attn_qkv.weight"))?; + if qkv_out != q_out + 2 * kv_out { + return Err(Error::Msg(format!( + "phi3 '{p}.attn_qkv': out {qkv_out} != q({q_out})+2·kv({kv_out})" + ))); + } + let wq = upload_rows(dev, &qkv, qkv_in, 0, q_out)?; + let wk = upload_rows(dev, &qkv, qkv_in, q_out, q_out + kv_out)?; + let wv = upload_rows(dev, &qkv, qkv_in, q_out + kv_out, q_out + 2 * kv_out)?; + + // Fused gate/up: rows [0,inter) = gate, [inter,2·inter) = up. + let (gu, gu_out, gu_in) = dequant_2d(g, &format!("{p}.ffn_up.weight"))?; + if gu_out != 2 * cfg.intermediate { + return Err(Error::Msg(format!( + "phi3 '{p}.ffn_up': out {gu_out} != 2·intermediate({})", + cfg.intermediate + ))); + } + let w_gate = upload_rows(dev, &gu, gu_in, 0, cfg.intermediate)?; + let w_up = upload_rows(dev, &gu, gu_in, cfg.intermediate, 2 * cfg.intermediate)?; + + layers.push(LayerWeights { + attn_norm: up(&format!("{p}.attn_norm.weight"))?, + wq, + wk, + wv, + wo: up(&format!("{p}.attn_output.weight"))?, + bias_q: None, + bias_k: None, + bias_v: None, + q_norm: None, + k_norm: None, + mlp_norm: up(&format!("{p}.ffn_norm.weight"))?, + w_gate, + w_up, + w_down: up(&format!("{p}.ffn_down.weight"))?, + }); + } + + Ok(ModelWeights { embed, layers, final_norm, lm_head }) +} + +// ════════════════════════════════════════════════════════════════════════ +// Resident-Q8 GGUF path: keep the big matmul weights QUANTIZED (Q8_0) on the +// device and decode through `ffai_ops::gemv_q8` instead of dequant-to-f32. +// ════════════════════════════════════════════════════════════════════════ +// +// This roughly halves the weight upload (int8 + f32 scale/32 ≈ 1.03 B/weight vs +// 4 B/weight for f32) AND cuts decode DRAM bandwidth (~4× less weight traffic +// per matvec — the resident-decode win). Norms/RoPE/attention stay f32 (small). +// Backend-agnostic: gemv_q8 is the registry-tested Metal/CUDA/Vulkan kernel. + +/// One resident-Q8 weight matrix in the layout `ffai_ops::gemv_q8` consumes: +/// `qs` = int8 codes packed 4-per-u32 (8 u32 per 32-block), `scales` = one f32 +/// per 32-block (`[m*k/32]`). Dense matvec ⇒ `rows_per_group = m`. +pub struct Q8Mat { + pub qs: Tensor, // [m * k/32 * 8] u32 + pub scales: Tensor, // [m * k/32] f32 + pub m: usize, // out-dim (rows) + pub k: usize, // in-dim (cols, multiple of 32) +} + +impl Q8Mat { + /// `out[m] = Wq · x[k]` via the resident-Q8 grouped matvec (dense: one group). + fn matvec(&self, dev: &dyn Device, x: &Tensor) -> Result { + ops::gemv_q8(dev, &self.qs, &self.scales, x, self.m, self.k, self.m) + } + + /// Batched `out[n_rows, m] = x[n_rows, k] · Wqᵀ` via the resident-Q8 + /// cooperative-matrix GEMM (`gemm_q8_mpp`). This is the **prefill** path: + /// all `n_rows` prompt tokens projected in one dispatch (vs `n_rows`× + /// `matvec`), and on Vulkan/RDNA4 it lights up the coopmat fragment ops when + /// `MT_VK_COOPMAT=1`. Returns `[n_rows, m]`. + /// + /// Debug: `FFAI_PREFILL_GEMV=1` routes the batched projection through a + /// per-row `gemv_q8` loop instead (the proven decode kernel), to A/B the + /// GEMM path against attention/rope when isolating a prefill correctness bug. + fn gemm(&self, dev: &dyn Device, x: &Tensor, n_rows: usize) -> Result { + if std::env::var("FFAI_PREFILL_GEMV").map(|v| v == "1").unwrap_or(false) { + // Per-row gemv_q8: slice each token's [k] activation, matvec, stack. + let mut rows: Vec = Vec::with_capacity(n_rows * self.m * 4); + for r in 0..n_rows { + let xr = ops::slice(dev, &x.reshaped(vec![n_rows * self.k]), r * self.k, self.k)?; + let yr = ops::gemv_q8(dev, &self.qs, &self.scales, &xr, self.m, self.k, self.m)?; + dev.synchronize()?; + let mut yb = vec![0u8; self.m * 4]; + dev.download(yr.buffer.as_ref(), &mut yb)?; + rows.extend_from_slice(&yb); + } + let buf = dev.upload(&rows)?; + return Ok(Tensor::new(buf, vec![n_rows, self.m], ffai_core::DType::F32)); + } + ops::gemm_q8_mpp(dev, &self.qs, &self.scales, x, n_rows, self.m, self.k) + } +} + +/// Per-layer resident-Q8 matmul weights (the 7 big projections). Norms/biases +/// stay f32 in the parallel [`LayerWeights`]. +pub struct Q8Layer { + pub wq: Q8Mat, + pub wk: Q8Mat, + pub wv: Q8Mat, + pub wo: Q8Mat, + pub w_gate: Q8Mat, + pub w_up: Q8Mat, + pub w_down: Q8Mat, +} + +/// Resident-Q8 mirror of [`ModelWeights`]'s big matmuls (`embed`/norms stay f32). +pub struct Q8Weights { + pub lm_head: Q8Mat, + pub layers: Vec, +} + +/// Upload a 2-D GGUF weight as a resident-Q8 [`Q8Mat`] — Q8_0 tensors are +/// repacked losslessly, F16/F32 are quantized per-32-block (see +/// [`ffai_loader::gguf::Gguf::q8_repack`]). No f32 weight upload. +fn up_q8(dev: &dyn Device, g: &ffai_loader::gguf::Gguf, name: &str) -> Result { + let (qs, scales, m, k) = g.q8_repack(name)?; + let qs_bytes: &[u8] = u32_bytes(&qs); + let sc_bytes: &[u8] = bytemuck_cast(&scales); + Ok(Q8Mat { + qs: Tensor::new(dev.upload(qs_bytes)?, vec![qs.len()], ffai_core::DType::U32), + scales: Tensor::new(dev.upload(sc_bytes)?, vec![scales.len()], ffai_core::DType::F32), + m, + k, + }) +} + +/// Build the resident-Q8 big-matmul weights from a GGUF. Parallel to +/// [`load_qwen_gguf`], but keeps q/k/v/o + gate/up/down + lm_head QUANTIZED. +/// `output.weight` falls back to the (tied) `token_embd.weight`. +pub fn load_qwen_gguf_q8( + dev: &dyn Device, + g: &ffai_loader::gguf::Gguf, + n_layers: usize, +) -> Result { + let lm_head = match up_q8(dev, g, "output.weight") { + Ok(t) => t, + Err(_) => up_q8(dev, g, "token_embd.weight")?, // tied embeddings + }; + let mut layers = Vec::with_capacity(n_layers); + for l in 0..n_layers { + let p = format!("blk.{l}"); + layers.push(Q8Layer { + wq: up_q8(dev, g, &format!("{p}.attn_q.weight"))?, + wk: up_q8(dev, g, &format!("{p}.attn_k.weight"))?, + wv: up_q8(dev, g, &format!("{p}.attn_v.weight"))?, + wo: up_q8(dev, g, &format!("{p}.attn_output.weight"))?, + w_gate: up_q8(dev, g, &format!("{p}.ffn_gate.weight"))?, + w_up: up_q8(dev, g, &format!("{p}.ffn_up.weight"))?, + w_down: up_q8(dev, g, &format!("{p}.ffn_down.weight"))?, + }); + } + Ok(Q8Weights { lm_head, layers }) +} + +/// Quantize a row-major `[m, k]` f32 slab into a resident-Q8 [`Q8Mat`] using the +/// identical per-32-block amax/127 scheme as [`ffai_loader::gguf::Gguf::q8_repack`]. +/// Used to carve Q8 sub-matrices from Phi-3's fused (already-dequantized) tensors. +fn q8mat_from_f32(dev: &dyn Device, w: &[f32], m: usize, k: usize) -> Result { + if k % 32 != 0 { + return Err(Error::Msg(format!("q8mat_from_f32: in-dim {k} not a multiple of 32"))); + } + let bpr = k / 32; + let mut qs = vec![0u32; m * bpr * 8]; + let mut scales = vec![0f32; m * bpr]; + for r in 0..m { + for b in 0..bpr { + let base = r * k + b * 32; + let amax = (0..32).fold(0f32, |a, i| a.max(w[base + i].abs())); + let d = amax / 127.0; + scales[r * bpr + b] = d; + let inv = if d > 0.0 { 1.0 / d } else { 0.0 }; + for w_i in 0..8 { + let mut packed = 0u32; + for i in 0..4 { + let q = (w[base + w_i * 4 + i] * inv).round().clamp(-127.0, 127.0) as i32; + packed |= ((q as u8) as u32) << (i * 8); + } + qs[r * bpr * 8 + b * 8 + w_i] = packed; + } + } + } + let qs_bytes: &[u8] = u32_bytes(&qs); + let sc_bytes: &[u8] = bytemuck_cast(&scales); + Ok(Q8Mat { + qs: Tensor::new(dev.upload(qs_bytes)?, vec![qs.len()], ffai_core::DType::U32), + scales: Tensor::new(dev.upload(sc_bytes)?, vec![scales.len()], ffai_core::DType::F32), + m, + k, + }) +} + +/// Resident-Q8 mirror of [`load_phi3_gguf`]: keeps q/k/v/o + gate/up/down + +/// lm_head QUANTIZED, splitting the fused `attn_qkv`/`ffn_up` row-ranges first +/// (re-quantized per-32-block from the exact f32 slabs). +pub fn load_phi3_gguf_q8( + dev: &dyn Device, + g: &ffai_loader::gguf::Gguf, + cfg: &LlamaConfig, + n_layers: usize, +) -> Result { + let lm_head = match up_q8(dev, g, "output.weight") { + Ok(t) => t, + Err(_) => up_q8(dev, g, "token_embd.weight")?, // tied embeddings + }; + let q_out = cfg.n_q_heads * cfg.head_dim; + let kv_out = cfg.n_kv_heads * cfg.head_dim; + let mut layers = Vec::with_capacity(n_layers); + for l in 0..n_layers { + let p = format!("blk.{l}"); + + let (qkv, qkv_out, qkv_in) = dequant_2d(g, &format!("{p}.attn_qkv.weight"))?; + if qkv_out != q_out + 2 * kv_out { + return Err(Error::Msg(format!( + "phi3 '{p}.attn_qkv': out {qkv_out} != q({q_out})+2·kv({kv_out})" + ))); + } + let wq = q8mat_from_f32(dev, &qkv[0..q_out * qkv_in], q_out, qkv_in)?; + let wk = q8mat_from_f32(dev, &qkv[q_out * qkv_in..(q_out + kv_out) * qkv_in], kv_out, qkv_in)?; + let wv = q8mat_from_f32( + dev, + &qkv[(q_out + kv_out) * qkv_in..(q_out + 2 * kv_out) * qkv_in], + kv_out, + qkv_in, + )?; + + let (gu, gu_out, gu_in) = dequant_2d(g, &format!("{p}.ffn_up.weight"))?; + if gu_out != 2 * cfg.intermediate { + return Err(Error::Msg(format!( + "phi3 '{p}.ffn_up': out {gu_out} != 2·intermediate({})", + cfg.intermediate + ))); + } + let inter = cfg.intermediate; + let w_gate = q8mat_from_f32(dev, &gu[0..inter * gu_in], inter, gu_in)?; + let w_up = q8mat_from_f32(dev, &gu[inter * gu_in..2 * inter * gu_in], inter, gu_in)?; + + layers.push(Q8Layer { + wq, + wk, + wv, + wo: up_q8(dev, g, &format!("{p}.attn_output.weight"))?, + w_gate, + w_up, + w_down: up_q8(dev, g, &format!("{p}.ffn_down.weight"))?, + }); + } + Ok(Q8Weights { lm_head, layers }) +} + +/// Phi-3 mirror of [`load_qwen_gguf_small_f32`]: f32 embed/norms/final-norm (the +/// big matmuls are resident-Q8 in [`load_phi3_gguf_q8`], so their slots are +/// dummies). Phi-3 has no QKV bias / q-k-norm. +pub fn load_phi3_gguf_small_f32( + dev: &dyn Device, + g: &ffai_loader::gguf::Gguf, + _cfg: &LlamaConfig, + n_layers: usize, +) -> Result { + let up = |name: &str| -> Result { + let (data, out_dim, in_dim) = dequant_2d(g, name)?; + let bytes: &[u8] = bytemuck_cast(&data); + Ok(Tensor::new(dev.upload(bytes)?, vec![out_dim, in_dim], ffai_core::DType::F32)) + }; + let up1 = |name: &str| -> Result { + // 1-D norm vectors (dequant_2d requires 2-D; norms are 1-D). + let t = g.tensor(name).ok_or_else(|| Error::Msg(format!("gguf: '{name}' not found")))?; + let data = g.dequant_f32(name)?; + let shape: Vec = t.dims.iter().rev().map(|&d| d as usize).collect(); + let bytes: &[u8] = bytemuck_cast(&data); + Ok(Tensor::new(dev.upload(bytes)?, shape, ffai_core::DType::F32)) + }; + let dummy = |dev: &dyn Device| -> Result { + Ok(Tensor::new(dev.upload(&0.0f32.to_le_bytes())?, vec![1, 1], ffai_core::DType::F32)) + }; + + let embed = up("token_embd.weight")?; + let final_norm = up1("output_norm.weight")?; + let lm_head = dummy(dev)?; // resident-Q8 in Q8Weights + + let mut layers = Vec::with_capacity(n_layers); + for l in 0..n_layers { + let p = format!("blk.{l}"); + layers.push(LayerWeights { + attn_norm: up1(&format!("{p}.attn_norm.weight"))?, + wq: dummy(dev)?, + wk: dummy(dev)?, + wv: dummy(dev)?, + wo: dummy(dev)?, + bias_q: None, + bias_k: None, + bias_v: None, + q_norm: None, + k_norm: None, + mlp_norm: up1(&format!("{p}.ffn_norm.weight"))?, + w_gate: dummy(dev)?, + w_up: dummy(dev)?, + w_down: dummy(dev)?, + }); + } + Ok(ModelWeights { embed, layers, final_norm, lm_head }) +} + +/// Build the f32 *small* weights for the resident-Q8 path: embed (gather needs +/// f32), the RMSNorm weights, QKV biases, and final norm. The big matmul slots +/// in [`ModelWeights`] get a 1-element dummy (the Q8 path never reads them) so +/// no f32 matmul weight is uploaded. Mirrors [`load_qwen_gguf`]'s f32 dequant +/// for these small tensors only. +pub fn load_qwen_gguf_small_f32( + dev: &dyn Device, + g: &ffai_loader::gguf::Gguf, + cfg: &LlamaConfig, + n_layers: usize, +) -> Result { + let up = |name: &str| -> Result { + let t = g + .tensor(name) + .ok_or_else(|| Error::Msg(format!("gguf: tensor '{name}' not found")))?; + let data = g.dequant_f32(name)?; + let shape: Vec = t.dims.iter().rev().map(|&d| d as usize).collect(); + let bytes: &[u8] = bytemuck_cast(&data); + Ok(Tensor::new(dev.upload(bytes)?, shape, ffai_core::DType::F32)) + }; + // 1-element f32 dummy for the unused big-matmul slots (Q8 path ignores them). + let dummy = |dev: &dyn Device| -> Result { + Ok(Tensor::new(dev.upload(&0.0f32.to_le_bytes())?, vec![1, 1], ffai_core::DType::F32)) + }; + + let embed = up("token_embd.weight")?; + let final_norm = up("output_norm.weight")?; + let lm_head = dummy(dev)?; // resident-Q8 in Q8Weights + + let mut layers = Vec::with_capacity(n_layers); + for l in 0..n_layers { + let p = format!("blk.{l}"); + layers.push(LayerWeights { + attn_norm: up(&format!("{p}.attn_norm.weight"))?, + wq: dummy(dev)?, + wk: dummy(dev)?, + wv: dummy(dev)?, + wo: dummy(dev)?, + bias_q: if cfg.attn_bias { Some(up(&format!("{p}.attn_q.bias"))?) } else { None }, + bias_k: if cfg.attn_bias { Some(up(&format!("{p}.attn_k.bias"))?) } else { None }, + bias_v: if cfg.attn_bias { Some(up(&format!("{p}.attn_v.bias"))?) } else { None }, + q_norm: if cfg.qk_norm { Some(up(&format!("{p}.attn_q_norm.weight"))?) } else { None }, + k_norm: if cfg.qk_norm { Some(up(&format!("{p}.attn_k_norm.weight"))?) } else { None }, + mlp_norm: up(&format!("{p}.ffn_norm.weight"))?, + w_gate: dummy(dev)?, + w_up: dummy(dev)?, + w_down: dummy(dev)?, + }); + } + Ok(ModelWeights { embed, layers, final_norm, lm_head }) +} + +/// `&[u32]` → `&[u8]` for upload (little-endian host). Plain reinterpret. +fn u32_bytes(v: &[u32]) -> &[u8] { + // SAFETY: u32 has no padding/invalid bit patterns; read-only byte view for + // the duration of the upload call. + unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) } +} + +/// `&[f32]` → `&[u8]` for upload (little-endian host; the device consumes raw +/// f32 bytes). Plain reinterpret — no external dep. +fn bytemuck_cast(v: &[f32]) -> &[u8] { + // SAFETY: f32 has no padding/invalid bit patterns; we expose its bytes + // read-only for the duration of the upload call. + unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) } +} + +/// A GGUF-loaded model with a resident multi-position KV cache, exposing a +/// `step(token, pos) -> logits` suitable for `ffai_runtime::generate`. The KV +/// cache is `[n_kv_heads, cap, head_dim]` per layer; each step writes k/v at +/// `pos` and attends over `[0, pos]`. Everything runs in f32 on `dev`. +pub struct GgufModel { + pub cfg: LlamaConfig, + pub weights: ModelWeights, + pub n_layers: usize, + pub vocab: usize, + cap: usize, + /// Resident-Q8 big-matmul weights. When `Some`, `step` decodes through + /// `gemv_q8` (weights kept quantized) instead of the f32 `gemv` path; the + /// f32 big-matmul slots in `weights` are then unused dummy placeholders. + q8: Option, + // Per-layer (k_cache, v_cache), each [n_kv_heads * cap * head_dim] f32. + kcache: Vec, + vcache: Vec, +} + +impl GgufModel { + /// Load from a GGUF path, deriving config from metadata and allocating a + /// KV cache of `cap` positions. Uses the f32 dequant-to-upload weight path. + pub fn open(dev: &dyn Device, path: &str, cap: usize) -> Result { + let g = ffai_loader::gguf::Gguf::open(path)?; + let (cfg, n_layers, vocab) = gguf_config(&g)?; + // Phi-3 ships FUSED attn_qkv / ffn_up tensors; split them at load. Once + // split, the Qwen f32 forward path serves Phi-3 unchanged. + let weights = if gguf_is_phi3(&g) { + load_phi3_gguf(dev, &g, &cfg, n_layers)? + } else { + load_qwen_gguf(dev, &g, &cfg, n_layers)? + }; + Self::with_weights(dev, cfg, weights, n_layers, vocab, cap) + } + + /// Load from a GGUF path keeping the big matmul weights QUANTIZED (Q8) and + /// decoding through `gemv_q8`. Only embed/norms/biases are uploaded as f32 + /// (small), so the weight upload is ~halved and decode reads ~4× less weight + /// DRAM. Same geometry/KV-cache as [`open`]; backend-agnostic. + pub fn open_q8(dev: &dyn Device, path: &str, cap: usize) -> Result { + let g = ffai_loader::gguf::Gguf::open(path)?; + let (cfg, n_layers, vocab) = gguf_config(&g)?; + let (weights, q8) = if gguf_is_phi3(&g) { + ( + load_phi3_gguf_small_f32(dev, &g, &cfg, n_layers)?, + load_phi3_gguf_q8(dev, &g, &cfg, n_layers)?, + ) + } else { + ( + load_qwen_gguf_small_f32(dev, &g, &cfg, n_layers)?, + load_qwen_gguf_q8(dev, &g, n_layers)?, + ) + }; + let mut m = Self::with_weights(dev, cfg, weights, n_layers, vocab, cap)?; + m.q8 = Some(q8); + Ok(m) + } + + /// **Recommended GGUF entrypoint.** Loads `path` preferring the resident-Q8 + /// decode path ([`open_q8`] — weights kept Q8, decode via `gemv_q8`, 20-57× + /// faster than f32 and bit-identical in practice) and transparently FALLS + /// BACK to the f32 dequant-to-upload path ([`open`]) if the Q8 repack can't + /// handle the file (e.g. a quant type `q8_repack` doesn't support). Same + /// geometry/KV-cache as both; backend-agnostic. + /// + /// Use this unless you specifically need the f32 reference path (e.g. a + /// numerical baseline) — then call [`open`] directly. + /// + /// [`open`]: GgufModel::open + /// [`open_q8`]: GgufModel::open_q8 + pub fn load(dev: &dyn Device, path: &str, cap: usize) -> Result { + match Self::open_q8(dev, path, cap) { + Ok(m) => Ok(m), + Err(e) => { + eprintln!( + "GgufModel::load: resident-Q8 path unavailable ({e}); \ + falling back to f32 dequant-to-upload" + ); + Self::open(dev, path, cap) + } + } + } + + /// Construct from already-loaded weights (lets a caller reuse a parsed Gguf + /// for the tokenizer without re-loading). + pub fn with_weights( + dev: &dyn Device, + cfg: LlamaConfig, + weights: ModelWeights, + n_layers: usize, + vocab: usize, + cap: usize, + ) -> Result { + let nkv_hd = cfg.n_kv_heads * cfg.head_dim; + let zero = vec![0.0f32; nkv_hd * cap]; + let zb = bytemuck_cast(&zero); + let mut kcache = Vec::with_capacity(n_layers); + let mut vcache = Vec::with_capacity(n_layers); + for _ in 0..n_layers { + kcache.push(Tensor::new(dev.upload(zb)?, vec![nkv_hd * cap], ffai_core::DType::F32)); + vcache.push(Tensor::new(dev.upload(zb)?, vec![nkv_hd * cap], ffai_core::DType::F32)); + } + Ok(GgufModel { cfg, weights, n_layers, vocab, cap, q8: None, kcache, vcache }) + } + + /// One decode step: embed `token`, run every layer with KV-cache attention + /// over `[0, pos]`, final-norm + lm_head, and download the `[vocab]` f32 + /// logits to the host (so the pure-Rust sampler can pick the next token). + pub fn step(&self, dev: &dyn Device, token: u32, pos: usize) -> Result> { + let cfg = &self.cfg; + let hd = cfg.head_dim; + let cap = self.cap; + let nkv = cfg.n_kv_heads; + let nq = cfg.n_q_heads; + + let ids = Tensor::new( + dev.upload(&token.to_le_bytes())?, + vec![1], + ffai_core::DType::U32, + ); + let mut x = ops::gather(dev, &self.weights.embed, &ids)?.reshaped(vec![cfg.hidden]); + + let pb = Tensor::new(dev.upload(&(pos as u32).to_le_bytes())?, vec![1], ffai_core::DType::U32); + + for l in 0..self.n_layers { + let w = &self.weights.layers[l]; + let q8l = self.q8.as_ref().map(|q| &q.layers[l]); + // ── attention ────────────────────────────────────────────── + let h = ops::rms_norm(dev, &x, &w.attn_norm, cfg.eps)?; + let (mut q, mut k, mut v) = match q8l { + Some(q8) => (q8.wq.matvec(dev, &h)?, q8.wk.matvec(dev, &h)?, q8.wv.matvec(dev, &h)?), + None => (ops::gemv(dev, &w.wq, &h)?, ops::gemv(dev, &w.wk, &h)?, ops::gemv(dev, &w.wv, &h)?), + }; + if cfg.attn_bias { + q = ops::add(dev, &q, w.bias_q.as_ref().unwrap())?; + k = ops::add(dev, &k, w.bias_k.as_ref().unwrap())?; + v = ops::add(dev, &v, w.bias_v.as_ref().unwrap())?; + } + let q = q.reshaped(vec![nq, hd]); + let k = k.reshaped(vec![nkv, hd]); + let (q, k) = if cfg.qk_norm { + ( + ops::rms_norm(dev, &q, w.q_norm.as_ref().unwrap(), cfg.eps)?, + ops::rms_norm(dev, &k, w.k_norm.as_ref().unwrap(), cfg.eps)?, + ) + } else { + (q, k) + }; + let q = ops::rope_llama(dev, &q, pos as u32, cfg.rope_theta, 1.0, 1.0, 1.0, 1e9)?; + let k = ops::rope_llama(dev, &k, pos as u32, cfg.rope_theta, 1.0, 1.0, 1.0, 1e9)?; + let v = v.reshaped(vec![nkv, hd]); + + // Write this token's K/V into the cache at `pos`, then attend [0,pos]. + ops::kv_append(dev, &k, &self.kcache[l], &pb, hd, cap, nkv * hd)?; + ops::kv_append(dev, &v, &self.vcache[l], &pb, hd, cap, nkv * hd)?; + let len = (pos + 1) as u32; + let attn = ops::sdpa_decode( + dev, + &q, + &self.kcache[l].reshaped(vec![nkv, cap, hd]), + &self.vcache[l].reshaped(vec![nkv, cap, hd]), + hd, + len, + cap as u32, + cfg.heads_per_group(), + cfg.attn_scale(), + )?; + let attn_o = attn.reshaped(vec![nq * hd]); + let o = match q8l { + Some(q8) => q8.wo.matvec(dev, &attn_o)?, + None => ops::gemv(dev, &w.wo, &attn_o)?, + }; + x = ops::add(dev, &x, &o)?; + + // ── MLP (SwiGLU) ─────────────────────────────────────────── + let h2 = ops::rms_norm(dev, &x, &w.mlp_norm, cfg.eps)?; + let (gate, up) = match q8l { + Some(q8) => (q8.w_gate.matvec(dev, &h2)?, q8.w_up.matvec(dev, &h2)?), + None => (ops::gemv(dev, &w.w_gate, &h2)?, ops::gemv(dev, &w.w_up, &h2)?), + }; + let act = ops::swiglu(dev, &gate, &up)?; + let down = match q8l { + Some(q8) => q8.w_down.matvec(dev, &act)?, + None => ops::gemv(dev, &w.w_down, &act)?, + }; + x = ops::add(dev, &x, &down)?; + } + + let xn = ops::rms_norm(dev, &x, &self.weights.final_norm, cfg.eps)?; + let logits = match self.q8.as_ref() { + Some(q8) => q8.lm_head.matvec(dev, &xn)?, + None => ops::gemv(dev, &self.weights.lm_head, &xn)?, + }; + dev.synchronize()?; + let mut bytes = vec![0u8; self.vocab * 4]; + dev.download(logits.buffer.as_ref(), &mut bytes)?; + Ok(bytes.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect()) + } + + /// Batched **prefill**: process all `tokens` at positions `[start, start+S)` + /// in ONE forward, writing their K/V into the cache, and return the next-token + /// logits (the last row's `[vocab]`). This is the prefill counterpart of the + /// per-token [`step`]: instead of `S` sequential `step()` calls (each doing + /// `gemv_q8` matvecs), the seven projections + lm_head run as batched + /// `[S, k]·Wᵀ` GEMMs through [`ffai_ops::gemm_q8_mpp`] — the SimdGroup CoopTile + /// kernel that picks up `VK_KHR_cooperative_matrix` on Vulkan/RDNA4 when + /// `MT_VK_COOPMAT=1`. Only the resident-Q8 path is GEMM-routed; without Q8 + /// weights it falls back to the per-token loop (f32 dense decode). + /// + /// Requires `head_dim == 128` (the `sdpa_multi` prefill-attention kernel). + /// Positions must fit the KV-cache capacity (`start + S <= cap`). + pub fn prefill(&self, dev: &dyn Device, tokens: &[u32], start: usize) -> Result> { + let cfg = &self.cfg; + let s = tokens.len(); + // Fall back to the proven per-token path when the GEMM prereqs aren't met + // (no resident-Q8 weights, single token, or non-128 head_dim). + if self.q8.is_none() || s <= 1 || cfg.head_dim != 128 { + let mut last = Vec::new(); + for (i, &t) in tokens.iter().enumerate() { + last = self.step(dev, t, start + i)?; + } + return Ok(last); + } + let hd = cfg.head_dim; + let cap = self.cap; + let nkv = cfg.n_kv_heads; + let nq = cfg.n_q_heads; + let q8w = self.q8.as_ref().unwrap(); + + // Embed all S tokens → x [S, hidden]. + let ids = Tensor::new( + dev.upload(u32_bytes(tokens))?, + vec![s], + ffai_core::DType::U32, + ); + let mut x = ops::gather(dev, &self.weights.embed, &ids)?.reshaped(vec![s, cfg.hidden]); + + // Positions [start, start+S) for batched RoPE + KV append. + let pos_vec: Vec = (0..s).map(|i| (start + i) as u32).collect(); + let positions = Tensor::new( + dev.upload(u32_bytes(&pos_vec))?, + vec![s], + ffai_core::DType::U32, + ); + + for l in 0..self.n_layers { + let w = &self.weights.layers[l]; + let q8 = &q8w.layers[l]; + // ── attention ────────────────────────────────────────────── + let h = ops::rms_norm(dev, &x, &w.attn_norm, cfg.eps)?; // [S, hidden] + let mut q = q8.wq.gemm(dev, &h, s)?; // [S, nq*hd] — coopmat GEMM + let mut k = q8.wk.gemm(dev, &h, s)?; // [S, nkv*hd] + let mut v = q8.wv.gemm(dev, &h, s)?; // [S, nkv*hd] + if cfg.attn_bias { + q = ops::add_bias_rows(dev, &q, w.bias_q.as_ref().unwrap(), s, nq * hd)?; + k = ops::add_bias_rows(dev, &k, w.bias_k.as_ref().unwrap(), s, nkv * hd)?; + v = ops::add_bias_rows(dev, &v, w.bias_v.as_ref().unwrap(), s, nkv * hd)?; + } + // Optional Qwen3 per-head Q/K RMSNorm (rms_norm normalizes the last + // dim, so a [S*heads, head_dim] view is correct per (token,head)). + let (q, k) = if cfg.qk_norm { + let qv = q.reshaped(vec![s * nq, hd]); + let kv = k.reshaped(vec![s * nkv, hd]); + ( + ops::rms_norm(dev, &qv, w.q_norm.as_ref().unwrap(), cfg.eps)?, + ops::rms_norm(dev, &kv, w.k_norm.as_ref().unwrap(), cfg.eps)?, + ) + } else { + (q, k) + }; + // Batched RoPE over [S, heads, hd] (one dispatch each for Q and K). + let q = ops::rope_llama_many(dev, &q.reshaped(vec![s, nq, hd]), &positions, nq, hd, cfg.rope_theta, 1.0, 1.0, 1.0, 1e9)?; + let k = ops::rope_llama_many(dev, &k.reshaped(vec![s, nkv, hd]), &positions, nkv, hd, cfg.rope_theta, 1.0, 1.0, 1.0, 1e9)?; + let v = v.reshaped(vec![s, nkv, hd]); + + // Append all S tokens' K/V into the cache in two batched dispatches, + // then attend the whole block in ONE `sdpa_multi` flash dispatch. + // `sdpa_multi` (causal) makes query `r` attend `[0, base_kv+r+1)`; + // with `base_kv = start` that is the causal prefix `[0, start+r]` — + // exactly the per-query `sdpa_decode` loop this replaced, but in a + // single grid (`[n_q_heads*n_query]` workgroups) with no per-row + // host download/upload round-trip. Validated bit-accurate on the + // Vulkan backend by `metaltile-std/tests/vulkan_sdpa_multi.rs` + // (prefill + GQA shapes) and the auto corpus. + ops::kv_append_many(dev, &k, &positions, &self.kcache[l], nkv, hd, cap)?; + ops::kv_append_many(dev, &v, &positions, &self.vcache[l], nkv, hd, cap)?; + let attn = ops::sdpa_multi( + dev, + &q.reshaped(vec![s * nq * hd]), + &self.kcache[l].reshaped(vec![nkv * cap * hd]), + &self.vcache[l].reshaped(vec![nkv * cap * hd]), + hd, + nq as u32, + start as u32, // base_kv + s as u32, // n_query + cap as u32, // kv_stride + cfg.heads_per_group(), + true, // causal + cfg.attn_scale(), + )?; // [s, nq, hd] + let attn_o = attn.reshaped(vec![s, nq * hd]); + let o = q8.wo.gemm(dev, &attn_o, s)?; // [S, hidden] — coopmat GEMM + x = ops::add(dev, &x.reshaped(vec![s * cfg.hidden]), &o.reshaped(vec![s * cfg.hidden]))? + .reshaped(vec![s, cfg.hidden]); + + // ── MLP (SwiGLU) ─────────────────────────────────────────── + let h2 = ops::rms_norm(dev, &x, &w.mlp_norm, cfg.eps)?; // [S, hidden] + let gate = q8.w_gate.gemm(dev, &h2, s)?; // [S, ffn] + let up = q8.w_up.gemm(dev, &h2, s)?; // [S, ffn] + let act = ops::swiglu(dev, &gate, &up)?; // [S, ffn] + let down = q8.w_down.gemm(dev, &act, s)?; // [S, hidden] + x = ops::add(dev, &x.reshaped(vec![s * cfg.hidden]), &down.reshaped(vec![s * cfg.hidden]))? + .reshaped(vec![s, cfg.hidden]); + } + + // Final norm over all rows, then lm_head on the LAST token only (the + // next-token logits). Slice the last hidden row on-device so the lm_head + // stays a single matvec, matching the per-token decode path's final step. + let xn = ops::rms_norm(dev, &x, &self.weights.final_norm, cfg.eps)?; // [S, hidden] + let last_xn = ops::slice(dev, &xn.reshaped(vec![s * cfg.hidden]), (s - 1) * cfg.hidden, cfg.hidden)?; + let logits = q8w.lm_head.matvec(dev, &last_xn)?; + dev.synchronize()?; + let mut bytes = vec![0u8; self.vocab * 4]; + dev.download(logits.buffer.as_ref(), &mut bytes)?; + Ok(bytes.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect()) + } +} diff --git a/rust/crates/ffai-models/src/moe.rs b/rust/crates/ffai-models/src/moe.rs new file mode 100644 index 00000000..37fb1914 --- /dev/null +++ b/rust/crates/ffai-models/src/moe.rs @@ -0,0 +1,103 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! Mixture-of-Experts MLP builder (DeepSeek-V4, GPT-OSS, Granite4, Qwen-MoE). +//! The attention half is the dense transformer (see [`super::llama`]); only +//! the feed-forward is replaced by a routed expert mixture. Built on the +//! shared [`ffai_ops`], so it runs on any backend. +//! +//! Single-token routing is done host-side (download the `n_experts` router +//! logits, pick top-k, softmax) — exact and cheap for one token; a fused +//! GPU router/top-k is a later optimization. The per-expert gate/up/down and +//! SwiGLU run on the device through the same ops the dense MLP uses. + +use ffai_core::{DType, Device, Error, Result, Tensor}; +use ffai_ops as ops; + +/// One expert's SwiGLU MLP weights. +pub struct ExpertWeights { + pub gate: Tensor, // [intermediate, hidden] + pub up: Tensor, // [intermediate, hidden] + pub down: Tensor, // [hidden, intermediate] +} + +/// A routed MoE feed-forward block. +pub struct MoeMlp { + pub router: Tensor, // [n_experts, hidden] + pub experts: Vec, + pub top_k: usize, + /// Normalize the top-k routing weights with a softmax (Qwen/DeepSeek do). + pub norm_topk: bool, +} + +fn to_f32(bytes: &[u8], dt: DType) -> Vec { + match dt { + DType::F32 => bytes.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect(), + DType::BF16 => bytes + .chunks_exact(2) + .map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)) + .collect(), + _ => vec![], + } +} +fn from_f32(vals: &[f32], dt: DType) -> Vec { + match dt { + DType::F32 => vals.iter().flat_map(|x| x.to_le_bytes()).collect(), + DType::BF16 => vals + .iter() + .flat_map(|x| ((x.to_bits() >> 16) as u16).to_le_bytes()) + .collect(), + _ => vec![], + } +} + +/// Run the routed MoE MLP for a single token's hidden vector `h` `[hidden]`. +/// Returns the mixed expert output `[hidden]` (same dtype as `h`). +pub fn moe_mlp(dev: &dyn Device, w: &MoeMlp, h: &Tensor) -> Result { + let n_experts = w.experts.len(); + let hidden = h.elem_count(); + + // Router → logits, read to host for top-k selection. + let logits = ops::gemv(dev, &w.router, h)?; + dev.synchronize()?; + let mut lb = vec![0u8; n_experts * logits.dtype.size_bytes()]; + dev.download(logits.buffer.as_ref(), &mut lb)?; + let lf = to_f32(&lb, logits.dtype); + + // Top-k experts. + let mut order: Vec = (0..n_experts).collect(); + order.sort_by(|&a, &b| lf[b].total_cmp(&lf[a])); + let top: Vec = order.into_iter().take(w.top_k).collect(); + + // Routing weights: softmax over the selected logits (or raw). + let weights: Vec = if w.norm_topk { + let m = top.iter().map(|&i| lf[i]).fold(f32::MIN, f32::max); + let e: Vec = top.iter().map(|&i| (lf[i] - m).exp()).collect(); + let s: f32 = e.iter().sum(); + e.iter().map(|x| x / s).collect() + } else { + top.iter().map(|&i| lf[i]).collect() + }; + + // Accumulate the weighted expert outputs. + let mut acc = vec![0.0f32; hidden]; + for (&e, &gw) in top.iter().zip(&weights) { + let ex = &w.experts[e]; + let gate = ops::gemv(dev, &ex.gate, h)?; + let up = ops::gemv(dev, &ex.up, h)?; + let act = ops::swiglu(dev, &gate, &up)?; + let out = ops::gemv(dev, &ex.down, &act)?; + dev.synchronize()?; + let mut ob = vec![0u8; hidden * out.dtype.size_bytes()]; + dev.download(out.buffer.as_ref(), &mut ob)?; + let of = to_f32(&ob, out.dtype); + for i in 0..hidden { + acc[i] += gw * of[i]; + } + } + + if acc.is_empty() { + return Err(Error::Msg("moe_mlp: no experts".into())); + } + Ok(Tensor::new(dev.upload(&from_f32(&acc, h.dtype))?, vec![hidden], h.dtype)) +} diff --git a/rust/crates/ffai-modeltests/Cargo.toml b/rust/crates/ffai-modeltests/Cargo.toml new file mode 100644 index 00000000..a3cd9005 --- /dev/null +++ b/rust/crates/ffai-modeltests/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ffai-modeltests" +version.workspace = true +edition.workspace = true +license.workspace = true + +# Shared, backend-agnostic model forwards + HF-reference verification. Each +# `verify_*(dev: &dyn Device)` is written ONCE here and called by the thin +# per-backend test wrappers in ffai-metal/ffai-cuda — so a model's logic +# exists in exactly one place instead of a Metal test + a sed'd CUDA twin. +[dependencies] +ffai-core.workspace = true +ffai-ops.workspace = true +ffai-loader.workspace = true +ffai-models.workspace = true +ffai-runtime.workspace = true diff --git a/rust/crates/ffai-modeltests/src/lib.rs b/rust/crates/ffai-modeltests/src/lib.rs new file mode 100644 index 00000000..5db1369e --- /dev/null +++ b/rust/crates/ffai-modeltests/src/lib.rs @@ -0,0 +1,4180 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +//! Shared, backend-agnostic model forwards + HF-reference verification. +//! +//! Each `verify_*(dev: &dyn Device)` holds a model's forward and its HF oracle +//! ONCE. The per-backend test files (`ffai-metal/tests/*`, `ffai-cuda/tests/*`) +//! are thin wrappers that build their device and call these — so a model's +//! logic lives in exactly one place, not a Metal test + a sed'd CUDA twin. +use ffai_core::{Backend, DType, Device, Tensor}; +use ffai_loader::SafeTensors; +use ffai_ops::{add, gelu, gemv, layer_norm, sdpa_decode}; + +fn tb(v: &[f32]) -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() } +fn fb(b: &[u8]) -> Vec { b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect() } + +/// Resolve a model dir from `$ENV` or an HF-cache snapshot glob. +fn model_dir(env: &str, hub: &str) -> Option { + if let Ok(d) = std::env::var(env) { + return Some(d); + } + let base = format!("{}/.cache/huggingface/hub/{hub}/snapshots", std::env::var("HOME").ok()?); + std::fs::read_dir(&base).ok()?.filter_map(|e| e.ok()).next().map(|e| e.path().to_string_lossy().into_owned()) +} + +/// GPT-2-124M single-token forward (LayerNorm-LLM, Conv1D weights, learned-pos, +/// gelu_new, tied) vs HF argmax 198. Runs on whatever `Device` is passed. +pub fn verify_gpt2(d: &dyn Device, plat: &str) { + let dir = model_dir("GPT2_DIR", "models--gpt2").unwrap_or_default(); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + + let (hid, nh, hd, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let conv_t = |w: &[f32], nin: usize, nout: usize| -> Vec { let mut o = vec![0.0f32; nin * nout]; for i in 0..nin { for j in 0..nout { o[j * nin + i] = w[i * nout + j]; } } o }; + + let token = 5usize; + let wte = g("wte.weight"); + let wpe = g("wpe.weight"); + let mut x: Vec = (0..hid).map(|i| wte[token * hid + i] + wpe[i]).collect(); + + for l in 0..n_layers { + let p = format!("h.{l}"); + let h = layer_norm(d, &up(&x, vec![hid]), + &up(&g(&format!("{p}.ln_1.weight")), vec![hid]), + &up(&g(&format!("{p}.ln_1.bias")), vec![hid]), eps).unwrap(); + let cattn_w = conv_t(&g(&format!("{p}.attn.c_attn.weight")), hid, 3 * hid); + let qkv = add(d, &gemv(d, &up(&cattn_w, vec![3 * hid, hid]), &h).unwrap(), + &up(&g(&format!("{p}.attn.c_attn.bias")), vec![3 * hid])).unwrap(); + let qkv = dl(&qkv, 3 * hid); + let q = up(&qkv[0..hid], vec![nh, hd]); + let k = up(&qkv[hid..2 * hid], vec![nh, hd]); + let v = up(&qkv[2 * hid..3 * hid], vec![nh, hd]); + let attn = sdpa_decode(d, &q, &k, &v, hd, 1, 1, 1, scale).unwrap(); + let cproj_w = conv_t(&g(&format!("{p}.attn.c_proj.weight")), hid, hid); + let o = add(d, &gemv(d, &up(&cproj_w, vec![hid, hid]), &attn.reshaped(vec![hid])).unwrap(), + &up(&g(&format!("{p}.attn.c_proj.bias")), vec![hid])).unwrap(); + let o = dl(&o, hid); + for i in 0..hid { x[i] += o[i]; } + + let h2 = layer_norm(d, &up(&x, vec![hid]), + &up(&g(&format!("{p}.ln_2.weight")), vec![hid]), + &up(&g(&format!("{p}.ln_2.bias")), vec![hid]), eps).unwrap(); + let fc_w = conv_t(&g(&format!("{p}.mlp.c_fc.weight")), hid, 4 * hid); + let f = add(d, &gemv(d, &up(&fc_w, vec![4 * hid, hid]), &h2).unwrap(), + &up(&g(&format!("{p}.mlp.c_fc.bias")), vec![4 * hid])).unwrap(); + let act = gelu(d, &f).unwrap(); + let proj_w = conv_t(&g(&format!("{p}.mlp.c_proj.weight")), 4 * hid, hid); + let m = add(d, &gemv(d, &up(&proj_w, vec![hid, 4 * hid]), &act).unwrap(), + &up(&g(&format!("{p}.mlp.c_proj.bias")), vec![hid])).unwrap(); + let m = dl(&m, hid); + for i in 0..hid { x[i] += m[i]; } + } + + let xf = layer_norm(d, &up(&x, vec![hid]), + &up(&g("ln_f.weight"), vec![hid]), &up(&g("ln_f.bias"), vec![hid]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&wte, vec![vocab, hid]), &xf).unwrap(), vocab); + let argmax = ffai_runtime::argmax(&logits); + eprintln!("GPT-2-124M full forward on {plat}: argmax = {argmax} (HF = 198)"); + assert_eq!(argmax, 198, "GPT-2 argmax != HF 198"); + eprintln!("✅ Full real GPT-2 forward matches HF on the shared engine ({plat}) — one shared forward, both backends."); +} + +/// Mamba2-130m single-token forward (SSM: conv1d + SSD scan + gated RMSNorm) +/// vs HF argmax 310. Same shared op layer, any `Device`. +pub fn verify_mamba2(d: &dyn Device, plat: &str) { + use ffai_ops::{conv1d_causal_step, rms_norm, silu, ssm_step}; + let dir = model_dir("MAMBA2_DIR", "models--AntonV--mamba2-130m-hf").unwrap_or_default(); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + + let (hid, di, nh, dh, ds, ng, kc, vocab, eps) = (768usize, 1536usize, 24usize, 64usize, 128usize, 1usize, 4usize, 50288usize, 1e-5f32); + let conv_dim = di + 2 * ng * ds; + let n_layers = 24usize; + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32]| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32) }; + let upm = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let softplus = |x: f32| if x > 20.0 { x } else { (1.0 + x.exp()).ln() }; + + let token = 5usize; + let embed = g("backbone.embeddings.weight"); + let mut x: Vec = embed[token * hid..(token + 1) * hid].to_vec(); + + for l in 0..n_layers { + let p = format!("backbone.layers.{l}"); + let xn = rms_norm(d, &up(&x), &up(&g(&format!("{p}.norm.weight"))), eps).unwrap(); + let in_proj = upm(&g(&format!("{p}.mixer.in_proj.weight")), vec![3352, hid]); + let proj = dl(&gemv(d, &in_proj, &xn).unwrap(), 3352); + let z = &proj[0..di]; + let xbc = &proj[di..di + conv_dim]; + let dt_raw = &proj[di + conv_dim..di + conv_dim + nh]; + let cw_hf = g(&format!("{p}.mixer.conv1d.weight")); + let mut cw = vec![0.0f32; kc * conv_dim]; + for ch in 0..conv_dim { for k in 0..kc { cw[k * conv_dim + ch] = cw_hf[ch * kc + k]; } } + let cb = g(&format!("{p}.mixer.conv1d.bias")); + let state0 = vec![0.0f32; (kc - 1) * conv_dim]; + let yc = conv1d_causal_step(d, &up(xbc), &up(&cw), &up(&cb), &up(&state0), conv_dim as u32, kc as u32).unwrap(); + let xbc_act = dl(&silu(d, &yc).unwrap(), conv_dim); + let x_ssm = &xbc_act[0..di]; + let bmat = &xbc_act[di..di + ng * ds]; + let cmat = &xbc_act[di + ng * ds..di + 2 * ng * ds]; + let dt_bias = g(&format!("{p}.mixer.dt_bias")); + let dt: Vec = (0..nh).map(|i| softplus(dt_raw[i] + dt_bias[i])).collect(); + let a_log = g(&format!("{p}.mixer.A_log")); + let dsk = g(&format!("{p}.mixer.D")); + let state_in = vec![0.0f32; nh * dh * ds]; + let (_so, y_t) = ssm_step(d, &up(x_ssm), &up(&a_log), &up(bmat), &up(cmat), &up(&dsk), &up(&dt), &up(&state_in), dh as u32, ds as u32, nh as u32, (nh / ng) as u32).unwrap(); + let y = dl(&y_t, di); + let sz = dl(&silu(d, &up(z)).unwrap(), di); + let y_gated: Vec = (0..di).map(|i| y[i] * sz[i]).collect(); + let y_normed = rms_norm(d, &up(&y_gated), &up(&g(&format!("{p}.mixer.norm.weight"))), eps).unwrap(); + let out_proj = upm(&g(&format!("{p}.mixer.out_proj.weight")), vec![hid, di]); + let out = dl(&gemv(d, &out_proj, &y_normed).unwrap(), hid); + for i in 0..hid { x[i] += out[i]; } + } + + let xf = rms_norm(d, &up(&x), &up(&g("backbone.norm_f.weight")), eps).unwrap(); + let lm = upm(&embed, vec![vocab, hid]); + let logits = dl(&gemv(d, &lm, &xf).unwrap(), vocab); + let argmax = ffai_runtime::argmax(&logits); + eprintln!("Mamba2-130m full forward on {plat}: argmax = {argmax} (HF = 310)"); + assert_eq!(argmax, 310, "Mamba2 argmax != HF 310"); + eprintln!("✅ Full real Mamba2-130m forward matches HF on the shared engine ({plat})."); +} + +/// Run the whole shared model suite against any backend. A new backend (ROCm, +/// Vulkan, …) implements `Device`, then calls this from one test file — and +/// inherits every model with zero model code. +pub fn run_all(d: &dyn Device, plat: &str) { + verify_gpt2(d, plat); + verify_pythia(d, plat); + verify_gptneo(d, plat); + verify_olmo2(d, plat); + verify_gemma2(d, plat); + verify_phi(d, plat); + verify_stablelm2(d, plat); + verify_olmoe(d, plat); + verify_mamba2(d, plat); + verify_falcon_h1(d, plat); +} + +// exact-erf GELU (Abramowitz-Stegun) — shared by GPT-NeoX / Whisper-style nets +fn erf(x: f32) -> f32 { + let s = x.signum(); let x = x.abs(); + let t = 1.0 / (1.0 + 0.3275911 * x); + let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * (-x * x).exp(); + s * y +} +fn gelu_erf(x: f32) -> f32 { 0.5 * x * (1.0 + erf(x * std::f32::consts::FRAC_1_SQRT_2)) } + +/// Pythia-160m (GPT-NeoX): parallel residual, interleaved per-head QKV, partial +/// rotary (identity@pos0), exact-erf GELU. vs HF argmax 285. +pub fn verify_pythia(d: &dyn Device, plat: &str) { + let dir = model_dir("PYTHIA_DIR", "models--EleutherAI--pythia-160m").unwrap_or_default(); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let (hid, nh, hd, inter, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 3072usize, 12usize, 50304usize, 1e-5f32); + let scale = 1.0 / (hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let token = 5usize; + let embed = g("gpt_neox.embed_in.weight"); + let mut x: Vec = embed[token * hid..(token + 1) * hid].to_vec(); + for l in 0..n_layers { + let p = format!("gpt_neox.layers.{l}"); + let ln1 = layer_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.input_layernorm.weight")), vec![hid]), &up(&g(&format!("{p}.input_layernorm.bias")), vec![hid]), eps).unwrap(); + let qkv = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.attention.query_key_value.weight")), vec![3*hid, hid]), &ln1).unwrap(), &up(&g(&format!("{p}.attention.query_key_value.bias")), vec![3*hid])).unwrap(), 3*hid); + let (mut q, mut k, mut v) = (vec![0.0f32; hid], vec![0.0f32; hid], vec![0.0f32; hid]); + for h in 0..nh { for dd in 0..hd { q[h*hd+dd]=qkv[h*3*hd+dd]; k[h*hd+dd]=qkv[h*3*hd+hd+dd]; v[h*hd+dd]=qkv[h*3*hd+2*hd+dd]; }} + let attn = sdpa_decode(d, &up(&q, vec![nh, hd]), &up(&k, vec![nh, hd]), &up(&v, vec![nh, hd]), hd, 1, 1, 1, scale).unwrap(); + let ao = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.attention.dense.weight")), vec![hid, hid]), &attn.reshaped(vec![hid])).unwrap(), &up(&g(&format!("{p}.attention.dense.bias")), vec![hid])).unwrap(), hid); + let ln2 = layer_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.post_attention_layernorm.weight")), vec![hid]), &up(&g(&format!("{p}.post_attention_layernorm.bias")), vec![hid]), eps).unwrap(); + let mut h1 = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.mlp.dense_h_to_4h.weight")), vec![inter, hid]), &ln2).unwrap(), &up(&g(&format!("{p}.mlp.dense_h_to_4h.bias")), vec![inter])).unwrap(), inter); + for vv in h1.iter_mut() { *vv = gelu_erf(*vv); } + let m = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.mlp.dense_4h_to_h.weight")), vec![hid, inter]), &up(&h1, vec![inter])).unwrap(), &up(&g(&format!("{p}.mlp.dense_4h_to_h.bias")), vec![hid])).unwrap(), hid); + for i in 0..hid { x[i] += ao[i] + m[i]; } + } + let xf = layer_norm(d, &up(&x, vec![hid]), &up(&g("gpt_neox.final_layer_norm.weight"), vec![hid]), &up(&g("gpt_neox.final_layer_norm.bias"), vec![hid]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&g("embed_out.weight"), vec![vocab, hid]), &xf).unwrap(), vocab); + let argmax = ffai_runtime::argmax(&logits); + eprintln!("Pythia-160m on {plat}: argmax = {argmax} (HF = 285)"); + assert_eq!(argmax, 285, "Pythia argmax != HF 285"); + eprintln!("✅ Pythia-160m matches HF ({plat})."); +} + +/// OLMo-2-1B: post-norm placement + qk-norm over full proj, SwiGLU. vs HF top-3 [198,8,13]. +pub fn verify_olmo2(d: &dyn Device, plat: &str) { + use ffai_ops::{rms_norm, swiglu}; + let dir = model_dir("OLMO2_DIR", "models--allenai--OLMo-2-0425-1B").unwrap_or_default(); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, nq, nkv, hd, inter, n_layers, vocab, eps) = (2048usize, 16usize, 16usize, 128usize, 8192usize, 16usize, 100352usize, 1e-6f32); + let (qdim, kvdim) = (nq*hd, nkv*hd); let scale = 1.0/(hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let token = 9707usize; + let embed = g("model.embed_tokens.weight"); + let mut x: Vec = embed[token*hid..(token+1)*hid].to_vec(); + for l in 0..n_layers { + let p = format!("model.layers.{l}"); + let xin = up(&x, vec![hid]); + let q = gemv(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![qdim, hid]), &xin).unwrap(); + let k = gemv(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![kvdim, hid]), &xin).unwrap(); + let v = gemv(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![kvdim, hid]), &xin).unwrap(); + let q = rms_norm(d, &q, &up(&g(&format!("{p}.self_attn.q_norm.weight")), vec![qdim]), eps).unwrap(); + let k = rms_norm(d, &k, &up(&g(&format!("{p}.self_attn.k_norm.weight")), vec![kvdim]), eps).unwrap(); + let attn = sdpa_decode(d, &q.reshaped(vec![nq, hd]), &k.reshaped(vec![nkv, hd]), &v.reshaped(vec![nkv, hd]), hd, 1, 1, (nq/nkv) as u32, scale).unwrap(); + let o = gemv(d, &up(&g(&format!("{p}.self_attn.o_proj.weight")), vec![hid, qdim]), &attn.reshaped(vec![qdim])).unwrap(); + let o = dl(&rms_norm(d, &o, &up(&g(&format!("{p}.post_attention_layernorm.weight")), vec![hid]), eps).unwrap(), hid); + for i in 0..hid { x[i] += o[i]; } + let xin2 = up(&x, vec![hid]); + let gate = gemv(d, &up(&g(&format!("{p}.mlp.gate_proj.weight")), vec![inter, hid]), &xin2).unwrap(); + let upp = gemv(d, &up(&g(&format!("{p}.mlp.up_proj.weight")), vec![inter, hid]), &xin2).unwrap(); + let act = swiglu(d, &gate, &upp).unwrap(); + let down = gemv(d, &up(&g(&format!("{p}.mlp.down_proj.weight")), vec![hid, inter]), &act).unwrap(); + let down = dl(&rms_norm(d, &down, &up(&g(&format!("{p}.post_feedforward_layernorm.weight")), vec![hid]), eps).unwrap(), hid); + for i in 0..hid { x[i] += down[i]; } + } + let xf = rms_norm(d, &up(&x, vec![hid]), &up(&g("model.norm.weight"), vec![hid]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&g("lm_head.weight"), vec![vocab, hid]), &xf).unwrap(), vocab); + let idx = ffai_runtime::topk(&logits, 3); + eprintln!("OLMo-2-1B on {plat}: top3 = {:?} (HF = [198,8,13])", &idx[..3]); + assert_eq!(&idx[..3], &[198usize, 8, 13], "OLMo-2 top-3 != HF"); + eprintln!("✅ OLMo-2-1B matches HF ({plat})."); +} + +/// GPT-Neo-125M: learned-pos + LayerNorm, separate q/k/v (no bias), no attn +/// scaling, gelu_new, tied. vs HF top-3 [28,59,91]. +pub fn verify_gptneo(d: &dyn Device, plat: &str) { + let dir = model_dir("GPTNEO_DIR", "models--EleutherAI--gpt-neo-125m").unwrap_or_default(); + let path = format!("{dir}/model.safetensors"); + let Ok(st) = SafeTensors::open(&path) else { eprintln!("no model at {path} — skipping"); return; }; + let (hid, nh, hd, inter, n_layers, vocab, eps) = (768usize, 12usize, 64usize, 3072usize, 12usize, 50257usize, 1e-5f32); + let scale = 1.0f32; + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let token = 5usize; + let wte = g("transformer.wte.weight"); let wpe = g("transformer.wpe.weight"); + let mut x: Vec = (0..hid).map(|i| wte[token*hid+i] + wpe[i]).collect(); + for l in 0..n_layers { + let p = format!("transformer.h.{l}"); + let h = layer_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.ln_1.weight")), vec![hid]), &up(&g(&format!("{p}.ln_1.bias")), vec![hid]), eps).unwrap(); + let q = gemv(d, &up(&g(&format!("{p}.attn.attention.q_proj.weight")), vec![hid, hid]), &h).unwrap(); + let k = gemv(d, &up(&g(&format!("{p}.attn.attention.k_proj.weight")), vec![hid, hid]), &h).unwrap(); + let v = gemv(d, &up(&g(&format!("{p}.attn.attention.v_proj.weight")), vec![hid, hid]), &h).unwrap(); + let attn = sdpa_decode(d, &q.reshaped(vec![nh, hd]), &k.reshaped(vec![nh, hd]), &v.reshaped(vec![nh, hd]), hd, 1, 1, 1, scale).unwrap(); + let o = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.attn.attention.out_proj.weight")), vec![hid, hid]), &attn.reshaped(vec![hid])).unwrap(), &up(&g(&format!("{p}.attn.attention.out_proj.bias")), vec![hid])).unwrap(), hid); + for i in 0..hid { x[i] += o[i]; } + let h2 = layer_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.ln_2.weight")), vec![hid]), &up(&g(&format!("{p}.ln_2.bias")), vec![hid]), eps).unwrap(); + let f = add(d, &gemv(d, &up(&g(&format!("{p}.mlp.c_fc.weight")), vec![inter, hid]), &h2).unwrap(), &up(&g(&format!("{p}.mlp.c_fc.bias")), vec![inter])).unwrap(); + let act = gelu(d, &f).unwrap(); + let m = dl(&add(d, &gemv(d, &up(&g(&format!("{p}.mlp.c_proj.weight")), vec![hid, inter]), &act).unwrap(), &up(&g(&format!("{p}.mlp.c_proj.bias")), vec![hid])).unwrap(), hid); + for i in 0..hid { x[i] += m[i]; } + } + let xf = layer_norm(d, &up(&x, vec![hid]), &up(&g("transformer.ln_f.weight"), vec![hid]), &up(&g("transformer.ln_f.bias"), vec![hid]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&wte, vec![vocab, hid]), &xf).unwrap(), vocab); + let idx = ffai_runtime::topk(&logits, 3); + eprintln!("GPT-Neo-125M on {plat}: top3 = {:?} (HF = [28,59,91])", &idx[..3]); + assert_eq!(&idx[..3], &[28usize, 59, 91], "GPT-Neo top-3 != HF"); + eprintln!("✅ GPT-Neo-125M matches HF ({plat})."); +} + +/// Gemma-2-2b: √hidden embed-scale, RMSNorm(1+w), 4 norms/layer, geGLU, GQA +/// hd256, softcaps (argmax-invariant at 1 pos). vs HF top-3 [9707,235265,110]. +pub fn verify_gemma2(d: &dyn Device, plat: &str) { + use ffai_ops::{mul, rms_norm}; + let dir = model_dir("GEMMA2_DIR", "models--unsloth--gemma-2-2b-it").unwrap_or_default(); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, nq, nkv, hd, inter, n_layers, vocab, eps) = (2304usize, 8usize, 4usize, 256usize, 9216usize, 26usize, 256000usize, 1e-6f32); + let (qdim, kvdim) = (nq*hd, nkv*hd); let scale = 1.0/(256.0f32).sqrt(); let embed_scale = (hid as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let g1 = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0.iter().map(|w| w+1.0).collect() }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let token = 9707usize; + let embed = g("model.embed_tokens.weight"); + let mut x: Vec = embed[token*hid..(token+1)*hid].iter().map(|v| v*embed_scale).collect(); + for l in 0..n_layers { + let p = format!("model.layers.{l}"); + let h = rms_norm(d, &up(&x, vec![hid]), &up(&g1(&format!("{p}.input_layernorm.weight")), vec![hid]), eps).unwrap(); + let q = gemv(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![qdim, hid]), &h).unwrap(); + let k = gemv(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![kvdim, hid]), &h).unwrap(); + let v = gemv(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![kvdim, hid]), &h).unwrap(); + let attn = sdpa_decode(d, &q.reshaped(vec![nq, hd]), &k.reshaped(vec![nkv, hd]), &v.reshaped(vec![nkv, hd]), hd, 1, 1, (nq/nkv) as u32, scale).unwrap(); + let o = gemv(d, &up(&g(&format!("{p}.self_attn.o_proj.weight")), vec![hid, qdim]), &attn.reshaped(vec![qdim])).unwrap(); + let o = dl(&rms_norm(d, &o, &up(&g1(&format!("{p}.post_attention_layernorm.weight")), vec![hid]), eps).unwrap(), hid); + for i in 0..hid { x[i] += o[i]; } + let h2 = rms_norm(d, &up(&x, vec![hid]), &up(&g1(&format!("{p}.pre_feedforward_layernorm.weight")), vec![hid]), eps).unwrap(); + let gate = gelu(d, &gemv(d, &up(&g(&format!("{p}.mlp.gate_proj.weight")), vec![inter, hid]), &h2).unwrap()).unwrap(); + let upp = gemv(d, &up(&g(&format!("{p}.mlp.up_proj.weight")), vec![inter, hid]), &h2).unwrap(); + let act = mul(d, &gate, &upp).unwrap(); + let down = gemv(d, &up(&g(&format!("{p}.mlp.down_proj.weight")), vec![hid, inter]), &act).unwrap(); + let down = dl(&rms_norm(d, &down, &up(&g1(&format!("{p}.post_feedforward_layernorm.weight")), vec![hid]), eps).unwrap(), hid); + for i in 0..hid { x[i] += down[i]; } + } + let xf = rms_norm(d, &up(&x, vec![hid]), &up(&g1("model.norm.weight"), vec![hid]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&embed, vec![vocab, hid]), &xf).unwrap(), vocab); + let idx = ffai_runtime::topk(&logits, 3); + eprintln!("Gemma-2-2b on {plat}: top3 = {:?} (HF = [9707,235265,110])", &idx[..3]); + assert_eq!(&idx[..3], &[9707usize, 235265, 110], "Gemma top-3 != HF"); + eprintln!("✅ Gemma-2-2b matches HF ({plat})."); +} + +/// Phi-1.5: single shared norm → parallel attn+MLP, separate q/k/v+bias, +/// partial rotary, gelu_new. vs HF top-3 [11,13,546]. +pub fn verify_phi(d: &dyn Device, plat: &str) { + let dir = model_dir("PHI_DIR", "models--microsoft--phi-1_5").unwrap_or_default(); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, nh, hd, inter, n_layers, vocab, eps) = (2048usize, 32usize, 64usize, 8192usize, 24usize, 51200usize, 1e-5f32); + let scale = 1.0/(hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let proj = |w: &str, b: &str, x: &Tensor, m: usize, inn: usize| -> Tensor { + add(d, &gemv(d, &up(&g(w), vec![m, inn]), x).unwrap(), &up(&g(b), vec![m])).unwrap() }; + let token = 9707usize; + let embed = g("model.embed_tokens.weight"); + let mut x: Vec = embed[token*hid..(token+1)*hid].to_vec(); + for l in 0..n_layers { + let p = format!("model.layers.{l}"); + let h = layer_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.input_layernorm.weight")), vec![hid]), &up(&g(&format!("{p}.input_layernorm.bias")), vec![hid]), eps).unwrap(); + let q = proj(&format!("{p}.self_attn.q_proj.weight"), &format!("{p}.self_attn.q_proj.bias"), &h, hid, hid); + let k = proj(&format!("{p}.self_attn.k_proj.weight"), &format!("{p}.self_attn.k_proj.bias"), &h, hid, hid); + let v = proj(&format!("{p}.self_attn.v_proj.weight"), &format!("{p}.self_attn.v_proj.bias"), &h, hid, hid); + let attn = sdpa_decode(d, &q.reshaped(vec![nh, hd]), &k.reshaped(vec![nh, hd]), &v.reshaped(vec![nh, hd]), hd, 1, 1, 1, scale).unwrap(); + let o = dl(&proj(&format!("{p}.self_attn.dense.weight"), &format!("{p}.self_attn.dense.bias"), &attn.reshaped(vec![hid]), hid, hid), hid); + let f = proj(&format!("{p}.mlp.fc1.weight"), &format!("{p}.mlp.fc1.bias"), &h, inter, hid); + let act = gelu(d, &f).unwrap(); + let m = dl(&proj(&format!("{p}.mlp.fc2.weight"), &format!("{p}.mlp.fc2.bias"), &act, hid, inter), hid); + for i in 0..hid { x[i] += o[i] + m[i]; } + } + let xf = layer_norm(d, &up(&x, vec![hid]), &up(&g("model.final_layernorm.weight"), vec![hid]), &up(&g("model.final_layernorm.bias"), vec![hid]), eps).unwrap(); + let logits = dl(&add(d, &gemv(d, &up(&g("lm_head.weight"), vec![vocab, hid]), &xf).unwrap(), &up(&g("lm_head.bias"), vec![vocab])).unwrap(), vocab); + let idx = ffai_runtime::topk(&logits, 3); + eprintln!("Phi-1.5 on {plat}: top3 = {:?} (HF = [11,13,546])", &idx[..3]); + assert_eq!(&idx[..3], &[11usize, 13, 546], "Phi top-3 != HF"); + eprintln!("✅ Phi-1.5 matches HF ({plat})."); +} + +/// StableLM-2-1.6B: LayerNorm(+bias), q/k/v bias, partial rotary, SwiGLU. +/// vs HF top-3 [341,11,280]. +pub fn verify_stablelm2(d: &dyn Device, plat: &str) { + use ffai_ops::swiglu; + let dir = model_dir("STABLELM2_DIR", "models--stabilityai--stablelm-2-1_6b").unwrap_or_default(); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, nh, hd, inter, n_layers, vocab, eps) = (2048usize, 32usize, 64usize, 5632usize, 24usize, 100352usize, 1e-5f32); + let scale = 1.0/(hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let bproj = |w: &str, b: &str, x: &Tensor, m: usize| -> Tensor { add(d, &gemv(d, &up(&g(w), vec![m, hid]), x).unwrap(), &up(&g(b), vec![m])).unwrap() }; + let token = 9707usize; + let embed = g("model.embed_tokens.weight"); + let mut x: Vec = embed[token*hid..(token+1)*hid].to_vec(); + for l in 0..n_layers { + let p = format!("model.layers.{l}"); + let h = layer_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.input_layernorm.weight")), vec![hid]), &up(&g(&format!("{p}.input_layernorm.bias")), vec![hid]), eps).unwrap(); + let q = bproj(&format!("{p}.self_attn.q_proj.weight"), &format!("{p}.self_attn.q_proj.bias"), &h, hid); + let k = bproj(&format!("{p}.self_attn.k_proj.weight"), &format!("{p}.self_attn.k_proj.bias"), &h, hid); + let v = bproj(&format!("{p}.self_attn.v_proj.weight"), &format!("{p}.self_attn.v_proj.bias"), &h, hid); + let attn = sdpa_decode(d, &q.reshaped(vec![nh, hd]), &k.reshaped(vec![nh, hd]), &v.reshaped(vec![nh, hd]), hd, 1, 1, 1, scale).unwrap(); + let o = dl(&gemv(d, &up(&g(&format!("{p}.self_attn.o_proj.weight")), vec![hid, hid]), &attn.reshaped(vec![hid])).unwrap(), hid); + for i in 0..hid { x[i] += o[i]; } + let h2 = layer_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.post_attention_layernorm.weight")), vec![hid]), &up(&g(&format!("{p}.post_attention_layernorm.bias")), vec![hid]), eps).unwrap(); + let gate = gemv(d, &up(&g(&format!("{p}.mlp.gate_proj.weight")), vec![inter, hid]), &h2).unwrap(); + let upp = gemv(d, &up(&g(&format!("{p}.mlp.up_proj.weight")), vec![inter, hid]), &h2).unwrap(); + let act = swiglu(d, &gate, &upp).unwrap(); + let m = dl(&gemv(d, &up(&g(&format!("{p}.mlp.down_proj.weight")), vec![hid, inter]), &act).unwrap(), hid); + for i in 0..hid { x[i] += m[i]; } + } + let xf = layer_norm(d, &up(&x, vec![hid]), &up(&g("model.norm.weight"), vec![hid]), &up(&g("model.norm.bias"), vec![hid]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&g("lm_head.weight"), vec![vocab, hid]), &xf).unwrap(), vocab); + let idx = ffai_runtime::topk(&logits, 3); + eprintln!("StableLM-2-1.6B on {plat}: top3 = {:?} (HF = [341,11,280])", &idx[..3]); + assert_eq!(&idx[..3], &[341usize, 11, 280], "StableLM-2 top-3 != HF"); + eprintln!("✅ StableLM-2-1.6B matches HF ({plat})."); +} + +/// OLMoE-1B-7B: 64-expert MoE, top-8 no-renorm, no shared expert, qk-norm over +/// full proj, MHA hd128. vs HF argmax 310. +pub fn verify_olmoe(d: &dyn Device, plat: &str) { + use ffai_ops::{rms_norm, swiglu}; + let dir = model_dir("OLMOE_DIR", "models--allenai--OLMoE-1B-7B-0924").unwrap_or_default(); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, nh, hd, inter, n_exp, top_k, n_layers, vocab, eps) = (2048usize, 16usize, 128usize, 1024usize, 64usize, 8usize, 16usize, 50304usize, 1e-5f32); + let scale = 1.0/(hd as f32).sqrt(); + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32]| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32) }; + let upm = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let token = 5usize; + let embed = g("model.embed_tokens.weight"); + let mut x: Vec = embed[token*hid..(token+1)*hid].to_vec(); + for l in 0..n_layers { + let p = format!("model.layers.{l}"); + let xn = rms_norm(d, &up(&x), &up(&g(&format!("{p}.input_layernorm.weight"))), eps).unwrap(); + let q = gemv(d, &upm(&g(&format!("{p}.self_attn.q_proj.weight")), vec![hid, hid]), &xn).unwrap(); + let k = gemv(d, &upm(&g(&format!("{p}.self_attn.k_proj.weight")), vec![hid, hid]), &xn).unwrap(); + let v = gemv(d, &upm(&g(&format!("{p}.self_attn.v_proj.weight")), vec![hid, hid]), &xn).unwrap(); + let q = rms_norm(d, &q, &up(&g(&format!("{p}.self_attn.q_norm.weight"))), eps).unwrap(); + let k = rms_norm(d, &k, &up(&g(&format!("{p}.self_attn.k_norm.weight"))), eps).unwrap(); + let attn = sdpa_decode(d, &q.reshaped(vec![nh, hd]), &k.reshaped(vec![nh, hd]), &v.reshaped(vec![nh, hd]), hd, 1, 1, 1, scale).unwrap(); + let o = dl(&gemv(d, &upm(&g(&format!("{p}.self_attn.o_proj.weight")), vec![hid, hid]), &attn.reshaped(vec![nh*hd])).unwrap(), hid); + for i in 0..hid { x[i] += o[i]; } + let xn2 = rms_norm(d, &up(&x), &up(&g(&format!("{p}.post_attention_layernorm.weight"))), eps).unwrap(); + let rl = dl(&gemv(d, &upm(&g(&format!("{p}.mlp.gate.weight")), vec![n_exp, hid]), &xn2).unwrap(), n_exp); + let mx = rl.iter().cloned().fold(f32::MIN, f32::max); + let exps: Vec = rl.iter().map(|&z| (z-mx).exp()).collect(); + let sum: f32 = exps.iter().sum(); + let probs: Vec = exps.iter().map(|&e| e/sum).collect(); + let eidx = ffai_runtime::topk(&probs, top_k); + let mut acc = vec![0.0f32; hid]; + for &e in &eidx { + let w = probs[e]; let ep = format!("{p}.mlp.experts.{e}"); + let ge = gemv(d, &upm(&g(&format!("{ep}.gate_proj.weight")), vec![inter, hid]), &xn2).unwrap(); + let ue = gemv(d, &upm(&g(&format!("{ep}.up_proj.weight")), vec![inter, hid]), &xn2).unwrap(); + let act = swiglu(d, &ge, &ue).unwrap(); + let de = dl(&gemv(d, &upm(&g(&format!("{ep}.down_proj.weight")), vec![hid, inter]), &act).unwrap(), hid); + for i in 0..hid { acc[i] += w*de[i]; } + } + for i in 0..hid { x[i] += acc[i]; } + } + let xf = rms_norm(d, &up(&x), &up(&g("model.norm.weight")), eps).unwrap(); + let logits = dl(&gemv(d, &upm(&g("lm_head.weight"), vec![vocab, hid]), &xf).unwrap(), vocab); + let argmax = ffai_runtime::argmax(&logits); + eprintln!("OLMoE-1B-7B on {plat}: argmax = {argmax} (HF = 310)"); + assert_eq!(argmax, 310, "OLMoE argmax != HF 310"); + eprintln!("✅ OLMoE-1B-7B (64-expert MoE) matches HF ({plat})."); +} + +/// Falcon-H1-0.5B: hybrid Mamba2 mixer ∥ GQA attention per layer + µP. vs HF +/// top-3 [593,531,587]. (token 5 is a reserved zero-embedding slot — use 9707.) +pub fn verify_falcon_h1(d: &dyn Device, plat: &str) { + use ffai_ops::{conv1d_causal_step, rms_norm, silu, ssm_step}; + let dir = model_dir("FALCON_H1_DIR", "models--tiiuae--Falcon-H1-0.5B-Base").unwrap_or_default(); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, nq, nkv, ahd, inter, n_layers, vocab, eps) = (1024usize, 8usize, 2usize, 64usize, 2048usize, 36usize, 32784usize, 1e-5f32); + let (d_ssm, m_nh, m_dh, d_state, n_groups, d_conv) = (1536usize, 24usize, 64usize, 128usize, 1usize, 4usize); + let conv_dim = d_ssm + 2*n_groups*d_state; let proj_dim = 2*d_ssm + 2*n_groups*d_state + m_nh; + let ascale = 1.0/(ahd as f32).sqrt(); + let (ssm_in, ssm_out, attn_out) = (1.25f32, 0.23570226039551587f32, 0.9375f32); + let (gate_mult, down_mult, embed_mult) = (0.8838834764831844f32, 0.5859375f32, 5.656854249492381f32); + let ssm_m = [0.3535533905932738f32, 0.25, 0.3535533905932738, 0.5, 0.3535533905932738]; + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let softplus = |x: f32| if x > 20.0 { x } else { (1.0 + x.exp()).ln() }; + let mut mup = vec![0.0f32; proj_dim]; + for i in 0..proj_dim { + let m = if i < d_ssm { ssm_m[0] } else if i < 2*d_ssm { ssm_m[1] } + else if i < 2*d_ssm + n_groups*d_state { ssm_m[2] } + else if i < 2*d_ssm + 2*n_groups*d_state { ssm_m[3] } else { ssm_m[4] }; + mup[i] = m * ssm_in; + } + let token = 9707usize; + let embed = g("model.embed_tokens.weight"); + let mut x: Vec = embed[token*hid..(token+1)*hid].iter().map(|v| v*embed_mult).collect(); + for l in 0..n_layers { + let p = format!("model.layers.{l}"); + let h = rms_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.input_layernorm.weight")), vec![hid]), eps).unwrap(); + let mut proj = dl(&gemv(d, &up(&g(&format!("{p}.mamba.in_proj.weight")), vec![proj_dim, hid]), &h).unwrap(), proj_dim); + for i in 0..proj_dim { proj[i] *= mup[i]; } + let z = &proj[0..d_ssm]; let xbc = &proj[d_ssm..d_ssm+conv_dim]; let dt_raw = &proj[d_ssm+conv_dim..proj_dim]; + let cw_hf = g(&format!("{p}.mamba.conv1d.weight")); + let mut cw = vec![0.0f32; d_conv*conv_dim]; + for ch in 0..conv_dim { for k in 0..d_conv { cw[k*conv_dim+ch] = cw_hf[ch*d_conv+k]; } } + let cb = g(&format!("{p}.mamba.conv1d.bias")); + let state0 = vec![0.0f32; (d_conv-1)*conv_dim]; + let yc = conv1d_causal_step(d, &up(xbc, vec![conv_dim]), &up(&cw, vec![d_conv, conv_dim]), &up(&cb, vec![conv_dim]), &up(&state0, vec![(d_conv-1)*conv_dim]), conv_dim as u32, d_conv as u32).unwrap(); + let xbc_act = dl(&silu(d, &yc).unwrap(), conv_dim); + let x_ssm = &xbc_act[0..d_ssm]; let bmat = &xbc_act[d_ssm..d_ssm+n_groups*d_state]; let cmat = &xbc_act[d_ssm+n_groups*d_state..conv_dim]; + let dt_bias = g(&format!("{p}.mamba.dt_bias")); + let dt: Vec = (0..m_nh).map(|i| softplus(dt_raw[i]+dt_bias[i])).collect(); + let a_log = g(&format!("{p}.mamba.A_log")); let dsk = g(&format!("{p}.mamba.D")); + let state_in = vec![0.0f32; m_nh*m_dh*d_state]; + let (_so, y_t) = ssm_step(d, &up(x_ssm, vec![d_ssm]), &up(&a_log, vec![m_nh]), &up(bmat, vec![n_groups*d_state]), &up(cmat, vec![n_groups*d_state]), &up(&dsk, vec![m_nh]), &up(&dt, vec![m_nh]), &up(&state_in, vec![m_nh*m_dh*d_state]), m_dh as u32, d_state as u32, m_nh as u32, (m_nh/n_groups) as u32).unwrap(); + let y = dl(&y_t, d_ssm); + let sz = dl(&silu(d, &up(z, vec![d_ssm])).unwrap(), d_ssm); + let scan: Vec = (0..d_ssm).map(|i| y[i]*sz[i]).collect(); + let mamba_out = dl(&gemv(d, &up(&g(&format!("{p}.mamba.out_proj.weight")), vec![hid, d_ssm]), &up(&scan, vec![d_ssm])).unwrap(), hid); + let q = gemv(d, &up(&g(&format!("{p}.self_attn.q_proj.weight")), vec![nq*ahd, hid]), &h).unwrap(); + let k = gemv(d, &up(&g(&format!("{p}.self_attn.k_proj.weight")), vec![nkv*ahd, hid]), &h).unwrap(); + let v = gemv(d, &up(&g(&format!("{p}.self_attn.v_proj.weight")), vec![nkv*ahd, hid]), &h).unwrap(); + let attn = sdpa_decode(d, &q.reshaped(vec![nq, ahd]), &k.reshaped(vec![nkv, ahd]), &v.reshaped(vec![nkv, ahd]), ahd, 1, 1, (nq/nkv) as u32, ascale).unwrap(); + let attn_out_v = dl(&gemv(d, &up(&g(&format!("{p}.self_attn.o_proj.weight")), vec![hid, nq*ahd]), &attn.reshaped(vec![nq*ahd])).unwrap(), hid); + for i in 0..hid { x[i] += mamba_out[i]*ssm_out + attn_out_v[i]*attn_out; } + let h2 = rms_norm(d, &up(&x, vec![hid]), &up(&g(&format!("{p}.pre_ff_layernorm.weight")), vec![hid]), eps).unwrap(); + let gate_w: Vec = g(&format!("{p}.feed_forward.gate_proj.weight")).iter().map(|w| w*gate_mult).collect(); + let gate = silu(d, &gemv(d, &up(&gate_w, vec![inter, hid]), &h2).unwrap()).unwrap(); + let upp = gemv(d, &up(&g(&format!("{p}.feed_forward.up_proj.weight")), vec![inter, hid]), &h2).unwrap(); + let act = dl(&gate, inter).iter().zip(dl(&upp, inter)).map(|(gg, uu)| gg*uu).collect::>(); + let ff = dl(&gemv(d, &up(&g(&format!("{p}.feed_forward.down_proj.weight")), vec![hid, inter]), &up(&act, vec![inter])).unwrap(), hid); + for i in 0..hid { x[i] += ff[i]*down_mult; } + } + let xf = rms_norm(d, &up(&x, vec![hid]), &up(&g("model.final_layernorm.weight"), vec![hid]), eps).unwrap(); + let logits = dl(&gemv(d, &up(&g("lm_head.weight"), vec![vocab, hid]), &xf).unwrap(), vocab); + let idx = ffai_runtime::topk(&logits, 3); + eprintln!("Falcon-H1-0.5B on {plat}: top3 = {:?} (HF = [593,531,587])", &idx[..3]); + assert_eq!(&idx[..3], &[593usize, 531, 587], "Falcon-H1 top-3 != HF"); + eprintln!("✅ Falcon-H1-0.5B (hybrid Mamba2+attn) matches HF ({plat})."); +} + +/// NemotronH-Nano-Omni-30B-A3B (text backbone): 52-layer M/E/* hybrid. +/// pattern MEMEM*EMEMEM*… (M=Mamba2 mixer, E=128-expert MoE relu², *=GQA attn). +/// Single mixer per layer, pre-norm residual. BF16 weights → F32 on-device. +/// Verified against an HF-transformers CPU oracle (set NEMOTRON_ARGMAX). +pub fn verify_nemotron(d: &dyn Device, plat: &str) { + use ffai_ops::{conv1d_causal_step, rms_norm, rope_llama, silu, ssm_step}; + const PATTERN: &str = "MEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEMEM*EMEMEMEME"; + let dir = std::env::var("NEMOTRON_DIR") + .unwrap_or_else(|_| "/home/pidtom/models/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16".into()); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, vocab, eps) = (2688usize, 131072usize, 1e-5f32); + // Mamba2: d_inner 4096 (64h×64), n_groups 8, d_state 128, conv_kernel 4. + let (di, m_nh, m_dh, ds, ng, kc) = (4096usize, 64usize, 64usize, 128usize, 8usize, 4usize); + let conv_dim = di + 2 * ng * ds; // 6144 + let in_proj_out = 2 * di + 2 * ng * ds + m_nh; // 10304 + // MoE: 128 experts top-6, relu² ungated, shared 3712, sigmoid+bias router, ×2.5. + let (n_exp, top_k, inter, shared_inter, scale_f) = (128usize, 6usize, 1856usize, 3712usize, 2.5f32); + // Attn: GQA 32q/2kv hd128, rope θ1e4. + let (nq, nkv, hd, rope_theta) = (32usize, 2usize, 128usize, 10000f32); + let (qdim, kvdim) = (nq * hd, nkv * hd); // 4096, 256 + let ascale = 1.0 / (hd as f32).sqrt(); + + // Coarse perf accounting: how much of a token is weight dequant (CPU BF16→F32) + // vs host↔device transfer vs everything else (GPU op dispatch). This baseline + // quantifies the resident-weights + quant headroom. + use std::cell::Cell; + use std::time::Instant; + let t_deq = Cell::new(0f64); // BF16→F32 dequant + mmap read + let t_xfer = Cell::new(0f64); // upload + download bytes + let g = |name: &str| -> Vec { let t = Instant::now(); let r = st.tensor_f32(name).unwrap().0; t_deq.set(t_deq.get() + t.elapsed().as_secs_f64()); r }; + let up = |v: &[f32]| -> Tensor { let t = Instant::now(); let b = d.upload(&tb(v)).unwrap(); t_xfer.set(t_xfer.get() + t.elapsed().as_secs_f64()); Tensor::new(b, vec![v.len()], DType::F32) }; + let upm = |v: &[f32], sh: Vec| -> Tensor { let t = Instant::now(); let b = d.upload(&tb(v)).unwrap(); t_xfer.set(t_xfer.get() + t.elapsed().as_secs_f64()); Tensor::new(b, sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let s = Instant::now(); let mut b = vec![0u8; n * 4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); let r = fb(&b); t_xfer.set(t_xfer.get() + s.elapsed().as_secs_f64()); r }; + let softplus = |x: f32| if x > 20.0 { x } else { (1.0 + x.exp()).ln() }; + let relu2 = |v: &mut [f32]| for x in v.iter_mut() { let r = x.max(0.0); *x = r * r; }; + let t_total = Instant::now(); + + let token: usize = std::env::var("NEMOTRON_TOKEN").ok().and_then(|s| s.parse().ok()).unwrap_or(1234); + let embed = g("language_model.backbone.embeddings.weight"); + let mut x: Vec = embed[token * hid..(token + 1) * hid].to_vec(); + let enorm = x.iter().map(|v| v * v).sum::().sqrt(); + eprintln!("Nemotron: token {token} embed‖·‖={enorm:.3}"); + let dump = std::env::var("NEMOTRON_DUMP").is_ok(); + let fp = |tag: &str, v: &[f32]| { + let n = v.iter().map(|a| a * a).sum::().sqrt(); + eprintln!("{tag} norm={n:.4} head={:?}", v[..4].iter().map(|a| (a * 10000.0).round() / 10000.0).collect::>()); + }; + if dump { fp("L00", &x); } + + for (l, mix) in PATTERN.chars().enumerate() { + let p = format!("language_model.backbone.layers.{l}"); + let xn = rms_norm(d, &up(&x), &up(&g(&format!("{p}.norm.weight"))), eps).unwrap(); + match mix { + 'M' => { + let in_proj = upm(&g(&format!("{p}.mixer.in_proj.weight")), vec![in_proj_out, hid]); + let proj = dl(&gemv(d, &in_proj, &xn).unwrap(), in_proj_out); + let z = &proj[0..di]; + let xbc = &proj[di..di + conv_dim]; + let dt_raw = &proj[di + conv_dim..di + conv_dim + m_nh]; + let cw_hf = g(&format!("{p}.mixer.conv1d.weight")); + let mut cw = vec![0.0f32; kc * conv_dim]; + for ch in 0..conv_dim { for k in 0..kc { cw[k * conv_dim + ch] = cw_hf[ch * kc + k]; } } + let cb = g(&format!("{p}.mixer.conv1d.bias")); + let state0 = vec![0.0f32; (kc - 1) * conv_dim]; + let yc = conv1d_causal_step(d, &up(xbc), &up(&cw), &up(&cb), &up(&state0), conv_dim as u32, kc as u32).unwrap(); + let xbc_act = dl(&silu(d, &yc).unwrap(), conv_dim); + let x_ssm = &xbc_act[0..di]; + let bmat = &xbc_act[di..di + ng * ds]; + let cmat = &xbc_act[di + ng * ds..di + 2 * ng * ds]; + let dt_bias = g(&format!("{p}.mixer.dt_bias")); + let dt: Vec = (0..m_nh).map(|i| softplus(dt_raw[i] + dt_bias[i])).collect(); + let a_log = g(&format!("{p}.mixer.A_log")); + let dsk = g(&format!("{p}.mixer.D")); + let state_in = vec![0.0f32; m_nh * m_dh * ds]; + let (_so, y_t) = ssm_step(d, &up(x_ssm), &up(&a_log), &up(bmat), &up(cmat), &up(&dsk), &up(&dt), &up(&state_in), m_dh as u32, ds as u32, m_nh as u32, (m_nh / ng) as u32).unwrap(); + let y = dl(&y_t, di); + let sz = dl(&silu(d, &up(z)).unwrap(), di); + let y_gated: Vec = (0..di).map(|i| y[i] * sz[i]).collect(); + // Zamba2RMSNormGated: group-wise RMSNorm (group_size = d_inner/n_groups), + // gate applied before, weight after. NOT a full-vector RMSNorm. + let nw = g(&format!("{p}.mixer.norm.weight")); + let gs = di / ng; // 512 + let mut yn = vec![0.0f32; di]; + for grp in 0..ng { + let s = grp * gs; + let seg = dl(&rms_norm(d, &up(&y_gated[s..s + gs]), &up(&nw[s..s + gs]), eps).unwrap(), gs); + yn[s..s + gs].copy_from_slice(&seg); + } + let out = dl(&gemv(d, &upm(&g(&format!("{p}.mixer.out_proj.weight")), vec![hid, di]), &up(&yn)).unwrap(), hid); + for i in 0..hid { x[i] += out[i]; } + } + 'E' => { + let rl = dl(&gemv(d, &upm(&g(&format!("{p}.mixer.gate.weight")), vec![n_exp, hid]), &xn).unwrap(), n_exp); + let sig: Vec = rl.iter().map(|&z| 1.0 / (1.0 + (-z).exp())).collect(); + let bias = g(&format!("{p}.mixer.gate.e_score_correction_bias")); + let choice: Vec = (0..n_exp).map(|i| sig[i] + bias[i]).collect(); + let eidx = ffai_runtime::topk(&choice, top_k); + let mut w: Vec = eidx.iter().map(|&e| sig[e]).collect(); + let wsum: f32 = w.iter().sum::() + 1e-20; + for v in w.iter_mut() { *v = *v / wsum * scale_f; } + let mut acc = vec![0.0f32; hid]; + for (j, &e) in eidx.iter().enumerate() { + let ep = format!("{p}.mixer.experts.{e}"); + let mut a = dl(&gemv(d, &upm(&g(&format!("{ep}.up_proj.weight")), vec![inter, hid]), &xn).unwrap(), inter); + relu2(&mut a); + let de = dl(&gemv(d, &upm(&g(&format!("{ep}.down_proj.weight")), vec![hid, inter]), &up(&a)).unwrap(), hid); + for i in 0..hid { acc[i] += w[j] * de[i]; } + } + // shared expert (relu², inter 3712) + let mut sa = dl(&gemv(d, &upm(&g(&format!("{p}.mixer.shared_experts.up_proj.weight")), vec![shared_inter, hid]), &xn).unwrap(), shared_inter); + relu2(&mut sa); + let sde = dl(&gemv(d, &upm(&g(&format!("{p}.mixer.shared_experts.down_proj.weight")), vec![hid, shared_inter]), &up(&sa)).unwrap(), hid); + for i in 0..hid { x[i] += acc[i] + sde[i]; } + } + '*' => { + let q = gemv(d, &upm(&g(&format!("{p}.mixer.q_proj.weight")), vec![qdim, hid]), &xn).unwrap(); + let k = gemv(d, &upm(&g(&format!("{p}.mixer.k_proj.weight")), vec![kvdim, hid]), &xn).unwrap(); + let v = gemv(d, &upm(&g(&format!("{p}.mixer.v_proj.weight")), vec![kvdim, hid]), &xn).unwrap(); + let q = rope_llama(d, &q.reshaped(vec![nq, hd]), 0, rope_theta, 1.0, 1.0, 1.0, 8192.0).unwrap(); + let k = rope_llama(d, &k.reshaped(vec![nkv, hd]), 0, rope_theta, 1.0, 1.0, 1.0, 8192.0).unwrap(); + let attn = sdpa_decode(d, &q, &k, &v.reshaped(vec![nkv, hd]), hd, 1, 1, (nq / nkv) as u32, ascale).unwrap(); + let o = dl(&gemv(d, &upm(&g(&format!("{p}.mixer.o_proj.weight")), vec![hid, qdim]), &attn.reshaped(vec![qdim])).unwrap(), hid); + for i in 0..hid { x[i] += o[i]; } + } + _ => unreachable!("bad pattern char"), + } + if dump { fp(&format!("L{:02}[{mix}]", l + 1), &x); } + } + let xf = rms_norm(d, &up(&x), &up(&g("language_model.backbone.norm_f.weight")), eps).unwrap(); + let logits = dl(&gemv(d, &upm(&g("language_model.lm_head.weight"), vec![vocab, hid]), &xf).unwrap(), vocab); + let argmax = ffai_runtime::argmax(&logits); + let idx = ffai_runtime::topk(&logits, 5); + let total = t_total.elapsed().as_secs_f64(); + let (deq, xfer) = (t_deq.get(), t_xfer.get()); + let compute = (total - deq - xfer).max(0.0); + eprintln!("──────── NemotronH-Nano BASELINE (1 token, F32, naive re-load) ────────"); + eprintln!(" total {total:7.1}s ({:.3} tok/s)", 1.0 / total); + eprintln!(" dequant {deq:7.1}s {:.0}% (CPU BF16→F32; gone once weights are quantized+resident)", 100.0 * deq / total); + eprintln!(" transfer {xfer:7.1}s {:.0}% (host↔device; gone once weights resident)", 100.0 * xfer / total); + eprintln!(" compute {compute:7.1}s {:.0}% (GPU op dispatch — the resident-decode floor in F32)", 100.0 * compute / total); + eprintln!(" ⇒ resident-weights ceiling ≈ {:.2} tok/s (F32 compute only); quant+fusion cuts further", 1.0 / compute.max(1e-3)); + eprintln!("───────────────────────────────────────────────────────────────────────"); + eprintln!("NemotronH-Nano text forward on {plat}: argmax={argmax} top5={:?}", &idx[..5]); + if let Ok(exp) = std::env::var("NEMOTRON_ARGMAX") { + assert_eq!(argmax, exp.parse::().unwrap(), "Nemotron argmax != HF"); + } else if token == 1234 { + // HF-transformers CPU oracle (NemotronHForCausalLM, bf16, naive Mamba2 path). + assert_eq!(&idx[..5], &[1234usize, 99493, 99391, 67501, 49418], "Nemotron top5 != HF oracle"); + eprintln!("✅ NemotronH-Nano-30B-A3B text backbone matches HF ({plat})."); + } +} + +/// NemotronH-Nano-30B-A3B **resident Q8 decode benchmark**: quantize every big +/// matrix to Q8_0 and upload it ONCE (resident), then run a real decode loop — +/// per-token forward reuses the resident weights via [`gemv_q8`], carries an +/// attention KV cache (6 layers) + Mamba2 conv/SSM state (23 layers), and times +/// steady-state tok/s. This is the path off the 0.003 tok/s naive-reload floor. +/// Env: NEMOTRON_DECODE (steps, default 32), NEMOTRON_PREFILL (warm the cache to +/// this context length before timing, default 0). +pub fn bench_nemotron(d: &dyn Device, plat: &str) { + use ffai_ops::{add, cast_f16_f32, cast_f32_f16, conv1d_causal_prefill, conv1d_causal_step, dequant_q4, dequant_q4_off, gather, gated_group_rmsnorm, gated_group_rmsnorm_batched, gemm_cublas, gemm_cublas_f32out, gemm_q4_mpp, gemv, gemv_q4, gemv_q4_accum, gemv_q4_relu2, gemv_q8, gemv_q8_relu2, gemv_q8_accum, kv_append, kv_append_many, mamba_split_conv, mamba_split_proj, matmul, moe_bgemm_q4_bm64, moe_expert_ids_from_offsets, moe_fused_ffn, moe_gather_down, moe_gather_up_relu2, moe_grouped_gemm, moe_grouped_gemm_cutlass, moe_q4_grouped_mma, moe_q4_grouped_mma_dev, moe_router_device, moe_scatter_add_det_dev, moe_scatter_add, moe_scatter_add_det, moe_w4a16, moe_w4a16_marlin, moe_weighted_sum, nvfp4_roundtrip, permute_q4_to_marlin, quantize_q4, quantize_q8, relu2, relu2_scale_f16, rms_norm, rope_llama, rope_llama_many, sdpa_multi, sdpa_multi_tc, sdpa_multi_tc_varlen, silu, slice, sdpa_decode, sdpa_decode_2pass, sdpa_decode_2pass_bc4, sdpa_decode_2pass_tiled, softplus_add, softplus_add_rows, ssm_prefill_scan, ssm_prefill_scan_chunked, ssm_prefill_scan_ssd, ssm_prefill_scan_ssd_portable, ssm_step, strided_col_copy}; + use std::collections::HashMap; + use std::time::Instant; + const PATTERN: &str = "MEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEM*EMEMEMEM*EMEMEMEME"; + let dir = std::env::var("NEMOTRON_DIR") + .unwrap_or_else(|_| "/home/pidtom/models/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16".into()); + let Ok(st) = SafeTensors::open_dir(&dir) else { eprintln!("no model at {dir} — skipping"); return; }; + let (hid, vocab, eps) = (2688usize, 131072usize, 1e-5f32); + let (di, m_nh, m_dh, ds, ng, kc) = (4096usize, 64usize, 64usize, 128usize, 8usize, 4usize); + let conv_dim = di + 2 * ng * ds; + let in_proj_out = 2 * di + 2 * ng * ds + m_nh; + let (n_exp, top_k, inter, shared_inter, scale_f) = (128usize, 6usize, 1856usize, 3712usize, 2.5f32); + let (nq, nkv, hd, rope_theta) = (32usize, 2usize, 128usize, 10000f32); + let (qdim, kvdim) = (nq * hd, nkv * hd); + let ascale = 1.0 / (hd as f32).sqrt(); + let gs = di / ng; + + let tbu = |v: &[u32]| -> Vec { v.iter().flat_map(|x| x.to_le_bytes()).collect() }; + // f32 → f16 (round-to-nearest-even, positive normals; Q4 scales = amax/7 are in range). + fn f32_to_f16(f: f32) -> u16 { + let x = f.to_bits(); + let sign = ((x >> 16) & 0x8000) as u16; + let e = ((x >> 23) & 0xff) as i32 - 112; // 127 - 15 + if e <= 0 { return sign; } + if e >= 0x1f { return sign | 0x7c00; } + let m = (x >> 13) & 0x3ff; + let round = (x >> 12) & 1; + let v = ((e as u32) << 10) | m; + sign | ((v + round) as u16) + } + let tb_f16 = |v: &[f32]| -> Vec { v.iter().flat_map(|&f| f32_to_f16(f).to_le_bytes()).collect() }; + let g = |name: &str| -> Vec { st.tensor_f32(name).unwrap().0 }; + let up = |v: &[f32]| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), vec![v.len()], DType::F32) }; + let upm = |v: &[f32], sh: Vec| -> Tensor { Tensor::new(d.upload(&tb(v)).unwrap(), sh, DType::F32) }; + let dl = |t: &Tensor, n: usize| -> Vec { let owned; let src = if t.dtype == DType::F16 { owned = cast_f16_f32(d, t).unwrap(); &owned } else { t }; let mut b = vec![0u8; n * 4]; d.download(src.buffer.as_ref(), &mut b).unwrap(); fb(&b) }; + let softplus = |x: f32| if x > 20.0 { x } else { (1.0 + x.exp()).ln() }; + + // ── Q4 weight cache ───────────────────────────────────────────────────────── + // Gated by NEMOTRON_Q4CACHE env (default ON). On first run, writes Q4+scales to + // ~/.cache/nemo_q4/.q4b. Subsequent runs skip BF16→F32+quantize + // and read the cached bytes directly → load time ~15-25s vs ~120s. + // Cache file format (little-endian): + // [8B: m as u64][8B: k as u64][1B: f16 flag (0=f32, 1=f16)] + // [N*4 bytes: qs as u32 LE][M*2 or M*4 bytes: scales as f16 or f32 LE] + let use_q4cache = std::env::var("NEMOTRON_Q4CACHE").map(|v| v != "0" && v != "false").unwrap_or(true); + let cache_dir = std::env::var("NEMOTRON_Q4CACHE_DIR") + .unwrap_or_else(|_| format!("{}/.cache/nemo_q4", std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()))); + if use_q4cache { let _ = std::fs::create_dir_all(&cache_dir); } + + let cache_path = |name: &str| -> std::path::PathBuf { + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect(); + std::path::PathBuf::from(&cache_dir).join(format!("{safe}.q4b")) + }; + + // Read from cache: returns (qs_bytes, sc_bytes, m, k, f16) or None if miss/disabled. + let cache_read = |name: &str| -> Option<(Vec, Vec, usize, usize, bool)> { + if !use_q4cache { return None; } + let bytes = std::fs::read(cache_path(name)).ok()?; + if bytes.len() < 17 { return None; } + let m = u64::from_le_bytes(bytes[0..8].try_into().ok()?) as usize; + let k = u64::from_le_bytes(bytes[8..16].try_into().ok()?) as usize; + let f16 = bytes[16] != 0; + let bpr = k / 32; + let qs_len_u32 = m * bpr * 4; + let sc_len = m * bpr; + let sc_bytes = if f16 { sc_len * 2 } else { sc_len * 4 }; + let expected = 17 + qs_len_u32 * 4 + sc_bytes; + if bytes.len() != expected { return None; } // stale/corrupt + let qs_bytes = bytes[17..17 + qs_len_u32 * 4].to_vec(); + let sc_bytes = bytes[17 + qs_len_u32 * 4..].to_vec(); + Some((qs_bytes, sc_bytes, m, k, f16)) + }; + + // Write to cache. + let cache_write = |name: &str, qs: &[u32], sc: &[f32], m: usize, k: usize, f16: bool| { + if !use_q4cache { return; } + let mut out = Vec::with_capacity(17 + qs.len() * 4 + sc.len() * if f16 { 2 } else { 4 }); + out.extend_from_slice(&(m as u64).to_le_bytes()); + out.extend_from_slice(&(k as u64).to_le_bytes()); + out.push(f16 as u8); + for &q in qs { out.extend_from_slice(&q.to_le_bytes()); } + if f16 { + for &s in sc { out.extend_from_slice(&f32_to_f16(s).to_le_bytes()); } + } else { + for &s in sc { out.extend_from_slice(&s.to_le_bytes()); } + } + let _ = std::fs::write(cache_path(name), &out); + }; + + // ── NVFP4 recipe flag ────────────────────────────────────────────────────── + // NEMOTRON_NVFP4_RECIPE=1: switch Mamba in_proj/out_proj, shared-expert up/down, + // and attention o_proj from Q4 to Q8. Routed MoE experts, q/k/v proj, and + // lm_head stay Q4. Result is ~4.98 bits/weight, ~20.9 GB resident — matching + // NVIDIA's published NVFP4 mixed-precision recipe for apples-to-apples comparison. + let use_nvfp4_recipe = std::env::var("NEMOTRON_NVFP4_RECIPE").map(|v| v != "0" && v != "false").unwrap_or(false); + + // ── SETUP: quantize + upload all big matrices to Q4 resident (once) ── + let t_load = Instant::now(); + let mut qw: HashMap = HashMap::new(); // name → (qs, scales, m, k) + // Q8 resident weights for NVFP4-recipe layers (Mamba in/out_proj, shared-expert up/down, attn o_proj). + // Stored separately so the qmv/qacc/qrelu2 dispatch closures can key on which map to use + // without touching the Q4 path at all. Only populated when use_nvfp4_recipe=true. + let mut qw8: HashMap = HashMap::new(); + // NVFP4 fragment-packed MoE weights (NEMOTRON_FP4_MOE): name → (wp, wsc, gw) + let mut fp4w: HashMap = HashMap::new(); + let use_fp4_moe = std::env::var("NEMOTRON_FP4_MOE").is_ok(); + // CUTLASS-layout NVFP4 MoE weights (NEMOTRON_CUTLASS_MOE=1 on top of + // FP4_MOE): name → (packed e2m1 slab, swizzled ue4m3 SF slab, per-expert + // globals). Feeds the grouped block-scaled CUTLASS GEMM instead of the + // hand-rolled mma. + let mut fp4w_cutlass: HashMap = HashMap::new(); + // W4A8 MoE weights (NEMOTRON_W4A8_MOE): mxfp4 weights (per-32 ue8m0) packed + // ONCE at load → name → (packed e2m1, sf ue8m0, sfb_exp_elems). Fed to the + // mxf8f6f4 grouped GEMM with fp8 e4m3 acts — the quality-holding fast path + // (256 act levels vs W4A4's 16; weights stay 4-bit so HBM/speed ~= W4A4). + let mut w4a8w: HashMap = HashMap::new(); + let use_w4a8_env = std::env::var("NEMOTRON_W4A8_MOE").is_ok(); + let mut w8a8w: HashMap = HashMap::new(); + let use_w8a8_env = std::env::var("NEMOTRON_W8A8_MOE").is_ok(); + // Persistent CUTLASS grouped-FP4 handles per layer: (up_handle, dn_handle, + // alpha_up, alpha_dn). Prepared lazily on first use; alpha buffers are + // fixed addresses the prepared blob points at (values rewritten per call). + let cutlass_handles: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); + let use_fp4_cutlass_env = use_fp4_moe && std::env::var("NEMOTRON_CUTLASS_MOE").is_ok(); + let mut fw: HashMap> = HashMap::new(); // f32 weights used HOST-side + let mut fwd: HashMap = HashMap::new(); // f32 weights RESIDENT on device + let fd = |fwd: &mut HashMap, name: &str, v: &[f32], shape: Vec| { + fwd.insert(name.to_string(), Tensor::new(d.upload(&tb(v)).unwrap(), shape, DType::F32)); + }; + + // ── Q8 cache helpers (parallel to the Q4 cache, uses .q8b extension) ────── + // Cache format: [8B m][8B k][N*4 bytes qs u32 LE][M*4 bytes scales f32 LE] + // (Q8 scales are always f32; no f16 flag byte needed — layout is fixed.) + let cache_path_q8 = |name: &str| -> std::path::PathBuf { + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' }).collect(); + std::path::PathBuf::from(&cache_dir).join(format!("{safe}.q8b")) + }; + let cache_read_q8 = |name: &str, m: usize, k: usize| -> Option<(Vec, Vec)> { + if !use_q4cache { return None; } + let bytes = std::fs::read(cache_path_q8(name)).ok()?; + if bytes.len() < 16 { return None; } + let cm = u64::from_le_bytes(bytes[0..8].try_into().ok()?) as usize; + let ck = u64::from_le_bytes(bytes[8..16].try_into().ok()?) as usize; + if cm != m || ck != k { return None; } + let bpr = k / 32; + let qs_len_u32 = m * bpr * 8; + let sc_len = m * bpr; + let expected = 16 + qs_len_u32 * 4 + sc_len * 4; + if bytes.len() != expected { return None; } + Some((bytes[16..16 + qs_len_u32 * 4].to_vec(), bytes[16 + qs_len_u32 * 4..].to_vec())) + }; + let cache_write_q8 = |name: &str, qs: &[u32], sc: &[f32], m: usize, k: usize| { + if !use_q4cache { return; } + let mut out = Vec::with_capacity(16 + qs.len() * 4 + sc.len() * 4); + out.extend_from_slice(&(m as u64).to_le_bytes()); + out.extend_from_slice(&(k as u64).to_le_bytes()); + for &q in qs { out.extend_from_slice(&q.to_le_bytes()); } + for &s in sc { out.extend_from_slice(&s.to_le_bytes()); } + let _ = std::fs::write(cache_path_q8(name), &out); + }; + + // Load a weight into qw8 (Q8). Always uses f32 scales (Q8 kernels expect f32). + let qload8 = |qw8: &mut HashMap, name: &str, m: usize, k: usize| { + if let Some((qs_bytes, sc_bytes)) = cache_read_q8(name, m, k) { + let qt = Tensor::new(d.upload(&qs_bytes).unwrap(), vec![qs_bytes.len() / 4], DType::U32); + let sct = Tensor::new(d.upload(&sc_bytes).unwrap(), vec![sc_bytes.len() / 4], DType::F32); + qw8.insert(name.to_string(), (qt, sct, m, k)); + return; + } + let w = g(name); + let (qs, sc) = quantize_q8(&w, m, k); + cache_write_q8(name, &qs, &sc, m, k); + let qt = Tensor::new(d.upload(&tbu(&qs)).unwrap(), vec![qs.len()], DType::U32); + let sct = Tensor::new(d.upload(&tb(&sc)).unwrap(), vec![sc.len()], DType::F32); + qw8.insert(name.to_string(), (qt, sct, m, k)); + }; + + // f16: true for weights read by the PLAIN gemv kernel (qmv) — its scale param + // is f16. false for shared-expert weights (relu2/accum kernels, f32 scale). + let qload = |qw: &mut HashMap, name: &str, m: usize, k: usize, f16: bool| { + // Try cache hit first: skip BF16→F32 + quantize if available. + if let Some((qs_bytes, sc_bytes, cm, ck, cf16)) = cache_read(name) { + if cm == m && ck == k && cf16 == f16 { + let qt = Tensor::new(d.upload(&qs_bytes).unwrap(), vec![qs_bytes.len() / 4], DType::U32); + let sct = Tensor::new(d.upload(&sc_bytes).unwrap(), vec![sc_bytes.len() / if f16 { 2 } else { 4 }], if f16 { DType::F16 } else { DType::F32 }); + qw.insert(name.to_string(), (qt, sct, m, k)); + return; + } + } + // Cache miss: full BF16→F32 + Q4 quantize path. + let w = g(name); + let (qs, sc) = quantize_q4(&w, m, k); + cache_write(name, &qs, &sc, m, k, f16); + let qt = Tensor::new(d.upload(&tbu(&qs)).unwrap(), vec![qs.len()], DType::U32); + let sct = if f16 { + Tensor::new(d.upload(&tb_f16(&sc)).unwrap(), vec![sc.len()], DType::F16) + } else { + Tensor::new(d.upload(&tb(&sc)).unwrap(), vec![sc.len()], DType::F32) + }; + qw.insert(name.to_string(), (qt, sct, m, k)); + }; + let embed = g("language_model.backbone.embeddings.weight"); // host lookup table + fd(&mut fwd, "norm_f", &g("language_model.backbone.norm_f.weight"), vec![hid]); + qload(&mut qw, "language_model.lm_head.weight", vocab, hid, true); + for (l, mix) in PATTERN.chars().enumerate() { + let p = format!("language_model.backbone.layers.{l}"); + fd(&mut fwd, &format!("{p}.norm.weight"), &g(&format!("{p}.norm.weight")), vec![hid]); + match mix { + 'M' => { + // NVFP4 recipe: Mamba in_proj + out_proj → Q8 (FP8-class in the official recipe). + if use_nvfp4_recipe { + qload8(&mut qw8, &format!("{p}.mixer.in_proj.weight"), in_proj_out, hid); + qload8(&mut qw8, &format!("{p}.mixer.out_proj.weight"), hid, di); + } else { + qload(&mut qw, &format!("{p}.mixer.in_proj.weight"), in_proj_out, hid, true); + qload(&mut qw, &format!("{p}.mixer.out_proj.weight"), hid, di, true); + } + // conv weight pre-reorganized [kc, conv_dim] ONCE (was redone per step). + let cw_hf = g(&format!("{p}.mixer.conv1d.weight")); + let mut cw = vec![0.0f32; kc * conv_dim]; + for ch in 0..conv_dim { for kk in 0..kc { cw[kk * conv_dim + ch] = cw_hf[ch * kc + kk]; } } + fd(&mut fwd, &format!("{p}.mixer.conv1d.weight"), &cw, vec![kc * conv_dim]); + let convbias = g(&format!("{p}.mixer.conv1d.bias")); + fd(&mut fwd, &format!("{p}.mixer.conv1d.bias"), &convbias, vec![conv_dim]); + // Host copies of conv weight (reorganized [kc,conv_dim]) + bias for the + // host-bridged batched-prefill causal conv (NEMOTRON_PREFILL_BATCHED). + fw.insert(format!("{p}.mixer.conv1d.weight_host"), cw.clone()); + fw.insert(format!("{p}.mixer.conv1d.bias_host"), convbias); + fd(&mut fwd, &format!("{p}.mixer.A_log"), &g(&format!("{p}.mixer.A_log")), vec![m_nh]); + fd(&mut fwd, &format!("{p}.mixer.D"), &g(&format!("{p}.mixer.D")), vec![m_nh]); + fw.insert(format!("{p}.mixer.dt_bias"), g(&format!("{p}.mixer.dt_bias"))); // host (softplus, tiny) + fw.insert(format!("{p}.mixer.norm.weight"), g(&format!("{p}.mixer.norm.weight"))); + } + 'E' => { + fd(&mut fwd, &format!("{p}.mixer.gate.weight"), &g(&format!("{p}.mixer.gate.weight")), vec![n_exp, hid]); + // f16 copy for the batched-prefill cuBLAS router GEMM (688KB). + fwd.insert(format!("{p}.mixer.gate.weight.f16"), + Tensor::new(d.upload(&tb_f16(&g(&format!("{p}.mixer.gate.weight")))).unwrap(), vec![n_exp, hid], DType::F16)); + fw.insert(format!("{p}.mixer.gate.e_score_correction_bias"), g(&format!("{p}.mixer.gate.e_score_correction_bias"))); + // Experts stored CONTIGUOUS ([n_exp*inter, hid] up, [n_exp*hid, inter] down) + // so the batched gather kernel runs them as one big efficient GEMV. + // Cache the combined expert packs under "{p}.moe_up_all" / ".moe_down_all". + // When NEMOTRON_W4A16_MARLIN=1, the weights are permuted into Marlin + // tile-major layout before GPU upload (cache stores standard layout). + let moe_up_name = format!("{p}.moe_up_all"); + let moe_down_name = format!("{p}.moe_down_all"); + let use_marlin_layout = std::env::var("NEMOTRON_W4A16_MARLIN").is_ok(); + // Helper: optionally permute qs to Marlin layout before GPU upload. + // (Marlin permute applied inline below under `_marlin` keys.) + let (mup_hit, mdown_hit) = (cache_read(&moe_up_name), cache_read(&moe_down_name)); + if let (Some((uqb, usb, cm, ck, _)), Some((dqb, dsb, dm, dk, _))) = (mup_hit, mdown_hit) { + if cm == n_exp * inter && ck == hid && dm == n_exp * hid && dk == inter { + // Reconstruct qs vecs from cached bytes for optional Marlin permutation. + let uqs_std: Vec = uqb.chunks_exact(4).map(|b| u32::from_le_bytes([b[0],b[1],b[2],b[3]])).collect(); + let dqs_std: Vec = dqb.chunks_exact(4).map(|b| u32::from_le_bytes([b[0],b[1],b[2],b[3]])).collect(); + // STANDARD layout under base keys (sequential step path needs std). + let ut = Tensor::new(d.upload(&tbu(&uqs_std)).unwrap(), vec![uqs_std.len()], DType::U32); + let ust = Tensor::new(d.upload(&usb).unwrap(), vec![usb.len() / 2], DType::F16); + let dt2 = Tensor::new(d.upload(&tbu(&dqs_std)).unwrap(), vec![dqs_std.len()], DType::U32); + let dst2 = Tensor::new(d.upload(&dsb).unwrap(), vec![dsb.len() / 2], DType::F16); + qw.insert(moe_up_name.clone(), (ut, ust, n_exp * inter, hid)); + qw.insert(moe_down_name.clone(), (dt2, dst2, n_exp * hid, inter)); + // MARLIN layout under `_marlin` keys (batched Marlin path only). + if use_marlin_layout { + let uqs_mar = permute_q4_to_marlin(&uqs_std, n_exp, inter, hid); + let dqs_mar = permute_q4_to_marlin(&dqs_std, n_exp, hid, inter); + qw.insert(format!("{moe_up_name}_marlin"), + (Tensor::new(d.upload(&tbu(&uqs_mar)).unwrap(), vec![uqs_mar.len()], DType::U32), + Tensor::new(d.upload(&usb).unwrap(), vec![usb.len() / 2], DType::F16), n_exp * inter, hid)); + qw.insert(format!("{moe_down_name}_marlin"), + (Tensor::new(d.upload(&tbu(&dqs_mar)).unwrap(), vec![dqs_mar.len()], DType::U32), + Tensor::new(d.upload(&dsb).unwrap(), vec![dsb.len() / 2], DType::F16), n_exp * hid, inter)); + } + } else { + // Dimension mismatch — fall through to rebuild. + let (mut uqs, mut usc, mut dqs, mut dsc) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for e in 0..n_exp { + let (q, s) = quantize_q4(&g(&format!("{p}.mixer.experts.{e}.up_proj.weight")), inter, hid); + uqs.extend(q); usc.extend(s); + let (q, s) = quantize_q4(&g(&format!("{p}.mixer.experts.{e}.down_proj.weight")), hid, inter); + dqs.extend(q); dsc.extend(s); + } + cache_write(&moe_up_name, &uqs, &usc, n_exp * inter, hid, true); + cache_write(&moe_down_name, &dqs, &dsc, n_exp * hid, inter, true); + // STANDARD layout under base keys (sequential step path). + qw.insert(moe_up_name.clone(), (Tensor::new(d.upload(&tbu(&uqs)).unwrap(), vec![uqs.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&usc)).unwrap(), vec![usc.len()], DType::F16), n_exp * inter, hid)); + qw.insert(moe_down_name.clone(), (Tensor::new(d.upload(&tbu(&dqs)).unwrap(), vec![dqs.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&dsc)).unwrap(), vec![dsc.len()], DType::F16), n_exp * hid, inter)); + if use_marlin_layout { + let uqs_mar = permute_q4_to_marlin(&uqs, n_exp, inter, hid); + let dqs_mar = permute_q4_to_marlin(&dqs, n_exp, hid, inter); + qw.insert(format!("{moe_up_name}_marlin"), (Tensor::new(d.upload(&tbu(&uqs_mar)).unwrap(), vec![uqs_mar.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&usc)).unwrap(), vec![usc.len()], DType::F16), n_exp * inter, hid)); + qw.insert(format!("{moe_down_name}_marlin"), (Tensor::new(d.upload(&tbu(&dqs_mar)).unwrap(), vec![dqs_mar.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&dsc)).unwrap(), vec![dsc.len()], DType::F16), n_exp * hid, inter)); + } + } + } else { + // Cache miss: build + cache. + let (mut uqs, mut usc, mut dqs, mut dsc) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for e in 0..n_exp { + let (q, s) = quantize_q4(&g(&format!("{p}.mixer.experts.{e}.up_proj.weight")), inter, hid); + uqs.extend(q); usc.extend(s); + let (q, s) = quantize_q4(&g(&format!("{p}.mixer.experts.{e}.down_proj.weight")), hid, inter); + dqs.extend(q); dsc.extend(s); + } + cache_write(&moe_up_name, &uqs, &usc, n_exp * inter, hid, true); + cache_write(&moe_down_name, &dqs, &dsc, n_exp * hid, inter, true); + // STANDARD layout under base keys (sequential step path). + qw.insert(moe_up_name.clone(), (Tensor::new(d.upload(&tbu(&uqs)).unwrap(), vec![uqs.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&usc)).unwrap(), vec![usc.len()], DType::F16), n_exp * inter, hid)); + qw.insert(moe_down_name.clone(), (Tensor::new(d.upload(&tbu(&dqs)).unwrap(), vec![dqs.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&dsc)).unwrap(), vec![dsc.len()], DType::F16), n_exp * hid, inter)); + if use_marlin_layout { + let uqs_mar = permute_q4_to_marlin(&uqs, n_exp, inter, hid); + let dqs_mar = permute_q4_to_marlin(&dqs, n_exp, hid, inter); + qw.insert(format!("{moe_up_name}_marlin"), (Tensor::new(d.upload(&tbu(&uqs_mar)).unwrap(), vec![uqs_mar.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&usc)).unwrap(), vec![usc.len()], DType::F16), n_exp * inter, hid)); + qw.insert(format!("{moe_down_name}_marlin"), (Tensor::new(d.upload(&tbu(&dqs_mar)).unwrap(), vec![dqs_mar.len()], DType::U32), Tensor::new(d.upload(&tb_f16(&dsc)).unwrap(), vec![dsc.len()], DType::F16), n_exp * hid, inter)); + } + } + // NVFP4 W4A4 path (NEMOTRON_FP4_MOE=1): quantize+fragment-pack the + // routed experts on DEVICE (upload f16 once, pack kernels do the rest). + if use_fp4_moe { + let mut build = |which: &str, n: usize, k: usize| { + let key = format!("{p}.moe_{which}_fp4"); + let (kt2, nt2) = (k / 64, n / 8); + let (wp_len, wsc_len) = (n_exp * nt2 * kt2 * 32 * 2, n_exp * nt2 * kt2 * 8); + let cpath = cache_path(&format!("{key}.v1")); + // disk-cache hit: upload the packed blobs directly (skips the + // f32 collect + f16 convert + device pack — the slow part). + if let Ok(b) = std::fs::read(&cpath) { + let expect = (wp_len + wsc_len + n_exp) * 4; + if b.len() == expect { + let wp = Tensor::new(d.upload(&b[..wp_len * 4]).unwrap(), vec![wp_len], DType::U32); + let wsc = Tensor::new(d.upload(&b[wp_len * 4..(wp_len + wsc_len) * 4]).unwrap(), vec![wsc_len], DType::U32); + let gw = Tensor::new(d.upload(&b[(wp_len + wsc_len) * 4..]).unwrap(), vec![n_exp], DType::F32); + fp4w.insert(key, (wp, wsc, gw)); + return; + } + } + let mut wh: Vec = Vec::with_capacity(n_exp * n * k); + for e in 0..n_exp { + wh.extend(g(&format!("{p}.mixer.experts.{e}.{which}.weight"))); + } + let bytes = tb_f16(&wh); + drop(wh); + let wt = Tensor::new(d.upload(&bytes).unwrap(), vec![n_exp * n, k], DType::F16); + drop(bytes); + let (wp, wsc, gw) = ffai_ops::moe_fp4_pack_weights(d, &wt, n_exp, n, k).unwrap(); + // fill the cache (raw blob: wp ++ wsc ++ gw) + let mut blob = vec![0u8; (wp_len + wsc_len + n_exp) * 4]; + d.download(wp.buffer.as_ref(), &mut blob[..wp_len * 4]).unwrap(); + d.download(wsc.buffer.as_ref(), &mut blob[wp_len * 4..(wp_len + wsc_len) * 4]).unwrap(); + d.download(gw.buffer.as_ref(), &mut blob[(wp_len + wsc_len) * 4..]).unwrap(); + let _ = std::fs::write(&cpath, &blob); + fp4w.insert(key, (wp, wsc, gw)); + }; + build("up_proj", inter, hid); + build("down_proj", hid, inter); + // CUTLASS-layout NVFP4 weights (NEMOTRON_CUTLASS_MOE=1 on top + // of FP4_MOE): linear packed e2m1 + per-expert canonical- + // swizzle ue4m3 SF, quantized ONCE at setup from the + // dequanted f16 (parallel to the fragment pack above). + if use_fp4_cutlass_env { + let mut buildc = |which: &str, n: usize, k: usize| { + let key = format!("{p}.moe_{which}_fp4_cutlass"); + let kb4 = (k / 16).div_ceil(4); + let sf_exp = n.div_ceil(128) * 512 * kb4; + let (pack_len, sf_len, gw_len) = (n_exp * n * k / 2, n_exp * sf_exp, n_exp * 4); // bytes + let cpath = cache_path(&format!("{key}.v2")); + // disk-cache hit: upload the packed blobs directly. + if let Ok(b) = std::fs::read(&cpath) { + if b.len() == pack_len + sf_len + gw_len { + let wp = Tensor::new(d.upload(&b[..pack_len]).unwrap(), vec![pack_len], DType::U32); + let wsf = Tensor::new(d.upload(&b[pack_len..pack_len + sf_len]).unwrap(), vec![sf_len], DType::U32); + let gw = Tensor::new(d.upload(&b[pack_len + sf_len..]).unwrap(), vec![n_exp], DType::F32); + fp4w_cutlass.insert(key, (wp, wsf, gw)); + return; + } + } + let mut wh: Vec = Vec::with_capacity(n_exp * n * k); + for e in 0..n_exp { + wh.extend(g(&format!("{p}.mixer.experts.{e}.{which}.weight"))); + } + let bytes = tb_f16(&wh); + drop(wh); + let wt = Tensor::new(d.upload(&bytes).unwrap(), vec![n_exp * n, k], DType::F16); + drop(bytes); + let (wp, wsf, gw) = ffai_ops::lt_fp4_quant_slab(d, &wt, n_exp, n, k).unwrap(); + // fill the cache (raw blob: pack ++ sf ++ gw) + let mut blob = vec![0u8; pack_len + sf_len + gw_len]; + d.download(wp.buffer.as_ref(), &mut blob[..pack_len]).unwrap(); + d.download(wsf.buffer.as_ref(), &mut blob[pack_len..pack_len + sf_len]).unwrap(); + d.download(gw.buffer.as_ref(), &mut blob[pack_len + sf_len..]).unwrap(); + let _ = std::fs::write(&cpath, &blob); + fp4w_cutlass.insert(key, (wp, wsf, gw)); + }; + buildc("up_proj", inter, hid); + buildc("down_proj", hid, inter); + } + // W4A8: pack the SAME dequanted f16 experts as mxfp4 (per-32 + // ue8m0) once at load. sfb_exp_elems returned by w4a8_packw + // is the per-expert SF stride the GEMM consumes (no disk cache + // — packing is fast and the stride must come from the packer). + if use_w4a8_env { + let mut buildw = |which: &str, n: usize, k: usize| { + let key = format!("{p}.moe_{which}_w4a8"); + let mut wh: Vec = Vec::with_capacity(n_exp * n * k); + for e in 0..n_exp { + wh.extend(g(&format!("{p}.mixer.experts.{e}.{which}.weight"))); + } + let bytes = tb_f16(&wh); drop(wh); + let wt = Tensor::new(d.upload(&bytes).unwrap(), vec![n_exp * n, k], DType::F16); + drop(bytes); + // mxf8f6f4 SF atom-K = 128 → pad K (down-proj K=1856 -> 1920). + let kpad = k.div_ceil(128) * 128; + let wt = ffai_ops::pad_rows_f16(d, &wt, n_exp * n, k, kpad).unwrap(); + let (wp, wsf, eb) = ffai_ops::w4a8_packw(d, &wt, n_exp, n, kpad).unwrap(); + w4a8w.insert(key, (wp, wsf, eb)); + }; + buildw("up_proj", inter, hid); + buildw("down_proj", hid, inter); + } + // W8A8: pack experts as fp8 e4m3 (per-32 ue8m0) once at load. + // No K-pad (mxf8 atom = per-32; validated K=1856 unpadded). + if use_w8a8_env { + let mut buildw8 = |which: &str, n: usize, k: usize| { + let key = format!("{p}.moe_{which}_w8a8"); + let mut wh: Vec = Vec::with_capacity(n_exp * n * k); + for e in 0..n_exp { wh.extend(g(&format!("{p}.mixer.experts.{e}.{which}.weight"))); } + let bytes = tb_f16(&wh); drop(wh); + let wt = Tensor::new(d.upload(&bytes).unwrap(), vec![n_exp * n, k], DType::F16); + drop(bytes); + let (wp, wsf, eb) = ffai_ops::w8a8_packw(d, &wt, n_exp, n, k).unwrap(); + w8a8w.insert(key, (wp, wsf, eb)); + }; + buildw8("up_proj", inter, hid); + buildw8("down_proj", hid, inter); + } + // NEMOTRON_FP4_SHARED_FOLD=1: shared expert folded into the routed + // grouped GEMM as 2 extra always-selected groups. Up: shared up + // [2*inter, hid] appended as two [inter, hid] expert slabs. Down: + // shared down [hid, 2*inter] K-SPLIT into two [hid, inter] slabs + // (their scatter-adds sum to the full K=2*inter GEMM). + if std::env::var("NEMOTRON_FP4_SHARED_FOLD").is_ok() { + let mut buildf = |which: &str, n: usize, k: usize, split_k: bool| { + let key = format!("{p}.moe_{which}_fp4_fold"); + let ne = n_exp + 2; + let (kt2, nt2) = (k / 64, n / 8); + let (wp_len, wsc_len) = (ne * nt2 * kt2 * 32 * 2, ne * nt2 * kt2 * 8); + let cpath = cache_path(&format!("{key}.v1")); + if let Ok(b) = std::fs::read(&cpath) { + let expect = (wp_len + wsc_len + ne) * 4; + if b.len() == expect { + let wp = Tensor::new(d.upload(&b[..wp_len * 4]).unwrap(), vec![wp_len], DType::U32); + let wsc = Tensor::new(d.upload(&b[wp_len * 4..(wp_len + wsc_len) * 4]).unwrap(), vec![wsc_len], DType::U32); + let gw = Tensor::new(d.upload(&b[(wp_len + wsc_len) * 4..]).unwrap(), vec![ne], DType::F32); + fp4w.insert(key, (wp, wsc, gw)); + return; + } + } + let mut wh: Vec = Vec::with_capacity(ne * n * k); + for e in 0..n_exp { + wh.extend(g(&format!("{p}.mixer.experts.{e}.{which}.weight"))); + } + let shw: Vec = g(&format!("{p}.mixer.shared_experts.{which}.weight")); + if !split_k { + // up: [2n, k] row-major → slab order is already two [n,k] stacks + wh.extend(&shw); + } else { + // down: [n, 2k] row-major → two [n, k] column halves + for half in 0..2 { + for r in 0..n { + wh.extend(&shw[r * 2 * k + half * k..r * 2 * k + half * k + k]); + } + } + } + let bytes = tb_f16(&wh); + drop(wh); + let wt = Tensor::new(d.upload(&bytes).unwrap(), vec![ne * n, k], DType::F16); + drop(bytes); + let (wp, wsc, gw) = ffai_ops::moe_fp4_pack_weights(d, &wt, ne, n, k).unwrap(); + let mut blob = vec![0u8; (wp_len + wsc_len + ne) * 4]; + d.download(wp.buffer.as_ref(), &mut blob[..wp_len * 4]).unwrap(); + d.download(wsc.buffer.as_ref(), &mut blob[wp_len * 4..(wp_len + wsc_len) * 4]).unwrap(); + d.download(gw.buffer.as_ref(), &mut blob[(wp_len + wsc_len) * 4..]).unwrap(); + let _ = std::fs::write(&cpath, &blob); + fp4w.insert(key, (wp, wsc, gw)); + }; + buildf("up_proj", inter, hid, false); + buildf("down_proj", hid, inter, true); + } + // shared expert (n_exp=1 grouped call), NEMOTRON_FP4_SHARED=1 + if std::env::var("NEMOTRON_FP4_SHARED").is_ok() { + let mut build1 = |which: &str, n: usize, k: usize| { + let wh: Vec = g(&format!("{p}.mixer.shared_experts.{which}.weight")); + let bytes = tb_f16(&wh); + drop(wh); + let wt = Tensor::new(d.upload(&bytes).unwrap(), vec![n, k], DType::F16); + drop(bytes); + let (wp, wsc, gw) = ffai_ops::moe_fp4_pack_weights(d, &wt, 1, n, k).unwrap(); + fp4w.insert(format!("{p}.sh_{which}_fp4"), (wp, wsc, gw)); + }; + build1("up_proj", shared_inter, hid); + build1("down_proj", hid, shared_inter); + } + } + // NVFP4 recipe: shared-expert up/down → Q8. + if use_nvfp4_recipe { + qload8(&mut qw8, &format!("{p}.mixer.shared_experts.up_proj.weight"), shared_inter, hid); + qload8(&mut qw8, &format!("{p}.mixer.shared_experts.down_proj.weight"), hid, shared_inter); + } else { + qload(&mut qw, &format!("{p}.mixer.shared_experts.up_proj.weight"), shared_inter, hid, true); + qload(&mut qw, &format!("{p}.mixer.shared_experts.down_proj.weight"), hid, shared_inter, true); + } + } + '*' => { + qload(&mut qw, &format!("{p}.mixer.q_proj.weight"), qdim, hid, true); + qload(&mut qw, &format!("{p}.mixer.k_proj.weight"), kvdim, hid, true); + qload(&mut qw, &format!("{p}.mixer.v_proj.weight"), kvdim, hid, true); + // NVFP4 recipe: attention o_proj → Q8. + if use_nvfp4_recipe { + qload8(&mut qw8, &format!("{p}.mixer.o_proj.weight"), hid, qdim); + } else { + qload(&mut qw, &format!("{p}.mixer.o_proj.weight"), hid, qdim, true); + } + } + _ => unreachable!(), + } + } + let load_s = t_load.elapsed().as_secs_f64(); + let q4_gb: f64 = qw.values().map(|(q, s, _, _)| (q.elem_count() * 4 + s.elem_count() * if s.dtype == DType::F16 { 2usize } else { 4usize }) as f64).sum::() / 1e9; + let q8_gb: f64 = (qw8.values().map(|(q, s, _, _)| (q.elem_count() * 4 + s.elem_count() * 4) as f64).sum::() / 1e9).max(0.0); + let recipe_label = if use_nvfp4_recipe { "NVFP4-recipe" } else { "all-Q4" }; + eprintln!("Nemotron resident setup [{}]: {:.1}s ({} Q4 + {} Q8 matrices, ~{:.2}GB Q4 + {:.2}GB Q8 = {:.2}GB total)", + recipe_label, load_s, qw.len(), qw8.len(), q4_gb, q8_gb, q4_gb + q8_gb); + + // resident-weight quantized matvec + let qmv = |x: &Tensor, name: &str| -> Tensor { + let (qs, sc, m, k) = &qw[name]; + gemv_q4(d, qs, sc, x, *m, *k, *m).unwrap() + }; + // resident-weight matvec that scales + accumulates into `acc` in one kernel. + let qacc = |x: &Tensor, name: &str, acc: &Tensor, sb: &Tensor| { + let (qs, sc, m, k) = &qw[name]; + gemv_q4_accum(d, qs, sc, x, acc, sb, *m, *k, *m).unwrap(); + }; + // resident-weight matvec with fused ReLU² (MoE expert up-projection). + let qrelu2 = |x: &Tensor, name: &str| -> Tensor { + let (qs, sc, m, k) = &qw[name]; + gemv_q4_relu2(d, qs, sc, x, *m, *k, *m).unwrap() + }; + + // ── Q8 dispatch closures (NVFP4-recipe layers) ──────────────────────────── + // These are no-ops when use_nvfp4_recipe=false (qw8 is empty; never called). + let qmv8 = |x: &Tensor, name: &str| -> Tensor { + let (qs, sc, m, k) = &qw8[name]; + gemv_q8(d, qs, sc, x, *m, *k, *m).unwrap() + }; + let qrelu2_q8 = |x: &Tensor, name: &str| -> Tensor { + let (qs, sc, m, k) = &qw8[name]; + gemv_q8_relu2(d, qs, sc, x, *m, *k, *m).unwrap() + }; + let qacc_q8 = |x: &Tensor, name: &str, acc: &Tensor, sb: &Tensor| { + let (qs, sc, m, k) = &qw8[name]; + gemv_q8_accum(d, qs, sc, x, acc, sb, *m, *k, *m).unwrap(); + }; + + // ── Fused MoE FFN (NEMOTRON_MOE_FUSED=1) ───────────────────────────────── + // Pre-allocate the scratch buffer once. All 23 MoE layers share it (they're + // serial — layer N+1 starts only after N finishes). + // NOTE: NEMOTRON_GRAPH + NEMOTRON_MOE_FUSED are mutually exclusive — the + // fused kernel uses cuLaunchCooperativeKernel which can't be captured into a + // CUDA graph. If both are set, fusion is silently disabled. + let use_moe_fused = std::env::var("NEMOTRON_MOE_FUSED").is_ok() + && !std::env::var("NEMOTRON_GRAPH").is_ok(); + let moe_scratch = if use_moe_fused { + let scratch_bytes = top_k * inter * 4; // f32 + Some(Tensor::new(d.upload(&vec![0u8; scratch_bytes]).unwrap(), vec![top_k * inter], DType::F32)) + } else { + None + }; + if use_moe_fused { + eprintln!("NEMOTRON_MOE_FUSED=1: using cooperative-groups fused MoE FFN kernel (eager, non-graph)"); + eprintln!(" NOTE: cuLaunchCooperativeKernel requires all grid blocks resident simultaneously."); + eprintln!(" On GB10 (48 SMs), max coop blocks = 288 at 256 threads, need 336 for hid=2688."); + eprintln!(" This will FAIL with 'too many blocks'. See NEMOTRON_MOE_FUSED analysis."); + } else if std::env::var("NEMOTRON_MOE_FUSED").is_ok() && std::env::var("NEMOTRON_GRAPH").is_ok() { + eprintln!("NEMOTRON_MOE_FUSED=1: disabled (NEMOTRON_GRAPH is set — cooperative launch not capturable)"); + } + + // ── DECODE: per-token forward reusing resident weights, KV + Mamba state ── + let env = |k: &str, d: usize| std::env::var(k).ok().and_then(|s| s.parse().ok()).unwrap_or(d); + let prefill = env("NEMOTRON_PREFILL", 0); + let n_decode = env("NEMOTRON_DECODE", 32); + let fakectx = env("NEMOTRON_FAKECTX", 0); + // GQA split-K flash-decode sdpa: default ON for long context (≈+15-25% @ 32K + // where the single-pass re-reads each shared KV head gqa_factor×); single-pass + // is marginally better at tiny ctx (2-pass partial overhead). Opt out w/ NEMOTRON_NO_2PASS. + let no_2pass = std::env::var("NEMOTRON_NO_2PASS").is_ok(); + // Device MoE router REGRESSED (-17-24%, clock-locked): the 1-simdgroup serial + // top-k bubble underutilizes the GPU worse than host top-k + a cheap sync drain + // (same lesson as device-Mamba). Host router is DEFAULT; device is opt-in only. + let no_devrouter = !std::env::var("NEMOTRON_DEVROUTER").is_ok(); + // CUDA-graph capture/replay: collapse ~390 per-token kernel launches into ONE + // cuGraphLaunch to remove host-launch-gap overhead. Requires NEMOTRON_DEVROUTER=1 + // (device MoE router, no host sync) and gates the final argmax download so the + // captured region is fully sync-free. Set NEMOTRON_GRAPH=1 to enable. + let use_graph = std::env::var("NEMOTRON_GRAPH").is_ok(); + // skip_dl: Cell threaded into the step closure. Set true during capture and + // replay so the dl(logits) host-sync is skipped (forbidden during capture; not + // needed for the graph throughput measurement). + let skip_dl = std::cell::Cell::new(false); + // F16 KV cache: the clock-locked "+11-27%" was a thermal/order artifact (the + // internal A/B shows it neutral-to-negative — casts cost ≥ halved-read saves). + // DEFAULT OFF (f32, no casts); opt in with NEMOTRON_F16KV. + let f16kv = std::env::var("NEMOTRON_F16KV").is_ok(); + // KV-cache capacity (positions). In the batched-prefill path the queries + // run at positions [fakectx, fakectx+prefill), so the cache must hold the + // full fakectx prefix PLUS the whole prefill block PLUS the decode tail — + // i.e. fakectx + prefill, NOT max(fakectx, prefill). Using max() under-sized + // the cache whenever both were nonzero, so sdpa_multi/kv_append_many walked + // past the buffer (base_kv + n_query > kv_stride) → illegal memory access at + // deep context. (Plain decode, where one of the two is 0, is unaffected.) + // NEMOTRON_PACKED=N packs N prompts (s·N tokens) into one batched prefill, + // so the KV cache must hold N× the per-sequence length. + let pack_n_cap = std::env::var("NEMOTRON_PACKED").ok() + .and_then(|v| v.parse::().ok()).unwrap_or(1).max(1); + let cap = fakectx + prefill * pack_n_cap + n_decode + 8; + // per-layer state, indexed by absolute layer id. KV cache is now ON-DEVICE + // ([nkv,cap,hd] per attn layer), so the growing context never round-trips + // through the host — the 32K decode fix. + let mut conv_state: Vec> = vec![Vec::new(); 52]; + let conv_dev: std::cell::RefCell>> = std::cell::RefCell::new((0..52).map(|_| None).collect()); // device conv state (NEMOTRON_DEVMAMBA) + let mut ssm_state: Vec> = (0..52).map(|_| None).collect(); // recurrent SSM state ON-DEVICE + let mut kvcache: Vec> = (0..52).map(|_| None).collect(); + let u32buf = |v: u32| Tensor::new(d.upload(&v.to_le_bytes()).unwrap(), vec![1], DType::U32); + let ones_gs = up(&vec![1.0f32; gs]); // grouped-norm: normalize each 512-group weightless, then scale by the real weight + // Optional per-op profiling (NEMOTRON_PROFILE=1): synchronize around EACH individual + // op call to attribute GPU time precisely. Adds sync overhead on every op — use only + // for profiling runs, not for throughput measurement. + let prof = std::env::var("NEMOTRON_PROFILE").is_ok(); + + // Per-op time accumulators (Cell = interior-mutable across the step closure). + // Index: 0=rms_norm, 1=m_in_proj, 2=slice, 3=conv1d, 4=silu, 5=conv_roll, + // 6=softplus_add, 7=ssm_step, 8=gated_norm, 9=m_out_proj, + // 10=moe_gate_gemv, 11=moe_router_dev, 12=moe_gather_up, 13=moe_gather_down, + // 14=moe_wsum, 15=shared_up, 16=shared_down_acc, + // 17=rope, 18=q_proj, 19=k_proj, 20=v_proj, 21=kv_append, 22=sdpa, 23=o_proj, + // 24=add, 25=norm_f, 26=lm_head, 27=add_residual_m, 28=add_residual_e, + // 29=add_residual_a, 30=cast_f16 + const N_OPS: usize = 31; + let op_t: Vec> = (0..N_OPS).map(|_| std::cell::Cell::new(0f64)).collect(); + let op_calls: Vec> = (0..N_OPS).map(|_| std::cell::Cell::new(0u64)).collect(); + // Bytes-read estimates per op (f64, accumulated across all calls) + let op_bytes: Vec> = (0..N_OPS).map(|_| std::cell::Cell::new(0f64)).collect(); + // Wall time per step (to compute host overhead = wall - sum_gpu) + let step_wall: std::cell::Cell = std::cell::Cell::new(0f64); + let step_count: std::cell::Cell = std::cell::Cell::new(0u64); + + // Macro-like helper: time one op when profiling is on. + // sync(); t0 = now(); result = expr; sync(); acc. + // Because we can't use macros in closures portably, we inline this pattern. + // The (tm, te, ta, th) coarse cells kept for backward compat display. + let (tm, te, ta, th) = (std::cell::Cell::new(0f64), std::cell::Cell::new(0f64), std::cell::Cell::new(0f64), std::cell::Cell::new(0f64)); + + // INSTRUMENTATION (correctness audit): capture the last-token logit vector of + // the most recent `step()` call so the prefill gate can compute logit-level + // metrics (cosine, top-5 overlap, max-abs err) vs the batched path. REVERT. + thread_local! { static LAST_STEP_LOGITS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; } + thread_local! { static ROUTER_HOST_T: std::cell::Cell = const { std::cell::Cell::new(0f64) }; } + thread_local! { static STEP_LAYER_TRACE: std::cell::RefCell>> = const { std::cell::RefCell::new(Vec::new()) }; } + thread_local! { static BATCHED_LAYER_TRACE: std::cell::RefCell>> = const { std::cell::RefCell::new(Vec::new()) }; } + thread_local! { static STEP0_FROZEN: std::cell::RefCell>> = const { std::cell::RefCell::new(Vec::new()) }; } + type SsmDump = (Vec, Vec, Vec, Vec); + thread_local! { static SEQ_SSM_DUMP: std::cell::RefCell = const { std::cell::RefCell::new((Vec::new(),Vec::new(),Vec::new(),Vec::new())) }; } + thread_local! { static BAT_SSM_DUMP: std::cell::RefCell = const { std::cell::RefCell::new((Vec::new(),Vec::new(),Vec::new(),Vec::new())) }; } + + // one decode step at absolute position `pos`; returns next-token logits' argmax + let step = |token: usize, pos: usize, + conv_state: &mut Vec>, ssm_state: &mut Vec>, + kvcache: &mut Vec>| -> usize { + let step_t0 = if prof { d.synchronize().ok(); Some(Instant::now()) } else { None }; + + // Per-op timing helper: sync before + after, accumulate into op_t[idx]. + // bytes_read: approximate DRAM bytes fetched (input tensors + weights). + // Only active when prof=true; otherwise the closure is zero-cost (condition + // checked at runtime but the body is short-circuit skipped). + macro_rules! pt { + ($idx:expr, $bytes:expr, $expr:expr) => {{ + if prof { + d.synchronize().ok(); + let _t0 = Instant::now(); + let _r = $expr; + d.synchronize().ok(); + let _e = _t0.elapsed().as_secs_f64(); + op_t[$idx].set(op_t[$idx].get() + _e); + op_calls[$idx].set(op_calls[$idx].get() + 1); + op_bytes[$idx].set(op_bytes[$idx].get() + $bytes as f64); + _r + } else { + $expr + } + }}; + } + + // residual stream stays ON-DEVICE the whole forward — no per-layer up(x)/dl(out). + let mut xt = up(&embed[token * hid..(token + 1) * hid]); + for (l, mix) in PATTERN.chars().enumerate() { + let p = format!("language_model.backbone.layers.{l}"); + // rms_norm per layer: reads x (hid*4B) + weight (hid*4B) + let xn = pt!(0, hid * 8, rms_norm(d, &xt, &fwd[&format!("{p}.norm.weight")], eps).unwrap()); + match mix { + 'M' => { + if std::env::var("NEMOTRON_SKIPMAMBA").is_ok() { continue; } + if !std::env::var("NEMOTRON_HOSTMAMBA").is_ok() { + // ALL-DEVICE Mamba (DEFAULT): no dl/host round-trips. +3.7% clean + // internal A/B; argmax 1234. Opt out: NEMOTRON_HOSTMAMBA. + // in_proj gemv_q4/q8: recipe layers use Q8 (2× bytes vs Q4) + let proj = pt!(1, if use_nvfp4_recipe { in_proj_out * hid + in_proj_out * 4 + hid * 4 } else { in_proj_out * hid / 2 + in_proj_out * 2 + hid * 4 }, + if use_nvfp4_recipe { qmv8(&xn, &format!("{p}.mixer.in_proj.weight")) } else { qmv(&xn, &format!("{p}.mixer.in_proj.weight")) }); + // slice×3: reads proj (in_proj_out*4B each) + let zt = pt!(2, in_proj_out * 4, slice(d, &proj, 0, di).unwrap()); + let xbc_t = pt!(2, in_proj_out * 4, slice(d, &proj, di, conv_dim).unwrap()); + let dt_raw_t= pt!(2, in_proj_out * 4, slice(d, &proj, di + conv_dim, m_nh).unwrap()); + { let mut cd = conv_dev.borrow_mut(); if cd[l].is_none() { cd[l] = Some(Tensor::new(d.alloc_zeroed((kc - 1) * conv_dim * 4).unwrap(), vec![(kc - 1) * conv_dim], DType::F32)); } } + // conv1d: reads state ((kc-1)*conv_dim*4B) + xbc_t (conv_dim*4B) + weight (kc*conv_dim*4B) + bias (conv_dim*4B) + let yc = pt!(3, (kc - 1) * conv_dim * 4 + conv_dim * 4 + kc * conv_dim * 4 + conv_dim * 4, { + let cd = conv_dev.borrow(); + conv1d_causal_step(d, &xbc_t, &fwd[&format!("{p}.mixer.conv1d.weight")], &fwd[&format!("{p}.mixer.conv1d.bias")], cd[l].as_ref().unwrap(), conv_dim as u32, kc as u32).unwrap() + }); + // silu: reads yc (conv_dim*4B) + let xbc_act = pt!(4, conv_dim * 4, silu(d, &yc).unwrap()); + // conv1d_causal_step above is the SOLE conv-ring updater on the all-device + // path: it shifts cd[l]'s ring in place and appends xbc_t (mutating the + // persistent device buffer). The previous conv_roll here shifted+appended a + // SECOND time -> the current token was double-counted in the conv history at + // every position >=1, silently corrupting multi-token decode past token 0 + // (invisible at pos 0 where the ring is zero). Removed. (host-fallback branch + // below stays as-is: it uploads a throwaway copy + rolls conv_state[l] once.) + let x_ssm = pt!(2, (di + ng * ds * 2) * 4, slice(d, &xbc_act, 0, di).unwrap()); + let bmat = pt!(2, (di + ng * ds * 2) * 4, slice(d, &xbc_act, di, ng * ds).unwrap()); + let cmat = pt!(2, (di + ng * ds * 2) * 4, slice(d, &xbc_act, di + ng * ds, ng * ds).unwrap()); + // softplus_add: reads dt_raw_t (m_nh*4B) + bias (m_nh*4B) + let dt = pt!(6, m_nh * 8, softplus_add(d, &dt_raw_t, &up(&fw[&format!("{p}.mixer.dt_bias")])).unwrap()); + if ssm_state[l].is_none() { ssm_state[l] = Some(Tensor::new(d.alloc_zeroed(m_nh * m_dh * ds * 4).unwrap(), vec![m_nh * m_dh * ds], DType::F32)); } + // ssm_step: reads state (m_nh*m_dh*ds*4B) + x (di*4B) + A,B,C,D,dt (small) + let (so, y_t) = pt!(7, m_nh * m_dh * ds * 4 + di * 4 + m_nh * 5 * 4 + ng * ds * 2 * 4, { + ssm_step(d, &x_ssm, &fwd[&format!("{p}.mixer.A_log")], &bmat, &cmat, &fwd[&format!("{p}.mixer.D")], &dt, ssm_state[l].as_ref().unwrap(), m_dh as u32, ds as u32, m_nh as u32, (m_nh / ng) as u32).unwrap() + }); + ssm_state[l] = Some(so); + // INSTRUMENTATION (revert): dump L0 SSM in/out for the seq path. + if l == 0 && std::env::var("NEMOTRON_DUMP_SSM").is_ok() { + let xs = dl(&x_ssm, di); let yy = dl(&y_t, di); + let bb = dl(&bmat, ng*ds); let cc = dl(&cmat, ng*ds); + SEQ_SSM_DUMP.with(|c| *c.borrow_mut() = (xs, bb, cc, yy)); + } + // gated_group_rmsnorm: reads y_t (di*4B) + zt (di*4B) + norm weight (di*4B) + let yn = pt!(8, di * 12, gated_group_rmsnorm(d, &y_t, &zt, &up(&fw[&format!("{p}.mixer.norm.weight")]), eps, di, gs).unwrap()); + // out_proj gemv_q4/q8: recipe → Q8 + let out = pt!(9, if use_nvfp4_recipe { hid * di + hid * 4 + di * 4 } else { hid * di / 2 + hid * 2 + di * 4 }, + if use_nvfp4_recipe { qmv8(&yn, &format!("{p}.mixer.out_proj.weight")) } else { qmv(&yn, &format!("{p}.mixer.out_proj.weight")) }); + // add residual: reads xt (hid*4B) + out (hid*4B) + xt = pt!(27, hid * 8, add(d, &xt, &out).unwrap()); + if prof { d.synchronize().ok(); let e = step_t0.map(|_| 0.0).unwrap_or(0.0); let _ = e; tm.set(tm.get()); } // coarse compat + if std::env::var("NEMOTRON_DUMP_LAYERS").is_ok() { + let h = dl(&xt, hid); + STEP_LAYER_TRACE.with(|c| { let mut v = c.borrow_mut(); if v.len() <= l { v.resize(l+1, Vec::new()); } v[l] = h; }); + } + continue; + } + let proj = dl(&pt!(1, if use_nvfp4_recipe { in_proj_out * hid + in_proj_out * 4 + hid * 4 } else { in_proj_out * hid / 2 + in_proj_out * 2 + hid * 4 }, + if use_nvfp4_recipe { qmv8(&xn, &format!("{p}.mixer.in_proj.weight")) } else { qmv(&xn, &format!("{p}.mixer.in_proj.weight")) }), in_proj_out); + let z = &proj[0..di]; + let xbc = &proj[di..di + conv_dim]; + let dt_raw = &proj[di + conv_dim..di + conv_dim + m_nh]; + if conv_state[l].is_empty() { conv_state[l] = vec![0.0f32; (kc - 1) * conv_dim]; } + let yc = pt!(3, (kc - 1) * conv_dim * 4 + conv_dim * 4 + kc * conv_dim * 4 + conv_dim * 4, + conv1d_causal_step(d, &up(xbc), &fwd[&format!("{p}.mixer.conv1d.weight")], &fwd[&format!("{p}.mixer.conv1d.bias")], &up(&conv_state[l]), conv_dim as u32, kc as u32).unwrap()); + let xbc_act = dl(&pt!(4, conv_dim * 4, silu(d, &yc).unwrap()), conv_dim); + { let s = &mut conv_state[l]; s.drain(0..conv_dim); s.extend_from_slice(xbc); } + let x_ssm = &xbc_act[0..di]; + let bmat = &xbc_act[di..di + ng * ds]; + let cmat = &xbc_act[di + ng * ds..di + 2 * ng * ds]; + let dt_bias = &fw[&format!("{p}.mixer.dt_bias")]; + let dt: Vec = (0..m_nh).map(|i| softplus(dt_raw[i] + dt_bias[i])).collect(); + if ssm_state[l].is_none() { ssm_state[l] = Some(Tensor::new(d.alloc_zeroed(m_nh * m_dh * ds * 4).unwrap(), vec![m_nh * m_dh * ds], DType::F32)); } + let (so, y_t) = pt!(7, m_nh * m_dh * ds * 4 + di * 4 + m_nh * 5 * 4 + ng * ds * 2 * 4, { + ssm_step(d, &up(x_ssm), &fwd[&format!("{p}.mixer.A_log")], &up(bmat), &up(cmat), &fwd[&format!("{p}.mixer.D")], &up(&dt), ssm_state[l].as_ref().unwrap(), m_dh as u32, ds as u32, m_nh as u32, (m_nh / ng) as u32).unwrap() + }); + ssm_state[l] = Some(so); + let y = dl(&y_t, di); + let nw = &fw[&format!("{p}.mixer.norm.weight")]; + let mut yn = vec![0.0f32; di]; + for grp in 0..ng { + let s = grp * gs; + let mut ss = 0.0f32; + for i in 0..gs { let g = y[s + i] * (z[s + i] / (1.0 + (-z[s + i]).exp())); yn[s + i] = g; ss += g * g; } + let r = 1.0 / ((ss / gs as f32) + eps).sqrt(); + for i in 0..gs { yn[s + i] = yn[s + i] * r * nw[s + i]; } + } + let out = pt!(9, if use_nvfp4_recipe { hid * di + hid * 4 + di * 4 } else { hid * di / 2 + hid * 2 + di * 4 }, + if use_nvfp4_recipe { qmv8(&up(&yn), &format!("{p}.mixer.out_proj.weight")) } else { qmv(&up(&yn), &format!("{p}.mixer.out_proj.weight")) }); + xt = pt!(27, hid * 8, add(d, &xt, &out).unwrap()); + } + 'E' => { + if std::env::var("NEMOTRON_SKIPMOE").is_ok() { continue; } + // Router: ON-DEVICE (sigmoid+bias+top-k+norm+scale, no host sync) by + // default; host path kept for A/B via NEMOTRON_HOSTROUTER. + let (idx_buf, wts_buf) = if !std::env::var("NEMOTRON_DEVROUTER").is_ok() { + // host router: gate gemv (f32 weight 128×2688) then host top-k + let rl = dl(&pt!(10, n_exp * hid * 4, gemv(d, &fwd[&format!("{p}.mixer.gate.weight")], &xn).unwrap()), n_exp); + let sig: Vec = rl.iter().map(|&z| 1.0 / (1.0 + (-z).exp())).collect(); + let bias = &fw[&format!("{p}.mixer.gate.e_score_correction_bias")]; + let choice: Vec = (0..n_exp).map(|i| sig[i] + bias[i]).collect(); + let eidx = ffai_runtime::topk(&choice, top_k); + let mut w: Vec = eidx.iter().map(|&e| sig[e]).collect(); + let wsum: f32 = w.iter().sum::() + 1e-20; + for v in w.iter_mut() { *v = *v / wsum * scale_f; } + (Tensor::new(d.upload(&tbu(&eidx.iter().map(|&e| e as u32).collect::>())).unwrap(), vec![top_k], DType::U32), up(&w)) + } else { + let logits = pt!(10, n_exp * hid * 4, gemv(d, &fwd[&format!("{p}.mixer.gate.weight")], &xn).unwrap()); + let bias_dev = up(&fw[&format!("{p}.mixer.gate.e_score_correction_bias")]); + // device router kernel: reads logits (n_exp*4B) + bias (n_exp*4B) + pt!(11, n_exp * 8, moe_router_device(d, &logits, &bias_dev, n_exp, top_k, scale_f).unwrap()) + }; + let acc_dev = up(&vec![0.0f32; hid]); + let (uqs, usc, _, _) = &qw[&format!("{p}.moe_up_all")]; + let (dqs, dsc, _, _) = &qw[&format!("{p}.moe_down_all")]; + if let Some(ref scratch) = moe_scratch { + // ── FUSED path: one cooperative kernel, intermediate in L2 ── + // Reads: up Q4 (top_k*inter*hid/2) + up scales + dn Q4 (top_k*hid*inter/2) + dn scales + x (hid*4B) + pt!(12, top_k * inter * hid / 2 + top_k * inter * 2 + top_k * hid * inter / 2 + top_k * hid * 2 + hid * 4, + moe_fused_ffn(d, uqs, usc, dqs, dsc, &xn, &idx_buf, &wts_buf, &acc_dev, scratch, hid, inter, top_k).unwrap()); + } else { + // ── Two-kernel path (baseline) ── + // moe_gather_up_relu2: reads top_k expert rows (top_k*inter*hid/2 Q4) + scales + x (hid*4B) + let a = pt!(12, top_k * inter * hid / 2 + top_k * inter * 2 + hid * 4, + moe_gather_up_relu2(d, uqs, usc, &xn, &idx_buf, top_k, inter, hid).unwrap()); + // moe_gather_down: reads top_k expert rows (top_k*hid*inter/2 Q4) + scales + a (top_k*inter*4B) + let downs = pt!(13, top_k * hid * inter / 2 + top_k * hid * 2 + top_k * inter * 4, + moe_gather_down(d, dqs, dsc, &a, &idx_buf, top_k, inter, hid).unwrap()); + // moe_weighted_sum: reads downs (top_k*hid*4B) + weights (top_k*4B) + pt!(14, top_k * hid * 4 + top_k * 4, + moe_weighted_sum(d, &downs, &wts_buf, &acc_dev, hid, top_k).unwrap()); + } + // shared expert up qrelu2: Q4 or Q8 (recipe) + let sa = pt!(15, if use_nvfp4_recipe { shared_inter * hid + shared_inter * 4 + hid * 4 } else { shared_inter * hid / 2 + shared_inter * 2 + hid * 4 }, + if use_nvfp4_recipe { qrelu2_q8(&xn, &format!("{p}.mixer.shared_experts.up_proj.weight")) } else { qrelu2(&xn, &format!("{p}.mixer.shared_experts.up_proj.weight")) }); + // shared expert down qacc: Q4 or Q8 (recipe) + pt!(16, if use_nvfp4_recipe { hid * shared_inter + hid * 4 + shared_inter * 4 } else { hid * shared_inter / 2 + hid * 2 + shared_inter * 4 }, + if use_nvfp4_recipe { qacc_q8(&sa, &format!("{p}.mixer.shared_experts.down_proj.weight"), &acc_dev, &up(&[1.0f32])) } else { qacc(&sa, &format!("{p}.mixer.shared_experts.down_proj.weight"), &acc_dev, &up(&[1.0f32])) }); + // add residual + xt = pt!(28, hid * 8, add(d, &xt, &acc_dev).unwrap()); + } + '*' => { + // q/k/v + RoPE stay ON-DEVICE; append k,v straight into the + // device KV cache; sdpa reads the cache. No host KV traffic. + // q_proj gemv_q4: reads Q4 (qdim*hid/2) + scales + x (hid*4B) + let q_raw = pt!(18, qdim * hid / 2 + qdim * 2 + hid * 4, + qmv(&xn, &format!("{p}.mixer.q_proj.weight"))); + // k_proj gemv_q4 + let k_raw = pt!(19, kvdim * hid / 2 + kvdim * 2 + hid * 4, + qmv(&xn, &format!("{p}.mixer.k_proj.weight"))); + // v_proj gemv_q4 + let v_raw = pt!(20, kvdim * hid / 2 + kvdim * 2 + hid * 4, + qmv(&xn, &format!("{p}.mixer.v_proj.weight"))); + // rope: reads q (nq*hd*4B) + let q = pt!(17, nq * hd * 4, + rope_llama(d, &q_raw.reshaped(vec![nq, hd]), pos as u32, rope_theta, 1.0, 1.0, 1.0, 8192.0).unwrap()); + // rope: reads k (nkv*hd*4B) + let k = pt!(17, nkv * hd * 4, + rope_llama(d, &k_raw.reshaped(vec![nkv, hd]), pos as u32, rope_theta, 1.0, 1.0, 1.0, 8192.0).unwrap()); + let v = v_raw; + // F16 KV: halve the 32K KV read (sdpa = 34% of GPU). Cache + q/k/v in + // f16, 2pass reads them natively (registry-tested f16), attn→f32 for o_proj. + let (q, k, v) = if f16kv { + // cast f32→f16: reads (nq*hd*4B + nkv*hd*4B + nkv*hd*4B) + let qh = pt!(30, nq * hd * 4, cast_f32_f16(d, &q).unwrap()); + let kh = pt!(30, nkv * hd * 4, cast_f32_f16(d, &k).unwrap()); + let vh = pt!(30, nkv * hd * 4, cast_f32_f16(d, &v).unwrap()); + (qh, kh, vh) + } else { (q, k, v) }; + if kvcache[l].is_none() { + kvcache[l] = if f16kv { + let zf16 = || Tensor::new(d.alloc_zeroed(nkv * cap * hd * 2).unwrap(), vec![nkv * cap * hd], DType::F16); + Some((zf16(), zf16())) + } else { + Some((Tensor::new(d.alloc_zeroed(nkv * cap * hd * 4).unwrap(), vec![nkv * cap * hd], DType::F32), Tensor::new(d.alloc_zeroed(nkv * cap * hd * 4).unwrap(), vec![nkv * cap * hd], DType::F32))) + }; + } + let (kcache, vcache) = kvcache[l].as_ref().unwrap(); + let pb = u32buf(pos as u32); + // kv_append: writes nkv*hd floats into cache + pt!(21, nkv * hd * if f16kv { 2 } else { 4 }, + kv_append(d, &k, kcache, &pb, hd, cap, nkv * hd).unwrap()); + pt!(21, nkv * hd * if f16kv { 2 } else { 4 }, + kv_append(d, &v, vcache, &pb, hd, cap, nkv * hd).unwrap()); + let len = (pos + 1) as u32; + // SDPA bytes at 32K: reads K (nkv*len*hd bytes) + V (nkv*len*hd bytes) + q (nq*hd bytes) + let kv_bytes = 2 * nkv * (len as usize) * hd * if f16kv { 2 } else { 4 }; + let q_bytes = nq * hd * if f16kv { 2 } else { 4 }; + // Diagnostic: skip the sdpa (placeholder output, right shape) to measure + // its wall-time contribution via the internal A/B (NEMOTRON_AB=NEMOTRON_SKIPSDPA). + let attn = if std::env::var("NEMOTRON_SKIPSDPA").is_ok() { + q.clone() + } else if !no_2pass && len > 1024 { + // NEMOTRON_BLOCKS overrides the split-K block count. + // GB10 sweep: 128 ≈ optimal at all ctx (256 within noise, both + // beat 512/1024). Default 128; blocks MUST be a multiple of 32. + let blocks: u32 = env("NEMOTRON_BLOCKS", 128usize) as u32; + // NEMOTRON_BC4=1: BC=4 pass-1 (batched 4 positions/iter). + // NEMOTRON_TILED=1: tiled pass-1 (contiguous chunks, L2-friendly). + let use_bc4 = std::env::var("NEMOTRON_BC4").is_ok(); + let use_tiled = std::env::var("NEMOTRON_TILED").is_ok(); + let a = if use_bc4 { + pt!(22, kv_bytes + q_bytes, sdpa_decode_2pass_bc4(d, &q, &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, len, cap as u32, (nq / nkv) as u32, ascale, blocks).unwrap()) + } else if use_tiled { + pt!(22, kv_bytes + q_bytes, sdpa_decode_2pass_tiled(d, &q, &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, len, cap as u32, (nq / nkv) as u32, ascale, blocks).unwrap()) + } else { + pt!(22, kv_bytes + q_bytes, sdpa_decode_2pass(d, &q, &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, len, cap as u32, (nq / nkv) as u32, ascale, blocks).unwrap()) + }; + if f16kv { pt!(30, qdim * 2, cast_f16_f32(d, &a).unwrap()) } else { a } + } else { + let a = pt!(22, kv_bytes + q_bytes, sdpa_decode(d, &q, &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, len, cap as u32, (nq / nkv) as u32, ascale).unwrap()); + if f16kv { pt!(30, qdim * 2, cast_f16_f32(d, &a).unwrap()) } else { a } + }; + // o_proj gemv_q4/q8: recipe → Q8 + let o = pt!(23, if use_nvfp4_recipe { hid * qdim + hid * 4 + qdim * 4 } else { hid * qdim / 2 + hid * 2 + qdim * 4 }, + if use_nvfp4_recipe { qmv8(&attn.reshaped(vec![qdim]), &format!("{p}.mixer.o_proj.weight")) } else { qmv(&attn.reshaped(vec![qdim]), &format!("{p}.mixer.o_proj.weight")) }); + xt = pt!(29, hid * 8, add(d, &xt, &o).unwrap()); + } + _ => unreachable!(), + } + // Coarse section timing (backward compat display) + if prof { + let c = match mix { 'M' => &tm, 'E' => &te, _ => &ta }; + let _ = c; // section totals now computed from fine-grained op_t + } + // INSTRUMENTATION (revert): dump this token's hidden after layer l. + // Overwritten every step → after the seq loop holds the LAST token trace. + if std::env::var("NEMOTRON_DUMP_LAYERS").is_ok() { + let h = dl(&xt, hid); + STEP_LAYER_TRACE.with(|c| { let mut v = c.borrow_mut(); if v.len() <= l { v.resize(l+1, Vec::new()); } v[l] = h; }); + } + } + // Final norm + lm_head + let xf = pt!(25, hid * 8, rms_norm(d, &xt, &fwd["norm_f"], eps).unwrap()); + // Gate the final argmax download: forbidden during CUDA-graph capture (host-sync). + // During capture and graph replay we return a dummy 0 — the bench only measures + // throughput, not token correctness. + if skip_dl.get() { + pt!(26, vocab * hid / 2 + vocab * 2 + hid * 4, + { let _ = qmv(&xf, "language_model.lm_head.weight"); }); // keep lm_head in graph + if prof { d.synchronize().ok(); step_wall.set(step_wall.get() + step_t0.unwrap().elapsed().as_secs_f64()); step_count.set(step_count.get() + 1); } + return 0; + } + let logits = dl(&pt!(26, vocab * hid / 2 + vocab * 2 + hid * 4, + qmv(&xf, "language_model.lm_head.weight")), vocab); + if prof { + d.synchronize().ok(); + step_wall.set(step_wall.get() + step_t0.unwrap().elapsed().as_secs_f64()); + step_count.set(step_count.get() + 1); + } + LAST_STEP_LOGITS.with(|c| *c.borrow_mut() = logits.clone()); // INSTRUMENTATION: revert + ffai_runtime::argmax(&logits) + }; + + let mut tok = 1234usize; + let mut pos = 0usize; + // For the 32K measurement, start at `fakectx` (the device KV cache is alloc'd + // to `cap` and zero-filled on first use, so the sdpa genuinely reads `fakectx` + // cached positions — the real 32K work — without a 38-min token prefill). + if fakectx > 0 { pos = fakectx; } + // warm the cache to `prefill` positions, then time `n_decode` steps. + // PHASE-0 BASELINE: time the sequential-prefill loop so we can report the + // current prompt-processing tok/s (= prefill / prefill_seconds). This is the + // ~67 tok/s sequential-decode-as-prefill number we're trying to beat ~95× + // with a batched forward. One untimed warm step first (JIT) so we don't + // charge first-launch compilation to the prefill measurement. + // ── BATCHED PREFILL (NEMOTRON_PREFILL_BATCHED=1) ───────────────────────── + // Process all S=prefill prompt tokens in ONE forward instead of S sequential + // decode steps. Compute-bound path: dequant Q4 projection weights → f32 and + // run the tensor-tiled `ffai_gemm` (matmul) over [S, *]; causal prefill + // flash-attn (sdpa_multi) over S; SSD scan (ssm_prefill_scan) over S; per- + // token MoE gather loop (correctness-first). Writes the SAME conv_dev / + // ssm_state / kvcache the sequential `step()` uses, so a following decode + // step continues correctly. Returns the next-token argmax after position S-1 + // (the same token the sequential path would produce). Token sequence is the + // greedy chain seeded by `tok` (matching the sequential warm-up). + thread_local! { static LAST_BATCHED_LOGITS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; } + // Device logits tensor (no dl) + capture flag — for CUDA-graphing the forward. + thread_local! { static LAST_BATCHED_LOGITS_DEV: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; } + thread_local! { static CAPTURING: std::cell::Cell = const { std::cell::Cell::new(false) }; } + let prefill_batched = std::env::var("NEMOTRON_PREFILL_BATCHED").is_ok(); + if prefill_batched && prefill > 0 { + let s = prefill; // number of prompt tokens + // f16 weight cache (Path A): dequant each Q4 weight to f16 ONCE (lazily on + // first use), keyed by name (+ expert id for MoE). The forward then feeds + // the cached f16 weight straight to cuBLAS — eliminates the ~26%-of-prefill + // redundant per-forward dequant. ~2× the GEMM-weight VRAM; fine on GB10. + // Disable with NEMOTRON_NO_W16CACHE=1 (A/B). + let w16: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + let no_w16 = std::env::var("NEMOTRON_NO_W16CACHE").is_ok(); + // NEMOTRON_RESIDENT_W16=1: ALSO cache the routed-expert up/down f16 weights + // resident (not just dense+shared). ~44.8 GB extra f16 on GB10 (62 GB total + // resident). Kills the routed-expert per-forward dequant (the remaining + // ~42% of prefill) at the cost of streaming a fat f16 expert pool from the + // unified-memory pool. Honest A/B lever — measure dequant%→0 vs the GEMM + // becoming bandwidth-bound on the larger f16 working set. + let resident_w16 = std::env::var("NEMOTRON_RESIDENT_W16").map(|v| v != "0" && v != "false").unwrap_or(false); + // Greedy token chain (same as sequential): we need the S token ids. The + // sequential path feeds argmax-of-previous; to reproduce it EXACTLY for + // the correctness gate we run the sequential path's token chain here too + // would defeat the purpose. Instead the gate runs both paths on the same + // FIXED token list. For prefill we use the deterministic ramp tok, tok+1… + // is NOT how decode chains; so the gate harness (NEMOTRON_PREFILL_CHECK) + // builds an explicit id list. Here we accept an id list via closure. + // ── Prefill per-op profiler (NEMOTRON_PROFILE=1) ───────────────────── + // Sync-bracketed wall time + call count + FLOP estimate per op category. + // Categories: 0=embed,1=rms_norm,2=proj_gemm(dequant+matmul),3=dequant, + // 4=conv_prefill(CONV_DEVICE=on-device causal conv+silu; off=host ring-conv), + // 5=ssm_scan,6=softplus_add_rows(dt prep),7=sdpa,8=rope,9=kv_append, + // 10=moe_router,11=moe_experts,12=moe_shared,13=add,14=lm_head,15=slice/cast. + const NPF: usize = 16; + let pf_t: Vec> = (0..NPF).map(|_| std::cell::Cell::new(0.0)).collect(); + let pf_n: Vec> = (0..NPF).map(|_| std::cell::Cell::new(0)).collect(); + let pf_flop: Vec> = (0..NPF).map(|_| std::cell::Cell::new(0.0)).collect(); + let pf_on = std::env::var("NEMOTRON_PROFILE").is_ok(); + // Device-resident constants cache: per-layer constant vectors (dt_bias, + // router correction bias) + tiny index tensors, uploaded ONCE and reused + // across layers AND forwards. These were re-uploaded per layer per + // forward — ~140 small pageable H2D copies/forward, each paying ~0.3ms + // of driver API time on GB10. + let const_dev: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); + // NEMOTRON_FP4_DENSE=1: dense projections (qkv/o, mamba in/out_proj) + // through the cublasLt block-scaled NVFP4 GEMM (~4x the f16 tensor-core + // path on GB10). Weight pack cached here across forwards (quantized + // once from the dequanted f16 weight); activations quantized per call. + let w4lt: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); + // Single-slot act-quant dedup: q/k/v (and any same-input projections) + // quantize the SAME activation tensor — cache the last (input, dims) -> + // (pack, sf). Holding the input Tensor keeps its buffer alive, so the + // pointer-identity check can't alias a pool-recycled buffer. + let w4g: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + let actq_slot: std::cell::RefCell> = + std::cell::RefCell::new(None); + // Batched forward over an explicit token-id slice; writes states; returns + // the argmax logits for the LAST token. + let forward_batched = |ids: &[usize], + conv_dev: &std::cell::RefCell>>, + ssm_state: &mut Vec>, + kvcache: &mut Vec>, + base: usize| -> usize { + let s = ids.len(); + // Backend gate: the CUDA-only escape hatches (gemm_cublas/cublasGemmEx, + // moe_grouped_gemm/moe_scatter_add via dispatch_raw_cuda, the Marlin MoE, + // and the SSD/TC-flash cuBLAS paths) hard-error on Metal. When the device + // is NOT CUDA we route the projection/MoE/shared GEMMs through the portable + // primitives that codegen to Metal hardware MMA: gemm_q4_mpp (Q4-native + // cooperative-tensor MMA) for the dense projections, and dequant_q4_off + + // matmul (f32 MMA) for the per-expert / shared-expert GEMMs. The scan + // (ssm_prefill_scan), attention (sdpa_multi), conv (host-bridge) and router + // (host top-k) defaults are already portable at d0/S≤4096. CUDA path is + // left byte-for-byte unchanged — gated purely on `is_cuda`. + let is_cuda = d.backend() == Backend::Cuda; + // Fetch-or-upload a device-resident constant (cached across forwards). + let cdev = |key: String, make: &dyn Fn() -> Tensor| -> Tensor { + if let Some(t) = const_dev.borrow().get(&key) { return t.clone(); } + let t = make(); + const_dev.borrow_mut().insert(key, t.clone()); + t + }; + // ── Packed multi-sequence prefill (NEMOTRON_PACKED=N) ────────────── + // N equal-length sequences (seg_len = s/N, a multiple of the SSD chunk + // len) packed into one token stream. Attention goes block-diagonal via + // sdpa_multi_tc_varlen (per-token segment-start `seg_lo`), and the Mamba + // SSD scan resets recurrent state at each boundary (`seg_reset[chunk]`). + // proj/MoE/router/norm are token-parallel → unchanged. RoPE is relative + // so global positions give correct intra-segment rotation. NOTE: the + // causal conv1d is NOT yet segment-aware (kc-1≈3 tokens/segment leak + // across the boundary) — throughput path until the varlen conv lands. + let n_seg = std::env::var("NEMOTRON_PACKED").ok() + .and_then(|v| v.parse::().ok()).unwrap_or(1).max(1); + let ssd_l_pk = std::env::var("NEMOTRON_SSD_L").ok() + .and_then(|v| v.parse::().ok()).unwrap_or(128); + let packed = is_cuda && n_seg > 1 && s % n_seg == 0 && (s / n_seg) % ssd_l_pk == 0; + let (seg_lo_dev, seg_reset_dev) = if packed { + let seg_len = s / n_seg; + let seg_lo_h: Vec = (0..s).map(|r| ((r / seg_len) * seg_len) as u32).collect(); + let seg_lo = Tensor::new(d.upload(unsafe { + std::slice::from_raw_parts(seg_lo_h.as_ptr() as *const u8, s * 4) }).unwrap(), + vec![s], DType::U32); + let nc = s.div_ceil(ssd_l_pk); + let seg_reset_h: Vec = (0..nc) + .map(|c| if (c * ssd_l_pk) % seg_len == 0 { 1u32 } else { 0u32 }).collect(); + let seg_reset = Tensor::new(d.upload(unsafe { + std::slice::from_raw_parts(seg_reset_h.as_ptr() as *const u8, nc * 4) }).unwrap(), + vec![nc], DType::U32); + (Some(seg_lo), Some(seg_reset)) + } else { (None, None) }; + // Per-segment length (KV-block size) → enables the attention segment-skip. + let pk_seg_len: u32 = if packed { (s / n_seg) as u32 } else { 0 }; + // pf!(idx, flops, expr): sync-bracket + accumulate when profiling. + macro_rules! pf { ($idx:expr, $flops:expr, $e:expr) => {{ + if pf_on { d.synchronize().ok(); let _t0 = Instant::now(); let _r = $e; d.synchronize().ok(); + pf_t[$idx].set(pf_t[$idx].get() + _t0.elapsed().as_secs_f64()); + pf_n[$idx].set(pf_n[$idx].get() + 1); + pf_flop[$idx].set(pf_flop[$idx].get() + ($flops) as f64); _r + } else { $e } + }}; } + // Embed S tokens → [S, hid] f32 resident. + // Embed cached device-side keyed by the id list: the upload sourced a + // stack-local host Vec, so a CUDA-graph capture recorded a memcpy node + // from a pointer that's dangling by replay time. Cached, the pre-warm + // forwards fill it and the captured forward does NO embed H2D at all. + let mut xt = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + ids.hash(&mut h); + let key = format!("__embed.{:x}.{}", h.finish(), s); + cdev(key, &|| { + let mut xh = vec![0.0f32; s * hid]; + for (i, &t) in ids.iter().enumerate() { xh[i*hid..(i+1)*hid].copy_from_slice(&embed[t*hid..(t+1)*hid]); } + upm(&xh, vec![s, hid]) + }) + }; + // Tensor-core projection GEMM (Path A): dequant Q4 weight → f16 once, + // cast x → f16, cuBLAS GemmEx (real tensor cores, f32 accumulate), + // cast result → f32. Replaces the software-emulated coop_tile MMA + // (~0.1% of peak). NEMOTRON_PREFILL_MPPGEMM=1 = old coop_tile path; + // NEMOTRON_PREFILL_F32GEMM=1 = f32 scalar matmul (A/B). + let use_f32_gemm = std::env::var("NEMOTRON_PREFILL_F32GEMM").is_ok(); + let use_mpp_gemm = std::env::var("NEMOTRON_PREFILL_MPPGEMM").is_ok(); + // ── Metal per-expert MoE GEMM compute dtype (Apple/Metal only) ── + // The per-forward Q4→dtype dequant of the routed experts × 23 MoE layers is a + // top prefill cost at small S. On Metal we dequant→f16 and run the per-expert + // matmul in f16 (vs the old f32 dequant + f32 matmul): the f16 dequant writes + // 2 B/elem (vs 4 B) and the f16 matmul reads 2 B weights, so it beats the f32 + // baseline (M5 Max: S=512 +26.6%, S=2048 98.8→113.5 tok/s) at EXACT-MATCH + // numerics (argmax unchanged vs the f32 sequential ref, 2× repeat identical). + // f16-compute is the Metal DEFAULT; set NEMOTRON_METAL_F32_EXPERTS=1 to fall + // back to the f32 path for A/B. No resident f16 weight cache: caching the f16 + // experts resident was measured a NET LOSS on Metal (bandwidth/residency bound + // — re-dequanting the compact Q4 each forward streams better than a fat f16 + // working set). CUDA path is unaffected (gated on `is_cuda` below). + let metal_f16_experts = !is_cuda && !std::env::var("NEMOTRON_METAL_F32_EXPERTS").is_ok(); + // Cached Q4→f16 dequant. On miss, dequant the [m,k] slab (optionally at + // a block offset for one MoE expert) and store under `key`. Returns a + // cheap Tensor clone (Arc buffer). `cache=false` (or NEMOTRON_NO_W16CACHE) + // → always dequant, no store. We CACHE the dense projection + shared + // weights (small, always used: ~few GB) by default. The 128 routed + // experts ×23 layers (~44.8 GB f16) re-dequant per forward UNLESS + // NEMOTRON_RESIDENT_W16=1 (`resident_w16`), which also caches them + // resident — fits GB10's 121 GB unified pool (~62 GB total resident). + let deq16c = |key: &str, qs: &Tensor, sc: &Tensor, m: usize, k: usize, blk_off: usize, cache: bool| -> Tensor { + if no_w16 || !cache { + return pf!(3, (m * k) as f64, dequant_q4_off(d, qs, sc, m, k, DType::F16, blk_off).unwrap()); + } + if let Some(w) = w16.borrow().get(key) { return w.clone(); } + let w = pf!(3, (m * k) as f64, dequant_q4_off(d, qs, sc, m, k, DType::F16, blk_off).unwrap()); + w16.borrow_mut().insert(key.to_string(), w.clone()); + w + }; + let deq16 = |key: &str, qs: &Tensor, sc: &Tensor, m: usize, k: usize, blk_off: usize| -> Tensor { + deq16c(key, qs, sc, m, k, blk_off, true) + }; + // FP4 dense projections (NEMOTRON_FP4_DENSE=1): quantize the f16 + // activation per call, weight pack cached cross-forward; cublasLt + // block-scaled NVFP4 GEMM with f32 output (fused output cast). + // Value selects scope: "1"/"all" = every dense projection; + // "mamba" = in/out_proj only; "qkv" = attention q/k/v/o only. + let fp4_dense_scope = if is_cuda { std::env::var("NEMOTRON_FP4_DENSE").ok() } else { None }; + let fp4_dense = fp4_dense_scope.is_some(); + let fp4_name_ok = move |name: &str| -> bool { + match fp4_dense_scope.as_deref() { + Some("mamba") => name.contains("in_proj") || name.contains("out_proj"), + Some("qkv") => name.contains("q_proj") || name.contains("k_proj") + || name.contains("v_proj") || name.contains("o_proj"), + // audit: in_proj carries the worst FP4 quantization error + // (normed-residual outliers) — exclude it, FP4 the rest. + Some("noin") => !name.contains("in_proj"), + Some("out") => name.contains("out_proj"), + Some(_) => true, + None => false, + } + }; + let qmm_fp4 = |xh: &Tensor, name: &str, rows: usize, m: usize, k: usize, + qs: &Tensor, sc: &Tensor| -> Tensor { + // GLOBAL-AWARE: per-tensor global keeps ue4m3 block scales in + // normal range (was the subnormal-floor bug = cosine 0.44); + // the act·weight globals fold back via the GEMM D-scale. + let (wp, wsf, wg) = { + if let (Some(t), Some(g)) = (w4lt.borrow().get(name).cloned(), w4g.borrow().get(name).cloned()) { (t.0, t.1, g) } else { + let wf = pf!(3, (m * k) as f64, dequant_q4_off(d, qs, sc, m, k, DType::F16, 0).unwrap()); + let (wp, wsf, wg) = ffai_ops::lt_fp4_quant_g(d, &wf, m, k).unwrap(); + w4lt.borrow_mut().insert(name.to_string(), (wp.clone(), wsf.clone())); + w4g.borrow_mut().insert(name.to_string(), wg.clone()); + (wp, wsf, wg) + } + }; + let (xp, xsf, xg) = { + let hit = actq_slot.borrow().as_ref().map_or(false, |(cx, cr, ck, _, _, _)| + *cr == rows && *ck == k && std::sync::Arc::ptr_eq(&cx.buffer, &xh.buffer)); + if hit { + let s0 = actq_slot.borrow(); + let (_, _, _, p, f, g) = s0.as_ref().unwrap(); + (p.clone(), f.clone(), g.clone()) + } else { + let t = pf!(15, 0.0, ffai_ops::lt_fp4_quant_g(d, xh, rows, k).unwrap()); + *actq_slot.borrow_mut() = Some((xh.clone(), rows, k, t.0.clone(), t.1.clone(), t.2.clone())); + t + } + }; + let dscale = ffai_ops::fp4_dscale(d, &xg, &wg).unwrap(); + let o = pf!(2, 2.0 * rows as f64 * m as f64 * k as f64, + ffai_ops::gemm_fp4(d, &xp, &xsf, &wp, &wsf, rows, m, k, true, Some(&dscale)).unwrap()); // [rows, m] f32 + if std::env::var("NEMOTRON_FP4_DENSE_DEBUG").is_ok() { + // numeric audit vs the f16 path (bench-only; huge max|d| = layout bug, + // few-percent rel = expected FP4 quantization noise) + let wf = deq16(name, qs, sc, m, k, 0); + let r16 = gemm_cublas_f32out(d, xh, &wf, rows, m, k).unwrap(); + let a = dl(&o, rows * m); let b = dl(&r16, rows * m); + let (mut mab, mut mrel, mut den) = (0f32, 0f32, 0f32); + for i in 0..a.len() { let d0 = (a[i] - b[i]).abs(); mab = mab.max(d0); den = den.max(b[i].abs()); if b[i].abs() > 1.0 { mrel = mrel.max(d0 / b[i].abs()); } } + eprintln!("FP4_DENSE_DEBUG {name}: rows={rows} m={m} k={k} max|d|={mab:.4} max_rel(>1)={mrel:.4} max|ref|={den:.2}"); + } + o + }; + // FP8 dense (NEMOTRON_FP8_DENSE): the vendor recipe for this model + // family runs Mamba in/out_proj (+ o_proj, shared) at FP8 e4m3 with + // per-tensor scales — far gentler than FP4. Scopes: "mamba" + // (in/out_proj), "recipe" (+ o_proj), "all". + let fp8_dense_scope = if is_cuda { std::env::var("NEMOTRON_FP8_DENSE").ok() } else { None }; + let fp8_dense = fp8_dense_scope.is_some(); + let fp8_name_ok = move |name: &str| -> bool { + match fp8_dense_scope.as_deref() { + Some("mamba") => name.contains("in_proj") || name.contains("out_proj"), + Some("recipe") => name.contains("in_proj") || name.contains("out_proj") || name.contains("o_proj"), + Some(_) => true, + None => false, + } + }; + let qmm_fp8d = |xh: &Tensor, name: &str, rows: usize, m: usize, k: usize, + qs: &Tensor, sc: &Tensor| -> Tensor { + // PER-CHANNEL weight (rows=m) + PER-TOKEN act (rows) FP8, scales + // folded post-GEMM (D[r,c]*=xsc[r]·wsc[c]) — the quality- + // preserving granularity (per-tensor was cosine ~0.94). + let (wq, wsc) = { + if let Some(t) = w4lt.borrow().get(&format!("{name}.fp8c")) { t.clone() } else { + let wf = pf!(3, (m * k) as f64, dequant_q4_off(d, qs, sc, m, k, DType::F16, 0).unwrap()); + let t = ffai_ops::fp8_quant_perrow(d, &wf, m, k).unwrap(); + w4lt.borrow_mut().insert(format!("{name}.fp8c"), t.clone()); + t + } + }; + let (xq, xsc) = pf!(15, 0.0, ffai_ops::fp8_quant_perrow(d, xh, rows, k).unwrap()); + pf!(2, 2.0 * rows as f64 * m as f64 * k as f64, + ffai_ops::gemm_fp8_perchan(d, &xq, &xsc, &wq, &wsc, rows, m, k).unwrap()) // [rows, m] f32 + }; + let qmm = |x: &Tensor, name: &str| -> Tensor { + let (qs, sc, m, k) = &qw[name]; + let rows = x.elem_count() / *k; + let flops = 2.0 * rows as f64 * *m as f64 * *k as f64; + if fp8_dense && rows > 1 && fp8_name_ok(name) { + let xh = pf!(15, 0.0, cast_f32_f16(d, x).unwrap()); + return qmm_fp8d(&xh, name, rows, *m, *k, qs, sc); + } + if fp4_dense && rows > 1 && *k % 32 == 0 && fp4_name_ok(name) { + // f32 input quantizes directly inside qmm_fp4 (no cast staging) + return qmm_fp4(x, name, rows, *m, *k, qs, sc); + } + if use_f32_gemm { + let wf = pf!(3, (m * k) as f64, dequant_q4(d, qs, sc, *m, *k, DType::F32).unwrap()); + pf!(2, flops, matmul(d, &wf, x).unwrap()) // [rows, m] + } else if use_mpp_gemm || !is_cuda { + // Portable Metal path (also NEMOTRON_PREFILL_MPPGEMM): Q4-native + // cooperative-tensor MMA — no cuBLAS, runs on Apple GPU hardware MMA. + let xh = pf!(15, 0.0, cast_f32_f16(d, x).unwrap()); + let oh = pf!(2, flops, gemm_q4_mpp(d, &xh, qs, sc, rows, *m, *k).unwrap()); + pf!(15, 0.0, cast_f16_f32(d, &oh).unwrap()) // [rows, m] + } else { + // cuBLAS tensor-core path: out[rows,m] = x[rows,k] · W[m,k]ᵀ. + let wf = deq16(name, qs, sc, *m, *k, 0); + let xh = pf!(15, 0.0, cast_f32_f16(d, x).unwrap()); + // Fused output cast: cuBLAS writes f32 directly (accumulate is + // already f32) — drops the separate cast_f16_f32, MFU preserved. + pf!(2, flops, gemm_cublas_f32out(d, &xh, &wf, rows, *m, *k).unwrap()) // [rows, m] f32 + } + }; + // Like `qmm` but takes a PRE-CAST f16 activation `xh` (skips the input + // cast_f32_f16). Lets multiple projections over the SAME normed input + // (q/k/v from `xn`, shared-expert up from `xn`) share one f16 cast + // instead of re-casting per projection — kills redundant slice/cast. + // cuBLAS tensor-core path only (the fast prefill path). + let qmm_h = |xh: &Tensor, name: &str| -> Tensor { + let (qs, sc, m, k) = &qw[name]; + let rows = xh.elem_count() / *k; + let flops = 2.0 * rows as f64 * *m as f64 * *k as f64; + if fp8_dense && rows > 1 && fp8_name_ok(name) { + return qmm_fp8d(xh, name, rows, *m, *k, qs, sc); + } + if fp4_dense && rows > 1 && *k % 32 == 0 && fp4_name_ok(name) { + return qmm_fp4(xh, name, rows, *m, *k, qs, sc); + } + if !is_cuda { + // Portable Q4-native Metal MMA (xh already f16). + let oh = pf!(2, flops, gemm_q4_mpp(d, xh, qs, sc, rows, *m, *k).unwrap()); + return pf!(15, 0.0, cast_f16_f32(d, &oh).unwrap()); // [rows, m] + } + let wf = deq16(name, qs, sc, *m, *k, 0); + // Fused output cast: cuBLAS writes f32 directly — no cast_f16_f32. + pf!(2, flops, gemm_cublas_f32out(d, xh, &wf, rows, *m, *k).unwrap()) // [rows, m] f32 + }; + // Fuse-slice-cast opt-in (NEMOTRON_FUSE_QKV=1): cast xn→f16 once per + // attention/shared block and reuse via qmm_h. Default off (A/B). + let fuse_qkv = std::env::var("NEMOTRON_FUSE_QKV").is_ok() && !use_f32_gemm && !use_mpp_gemm; + // Pre-allocate grouped-GEMM weight scratch buffers (max size, reused across layers). + // Avoids per-layer cuMemAlloc which is expensive. Allocated once at max expert count. + // UP scratch: [n_exp * inter, hid] f16 (for all 128 experts, even if only ~107 active). + // DN scratch: [n_exp * hid, inter] f16. + // These are bounded: 128 * 1856 * 4096 * 2 ≈ 1.83 GB each, ≈ 3.67 GB total. + // Pre-alloc grouped-gemm scratch for GROUPED_GEMM and W4A16 (which falls back + // to grouped_gemm at large S — needs scratch to avoid per-layer cuMemAlloc). + let needs_grouped_scratch = (std::env::var("NEMOTRON_GROUPED_GEMM").is_ok() && !std::env::var("NEMOTRON_BGEMM").is_ok()) + || std::env::var("NEMOTRON_W4A16").is_ok() + || std::env::var("NEMOTRON_W4A16_MARLIN").is_ok(); + let grouped_up_scratch: Option = if needs_grouped_scratch { + Some(Tensor::new(d.alloc(n_exp * inter * hid * 2).unwrap(), vec![n_exp * inter, hid], DType::F16)) + } else { None }; + let grouped_dn_scratch: Option = if needs_grouped_scratch { + Some(Tensor::new(d.alloc(n_exp * hid * inter * 2).unwrap(), vec![n_exp * hid, inter], DType::F16)) + } else { None }; + // xn carried from the previous layer's fused add+rms_norm (None on layer 0 + // or after a non-fusable tail). + let mut xn_carry: Option = None; + for (l, mix) in PATTERN.chars().enumerate() { + let p = format!("language_model.backbone.layers.{l}"); + let xn = match xn_carry.take() { + Some(t) => t, + None => pf!(1, 0.0, rms_norm(d, &xt, &fwd[&format!("{p}.norm.weight")], eps).unwrap()), // [s, hid] + }; + match mix { + 'M' => { + // ── NEMOTRON_CONV_DEVICE=1: fully on-device conv (no host round-trip) ── + // proj stays on device; strided_col_copy carves z/xbc/dt_raw on GPU; + // conv1d_causal_prefill + softplus_add_rows run the conv+silu+softplus + // without any dl/up. Saves ~42 MB of PCIe per Mamba layer per forward. + // DEFAULT-ON for CUDA (the on-device-MoE prefill win requires it; + // argmax-exact, ~2.5× over host ring-conv at S=2048 d0). Metal keeps + // the host ring-conv (its validated path). Escapes: + // NEMOTRON_CONV_DEVICE=1 forces on (Metal opt-in / explicit); + // NEMOTRON_CONV_DEVICE_OFF=1 forces the host ring-conv on CUDA. + let use_conv_device = std::env::var("NEMOTRON_CONV_DEVICE").is_ok() + || (is_cuda && std::env::var("NEMOTRON_CONV_DEVICE_OFF").is_err()); + if use_conv_device { + let proj_t = pf!(2, 2.0 * s as f64 * in_proj_out as f64 * hid as f64, + qmm(&xn, &format!("{p}.mixer.in_proj.weight"))); // [s, in_proj_out] on device + // PART B: fused split — 1 dispatch replaces 3 strided_col_copy calls. + // proj layout: [z(di) | xbc(conv_dim) | dt_raw(m_nh)] per row. + let (z_t, xbc_t, dt_raw_t) = pf!(15, 0.0, + mamba_split_proj(d, &proj_t, s, in_proj_out, di, conv_dim, m_nh).unwrap()); + // On-device causal depthwise conv1d + silu over all S tokens. + // Result: yc_silu [s * conv_dim] flat. + let yc_silu = pf!(4, 0.0, conv1d_causal_prefill(d, &xbc_t, + &fwd[&format!("{p}.mixer.conv1d.weight")], + &fwd[&format!("{p}.mixer.conv1d.bias")], + s, conv_dim, kc).unwrap()); + // Persist conv ring state for decode continuity: last (kc-1) rows of xbc. + let ring_len = (kc - 1) * conv_dim; + let ring_off = (s.saturating_sub(kc - 1)) * conv_dim; + { let mut cd = conv_dev.borrow_mut(); + cd[l] = Some(slice(d, &xbc_t, ring_off, ring_len).unwrap()); } + // Softplus + dt_bias on device: [s, m_nh] → dt_all [s*m_nh]. + let dt_bias_dev = cdev(format!("{p}.mixer.dt_bias"), &|| up(&fw[&format!("{p}.mixer.dt_bias")])); + let dt_dev = pf!(6, 0.0, softplus_add_rows(d, &dt_raw_t, &dt_bias_dev, s, m_nh).unwrap()); + // SSD scan over S. + if ssm_state[l].is_none() { ssm_state[l] = Some(Tensor::new(d.alloc_zeroed(m_nh * m_dh * ds * 4).unwrap(), vec![m_nh * m_dh * ds], DType::F32)); } + let ssm_flops = s as f64 * m_nh as f64 * m_dh as f64 * ds as f64 * 4.0; + let use_chunked = std::env::var("NEMOTRON_CHUNKED_SCAN").is_ok(); + // SSD chunked-matmul scan is DEFAULT-ON for CUDA (the Nemotron + // (64,128,64,8) cell): tensor-core GEMMs + fused per-group B/C + // cut ssm_scan ~681ms(36%)→75ms(<6%), e2e +~30% @d0, argmax-exact. + // Escape NEMOTRON_SSD_SEQ=1 → old sequential ssm_prefill_scan. + let ssd_seq = std::env::var("NEMOTRON_SSD_SEQ").is_ok(); + let use_ssd = !ssd_seq && (is_cuda + || std::env::var("NEMOTRON_SSD_MATMUL").is_ok() + || std::env::var("NEMOTRON_SSD_FUSED").is_ok()); + let use_ssd_port = std::env::var("NEMOTRON_SSD_PORTABLE").is_ok(); + // CUDA default chunk len 128 (validated win); 256 elsewhere. + let ssd_l: u32 = std::env::var("NEMOTRON_SSD_L").ok().and_then(|v| v.parse().ok()) + .unwrap_or(if is_cuda { 128 } else { 256 }); + let mut ssd_split_out: Option<(Tensor, Tensor)> = None; + // NOTE: a strided-input SSD path (reading x/B/C in place from the + // conv output, no mamba_split_conv) was measured SLOWER (-6-8% e2e, + // argmax exact): scattered column reads from the 6K-wide conv rows + // cost more than the split's streaming write+read. Machinery kept in + // ssm_prefill_scan_ssd_strided; compact split path stays. + { + // PART B: fused split — 1 dispatch replaces 3 strided_col_copy calls. + let (x_dev, b_dev, c_dev) = pf!(15, 0.0, + mamba_split_conv(d, &yc_silu, s, conv_dim, di, ng * ds).unwrap()); + if use_ssd { + ssd_split_out = Some(pf!(5, ssm_flops, ssm_prefill_scan_ssd(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32, ssd_l, seg_reset_dev.as_ref()).unwrap())); + } else if use_ssd_port { + ssd_split_out = Some(pf!(5, ssm_flops, ssm_prefill_scan_ssd_portable(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32, ssd_l).unwrap())); + } else if use_chunked { + ssd_split_out = Some(pf!(5, ssm_flops, ssm_prefill_scan_chunked(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32).unwrap())); + } else { + ssd_split_out = Some(pf!(5, ssm_flops, ssm_prefill_scan(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32).unwrap())); + } + }; + let (so, y_dev) = ssd_split_out.take().unwrap(); + ssm_state[l] = Some(so); + // PART B: on-device batched gated group RMSNorm — eliminates the + // dl(y)+dl(z)+host loop+up(yn) round-trip (2 × s×di = ~42MB per layer). + let ynt = pf!(6, 0.0, gated_group_rmsnorm_batched(d, &y_dev, &z_t, + &up(&fw[&format!("{p}.mixer.norm.weight")]), eps, s, di, gs).unwrap()); + let out = qmm(&ynt.reshaped(vec![s, di]), &format!("{p}.mixer.out_proj.weight")); + { + // fused residual add + NEXT layer's rms_norm (one pass, llama.cpp-style) + let nw = if l + 1 < PATTERN.len() { fwd.get(&format!("language_model.backbone.layers.{}.norm.weight", l + 1)) } else { fwd.get("norm_f") }; + match nw { + Some(w) if is_cuda && std::env::var("NEMOTRON_FUSED_ADDNORM").is_ok() => { let (r, nx) = ffai_ops::add_rms_norm(d, &xt, &out, w, eps).unwrap(); xt = r; xn_carry = Some(nx); } + _ => { xt = add(d, &xt, &out).unwrap(); } + } + } + } else { + let proj = dl(&qmm(&xn, &format!("{p}.mixer.in_proj.weight")), s * in_proj_out); // [s, in_proj_out] + // Host-bridge the Mamba conv + SSD-scan input shuffle (correctness-first). + // Per-token slices: z[di], xbc[conv_dim], dt_raw[m_nh]. + let cw = &fw[&format!("{p}.mixer.conv1d.weight_host")]; // [kc*conv_dim] reorganized + let cb = &fw[&format!("{p}.mixer.conv1d.bias_host")]; // [conv_dim] + // Causal depthwise conv over S (state = (kc-1) zeros prefix at prefill start), + // then silu. Produces xbc_act[s, conv_dim]. + let mut z_all = vec![0.0f32; s * di]; + let mut xssm = vec![0.0f32; s * di]; // [s, H*Dh] + let mut bmat_all = vec![0.0f32; s * ng * ds]; // [s, G*Ds] + let mut cmat_all = vec![0.0f32; s * ng * ds]; + let mut dt_all = vec![0.0f32; s * m_nh]; // [s, H] + let dtb = &fw[&format!("{p}.mixer.dt_bias")]; + // ring conv state carried across tokens within this prefill. + let mut ring = vec![0.0f32; (kc - 1) * conv_dim]; + for ti in 0..s { + let base_p = ti * in_proj_out; + let z = &proj[base_p..base_p + di]; + z_all[ti*di..(ti+1)*di].copy_from_slice(z); + let xbc = &proj[base_p + di..base_p + di + conv_dim]; + let dt_raw = &proj[base_p + di + conv_dim..base_p + di + conv_dim + m_nh]; + // depthwise causal conv1d (kc taps) + silu, per channel. + let mut yc = vec![0.0f32; conv_dim]; + for ch in 0..conv_dim { + let mut acc = cb[ch]; + for kk in 0..kc { + // tap kk reads position (ti - (kc-1) + kk): ring for <0, xbc for current. + let rel = kk as isize - (kc as isize - 1); + let v = if rel < 0 { + let idx = (kc - 1) as isize + rel; // ring slot + ring[idx as usize * conv_dim + ch] + } else { xbc[ch] }; + acc += cw[kk * conv_dim + ch] * v; + } + let a = acc; yc[ch] = a / (1.0 + (-a).exp()); // silu + } + // advance ring: drop oldest, push xbc. + if kc > 1 { ring.drain(0..conv_dim); ring.extend_from_slice(xbc); } + xssm[ti*di..(ti+1)*di].copy_from_slice(&yc[0..di]); + bmat_all[ti*ng*ds..(ti+1)*ng*ds].copy_from_slice(&yc[di..di+ng*ds]); + cmat_all[ti*ng*ds..(ti+1)*ng*ds].copy_from_slice(&yc[di+ng*ds..di+2*ng*ds]); + for hh in 0..m_nh { dt_all[ti*m_nh+hh] = softplus(dt_raw[hh] + dtb[hh]); } + } + // persist conv ring state for decode continuity. + { let mut cd = conv_dev.borrow_mut(); cd[l] = Some(up(&ring)); } + // SSD scan over S: x[s,H*Dh], b/c[s,G*Ds], dt[s,H], state[H*Dh*Ds]. + if ssm_state[l].is_none() { ssm_state[l] = Some(Tensor::new(d.alloc_zeroed(m_nh * m_dh * ds * 4).unwrap(), vec![m_nh * m_dh * ds], DType::F32)); } + let x_dev = up(&xssm); + let b_dev = up(&bmat_all); + let c_dev = up(&cmat_all); + let dt_dev = up(&dt_all); + // SSD FLOPs ≈ s * H * Dh * Ds * 4 (dA*state + dbx, y=state*c) + let ssm_flops = s as f64 * m_nh as f64 * m_dh as f64 * ds as f64 * 4.0; + // NEMOTRON_CHUNKED_SCAN=1: use two-pass parallel segment scan. + // Drops serial depth from T to T/64, exploiting 64× more GPU parallelism. + // Default: sequential step-record (validated, correctness-first). + let use_chunked = std::env::var("NEMOTRON_CHUNKED_SCAN").is_ok(); + // SSD chunked-matmul scan is DEFAULT-ON for CUDA (the Nemotron + // (64,128,64,8) cell): tensor-core GEMMs + fused per-group B/C + // cut ssm_scan ~681ms(36%)→75ms(<6%), e2e +~30% @d0, argmax-exact. + // Escape NEMOTRON_SSD_SEQ=1 → old sequential ssm_prefill_scan. + let ssd_seq = std::env::var("NEMOTRON_SSD_SEQ").is_ok(); + let use_ssd = !ssd_seq && (is_cuda + || std::env::var("NEMOTRON_SSD_MATMUL").is_ok() + || std::env::var("NEMOTRON_SSD_FUSED").is_ok()); + let use_ssd_port = std::env::var("NEMOTRON_SSD_PORTABLE").is_ok(); + // CUDA default chunk len 128 (validated win); 256 elsewhere. + let ssd_l: u32 = std::env::var("NEMOTRON_SSD_L").ok().and_then(|v| v.parse().ok()) + .unwrap_or(if is_cuda { 128 } else { 256 }); + let (so, y_dev) = if use_ssd_port { + pf!(5, ssm_flops, ssm_prefill_scan_ssd_portable(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32, ssd_l).unwrap()) + } else if use_ssd { + pf!(5, ssm_flops, ssm_prefill_scan_ssd(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32, ssd_l, seg_reset_dev.as_ref()).unwrap()) + } else if use_chunked { + pf!(5, ssm_flops, ssm_prefill_scan_chunked(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32).unwrap()) + } else { + pf!(5, ssm_flops, ssm_prefill_scan(d, &x_dev, &fwd[&format!("{p}.mixer.A_log")], &b_dev, &c_dev, &fwd[&format!("{p}.mixer.D")], &dt_dev, ssm_state[l].as_ref().unwrap(), s as u32, m_dh as u32, ds as u32, m_nh as u32, ng as u32).unwrap()) + }; + ssm_state[l] = Some(so); + let y = dl(&y_dev, s * di); // [s, di] + // INSTRUMENTATION (revert): dump L0 last-token SSM in/out (batched). + if l == 0 && std::env::var("NEMOTRON_DUMP_SSM").is_ok() { + let o = (s-1)*di; let og = (s-1)*ng*ds; + let xs = xssm[o..o+di].to_vec(); + let bb = bmat_all[og..og+ng*ds].to_vec(); + let cc = cmat_all[og..og+ng*ds].to_vec(); + let yy = y[o..o+di].to_vec(); + BAT_SSM_DUMP.with(|c| *c.borrow_mut() = (xs, bb, cc, yy)); + } + // gated group rmsnorm per token, then out_proj batched. + let nw = &fw[&format!("{p}.mixer.norm.weight")]; + let mut yn = vec![0.0f32; s * di]; + for ti in 0..s { + let yo = ti*di; + for grp in 0..ng { + let sgrp = grp * gs; let mut ssq = 0.0f32; + for i in 0..gs { let zi = z_all[yo+sgrp+i]; let gg = y[yo+sgrp+i] * (zi/(1.0+(-zi).exp())); yn[yo+sgrp+i]=gg; ssq+=gg*gg; } + let r = 1.0/((ssq/gs as f32)+eps).sqrt(); + for i in 0..gs { yn[yo+sgrp+i] = yn[yo+sgrp+i]*r*nw[sgrp+i]; } + } + } + let ynt = upm(&yn, vec![s, di]); + let out = qmm(&ynt, &format!("{p}.mixer.out_proj.weight")); // [s, hid] + { + // fused residual add + NEXT layer's rms_norm (one pass, llama.cpp-style) + let nw = if l + 1 < PATTERN.len() { fwd.get(&format!("language_model.backbone.layers.{}.norm.weight", l + 1)) } else { fwd.get("norm_f") }; + match nw { + Some(w) if is_cuda && std::env::var("NEMOTRON_FUSED_ADDNORM").is_ok() => { let (r, nx) = ffai_ops::add_rms_norm(d, &xt, &out, w, eps).unwrap(); xt = r; xn_carry = Some(nx); } + _ => { xt = add(d, &xt, &out).unwrap(); } + } + } + } // end !use_conv_device + } + 'E' => { + let bias = &fw[&format!("{p}.mixer.gate.e_score_correction_bias")]; + // ── Router over all S tokens (host top-k, matching decode) ── + // Batched gate gemv: [S, n_exp]. gate.weight is f32 dense. + let gate_w = &fwd[&format!("{p}.mixer.gate.weight")]; + // NEMOTRON_DEVSORT=1 (CUDA): on-device router + counting-sort + // (moe_route_sort_device) — keeps the gate logits ON DEVICE (no + // ~1MB rl_all dl), runs sigmoid+bias+topk + sort on the GPU, then + // reconstructs the SAME `triples` via 3 small dls (offsets, sorted + // tok, sorted wt). Removes the per-E-layer host sigmoid/topk/sort. + // Step toward fully-on-device MoE + CUDA-graph prefill. + let devsort = is_cuda && std::env::var("NEMOTRON_DEVSORT_OFF").is_err(); + let _t_router = std::time::Instant::now(); + // off_dev/st_dev/sw_dev hoisted out (device routing tensors) so the + // Q4 path can run fully on-device (device descriptors + gather + + // scatter) with NO host sync — the prerequisite for CUDA-graphing + // the forward (kill the host-launch GPU idle, the real 3-5× lever). + let mut moe_dev: Option<(Tensor, Tensor, Tensor)> = None; // (offsets, sorted_tok, sorted_wt) + let mut xn_f16_router: Option = None; // f16 xn from the router, reused by the gather + // Fully-on-device MoE (descriptors+gather+scatter all device) → SKIP + // the host triples reconstruction entirely. mt = s*top_k is + // deterministic (every token picks exactly top_k experts) so NO dl is + // needed → the MoE region is host-sync-free. DEFAULT-ON for CUDA + // batched prefill: it kills the per-expert host round-trips that left + // the GPU 56% IDLE on the host-triples path (nsys: 43.6%→98% busy) → + // ~2× prefill (S=2048 925→1820, S=1024 968→2009, S=3072 461→1865 tok/s; + // argmax-matches the host path at S=1024/2048/3072, gold 1104@S2048). + // NEMOTRON_Q4_DEVDESC_OFF=1 reverts to the host-triples path (A/B). + // (Decode is unaffected — this is inside the batched-prefill closure.) + let devdesc_skip = is_cuda && std::env::var("NEMOTRON_Q4_DEVDESC_OFF").is_err(); + let (triples, mt): (Vec<(u32, usize, f32)>, usize) = if devsort { + let bias_dev = cdev(format!("{p}.mixer.gate.e_score_correction_bias"), &|| up(&bias[..])); + // xn cast once to f16: feeds BOTH the cuBLAS router GEMM + // (was software-MMA ffai_gemm, ~28x slower at this shape) + // and the f16 token gather below. + let xn_f16_e = pf!(15, 0.0, cast_f32_f16(d, &xn).unwrap()); + let gate_w16 = &fwd[&format!("{p}.mixer.gate.weight.f16")]; + let rl_dev = pf!(10, 2.0 * s as f64 * n_exp as f64 * hid as f64, + gemm_cublas_f32out(d, &xn_f16_e, gate_w16, s, n_exp, hid).unwrap()); + xn_f16_router = Some(xn_f16_e); + let (st_dev, sw_dev, off_dev) = ffai_ops::moe_route_sort_device(d, &rl_dev, &bias_dev, n_exp, top_k, scale_f).unwrap(); + let m = s * top_k; + let tr: Vec<(u32, usize, f32)> = if devdesc_skip { + Vec::new() // host-free: descriptors/gather/scatter consume device tensors + } else { + let rdu = |t: &Tensor, n: usize| -> Vec { let mut b = vec![0u8; n*4]; d.download(t.buffer.as_ref(), &mut b).unwrap(); b.chunks(4).map(|c| u32::from_le_bytes([c[0],c[1],c[2],c[3]])).collect() }; + let offs = rdu(&off_dev, n_exp + 1); + let stok = rdu(&st_dev, m); + let swt = dl(&sw_dev, m); + let mut tr = Vec::with_capacity(m); + for e in 0..n_exp { for r in offs[e] as usize..offs[e+1] as usize { tr.push((e as u32, stok[r] as usize, swt[r])); } } + tr + }; + moe_dev = Some((off_dev, st_dev, sw_dev)); + (tr, m) + } else { + let rl_all = dl(&pf!(10, 2.0 * s as f64 * n_exp as f64 * hid as f64, matmul(d, gate_w, &xn).unwrap()), s * n_exp); + let mut tr: Vec<(u32, usize, f32)> = Vec::with_capacity(s * top_k); + for ti in 0..s { + let rl = &rl_all[ti*n_exp..(ti+1)*n_exp]; + let sig: Vec = rl.iter().map(|&z| 1.0/(1.0+(-z).exp())).collect(); + let choice: Vec = (0..n_exp).map(|i| sig[i]+bias[i]).collect(); + let eidx = ffai_runtime::topk(&choice, top_k); + let mut w: Vec = eidx.iter().map(|&e| sig[e]).collect(); + let wsum: f32 = w.iter().sum::()+1e-20; for v in w.iter_mut() { *v = *v/wsum*scale_f; } + for (j,&e) in eidx.iter().enumerate() { tr.push((e as u32, ti, w[j])); } + } + tr.sort_by_key(|t| t.0); + let m = tr.len(); + (tr, m) + }; + if std::env::var("NEMOTRON_ROUTER_TIME").is_ok() { + ROUTER_HOST_T.with(|c| c.set(c.get() + _t_router.elapsed().as_secs_f64())); + } + // Expert GEMM mode flags (read early so they're in scope for gather). + let use_bgemm = std::env::var("NEMOTRON_BGEMM").is_ok(); + // NEMOTRON_W4A16=1: W4A16 WMMA grouped GEMM (inline Q4 dequant, + // scattered nibble reads). Supersedes NEMOTRON_BGEMM when both set. + // NEMOTRON_W4A16_MARLIN=1: same as W4A16 but with Marlin coalesced + // tile-major layout — requires weights pre-permuted at load time. + // Supersedes both NEMOTRON_W4A16 and NEMOTRON_BGEMM. + let use_w4a16_marlin = std::env::var("NEMOTRON_W4A16_MARLIN").is_ok(); + let use_w4a16 = std::env::var("NEMOTRON_W4A16").is_ok() || use_w4a16_marlin; + let use_bgemm = use_bgemm && !use_w4a16; + // On non-CUDA (Metal): the bm64 BGEMM / W4A16 / grouped-GEMM MoE + // expert paths all finish with moe_scatter_add (+relu2_scale_f16), + // which dispatch_raw_cuda → hard-error on Metal. Force the portable + // per-expert path (host scatter, host f32 relu2) whose only CUDA dep + // is the GEMM — and that we swap below for dequant_q4_off + matmul. + let (use_bgemm, use_w4a16, use_w4a16_marlin) = + if is_cuda { (use_bgemm, use_w4a16, use_w4a16_marlin) } else { (false, false, false) }; + // NEMOTRON_GROUPED_GEMM=1: async two-pass all-expert GEMM — all UP + // GEMMs enqueued (no inter-expert sync), on-device relu2_scale_f16, + // all DN GEMMs, then on-device scatter-add. Uses the device-gather + // path for xs. Eliminates per-expert host syncs + cast overhead. + let use_grouped_gemm = std::env::var("NEMOTRON_GROUPED_GEMM").is_ok() && !use_bgemm && !use_w4a16; + // NEMOTRON_CUTLASS_MOE=1 (CUDA + CUTLASS build): contiguous f16 expert + // slabs (dequant the full Q4 weight once, resident-cached) + ONE + // CUTLASS grouped GEMM per up/down over the sorted tokens. Beats + // cuBLAS per-expert on the skinny m≈96 shape (17.1 vs 15.7 TFLOP/s). + let use_cutlass_moe = is_cuda && std::env::var("NEMOTRON_CUTLASS_MOE").is_ok() + && !use_fp4_moe + && !use_bgemm && !use_w4a16 && !use_grouped_gemm; + // NEMOTRON_Q4_GROUPED=1 (CUDA): Q4-NATIVE single-launch grouped MoE + // GEMM (moe_q4_grouped_mma) — reads the model's signed Q4 weights + // DIRECTLY (no f16 slab, no dequant) per up/down over the sorted + // tokens; on-device relu²+det-scatter. Collapses ~234 per-expert + // cuBLAS launches → 2 AND reads 4.5-bit weights (vs 16-bit) → the + // Marlin lever for the BW-bound MoE wall. Uses the device gather. + // NEMOTRON_Q4_GROUPED_LASTEXACT=K: run the EXACT cuBLAS per-expert + // path for the LAST K MoE layers (output-proximal) and Q4-grouped for + // the earlier ones — a precision/speed hybrid to test whether late- + // layer exactness restores the argmax (1104) while keeping most of + // the +35%. moe_idx = this E-layer's 0-based index among all E-layers. + let q4_lastexact: usize = std::env::var("NEMOTRON_Q4_GROUPED_LASTEXACT") + .ok().and_then(|v| v.parse().ok()).unwrap_or(0); + let n_moe_layers = PATTERN.chars().filter(|&c| c == 'E').count(); + let moe_idx = PATTERN.chars().take(l).filter(|&c| c == 'E').count(); + // NEMOTRON_Q4_GROUPED_FIRSTEXACT=K: exact cuBLAS for the FIRST K MoE + // layers (the sensitive early ones), Q4 for the rest. (lastexact failed + // at all K — early error propagates irreducibly; test exact-early.) + // Default FIRSTEXACT=4: the first 4 MoE layers run EXACT cuBLAS, the + // rest Q4. Validated argmax==1104@S2048 (exact-early restores the + // razor 1104/1776 near-tie that pure-Q4 flips; gap 0.125, robust). + // exact-LAST-K failed at all K (early error propagates irreducibly). + // devdesc is host-free (empty triples) → the per-expert exact path is + // unavailable; force firstexact=0 (all layers Q4; validated 1104). + let q4_firstexact: usize = if devdesc_skip { 0 } else { + std::env::var("NEMOTRON_Q4_GROUPED_FIRSTEXACT") + .ok().and_then(|v| v.parse().ok()).unwrap_or(4) }; + let q4_layer_ok = moe_idx >= q4_firstexact + && moe_idx < n_moe_layers.saturating_sub(q4_lastexact); + // DEFAULT-ON for CUDA (escape: NEMOTRON_Q4_GROUPED_OFF=1): Q4-native + // grouped MoE for all but the first FIRSTEXACT layers. +21% @S2048 + // (1009→1220 tok/s), argmax 1104 exact. + let use_q4_grouped = is_cuda && std::env::var("NEMOTRON_Q4_GROUPED_OFF").is_err() + && q4_layer_ok + && !use_bgemm && !use_w4a16 && !use_grouped_gemm && !use_cutlass_moe; + // NEMOTRON_MOE_GROUPED=1 (Metal only): replace the per-expert + // dequant_q4_off+matmul loop with the token-sorted GROUPED Q4 + // GEMM (moe_bgemm_q4_bm64) — ONE MMA dispatch over all sorted + // rows, Q4 dequant fused in the kernel's block prologue (no + // per-expert f16 weight materialization). relu²+scale on host + // f32 (overflow-safe, matches the validated per-expert numerics), + // then a second grouped GEMM for down, then host scatter. The + // Metal analog of the CUDA GROUPED_GEMM/BGEMM device path — but + // those finish with the CUDA-only moe_scatter_add, so this path + // keeps the portable host relu²/scatter and only swaps the GEMM. + // bm64 requires n_out % 64 == 0: inter=1856 (29·64) and + // hid=2688 (42·64) both qualify. Gated off on CUDA (uses the + // shipped GROUPED_GEMM/BGEMM tensor-core paths instead). + let use_metal_grouped = !is_cuda + && std::env::var("NEMOTRON_MOE_GROUPED").is_ok() + && inter % 64 == 0 && hid % 64 == 0; + // Build sorted activation [mt, hid]: + // NEMOTRON_DEVICE_GATHER=1: stay on device (gather kernel + cast), + // eliminates the 22MB dl(&xn) + 132MB host scatter per E-layer. + // Default: host download + gather (validated path). + let dev_gather = std::env::var("NEMOTRON_DEVICE_GATHER").is_ok(); + // ON-DEVICE MoE gather/scatter (DEFAULT-ON for CUDA): keep the + // validated fewer_syncs per-expert cuBLAS GEMM + on-device + // relu²/scatter, but ALSO gather the sorted expert activation ON + // DEVICE (ffai_gather over xn rows → [mt,hid] f16) instead of the + // dl(&xn)+host-scatter+per-expert re-upload, and keep the routed + // accumulator on device through the shared-expert + residual merge. + // Kills the ~22MB D2H × 23 host gather + host scatter + the + // dl(acc)/upm(acc) round-trip — the dominant remaining host wall in + // the conv-device prefill path. argmax-exact (1104/1120/1763), + // bit-deterministic, +~70% e2e. Escape: NEMOTRON_ONDEVICE_MOE_OFF=1 + // reverts to the host-gather path. + // Only the default fewer_syncs per-expert path consumes the device + // gather; the BGEMM/W4A16/grouped/two_pass paths have their own xs + // handling, so don't hijack their gather when those are selected. + let two_pass_sel = is_cuda && std::env::var("NEMOTRON_TWO_PASS").is_ok(); + let fewer_syncs_off = std::env::var("NEMOTRON_FEWER_SYNCS_OFF").is_ok(); + let ondevice_moe = is_cuda && std::env::var("NEMOTRON_ONDEVICE_MOE_OFF").is_err() + && !use_bgemm && !use_w4a16 && !use_grouped_gemm && !use_metal_grouped + && !two_pass_sel && !fewer_syncs_off; + // xs_dev_f16 is the device version (for DEV_GATHER or BGEMM paths). + // xs (host f32 vec) is populated for the default host path. + // Build sorted activation [mt, hid]: + // For BGEMM: host gather → single device upload (avoids per-expert uploads). + // For DEVICE_GATHER: on-device gather (EXPERIMENTAL, may produce wrong output). + // Default: host gather (validated path). + let xn_h_opt: Option> = if (use_bgemm || use_w4a16 || dev_gather || use_grouped_gemm || use_metal_grouped) + && !(use_w4a16_marlin && moe_dev.is_some()) { + // Need xn on host for BGEMM/W4A16/METAL_GROUPED (host gather) or DEV_GATHER (gather source). + // The on-device Marlin path (PATH B) gathers via st_dev and never reads xn_h, so skip the + // host download -- a dl here aborts CUDA-graph stream capture (keeps the forward capturable). + Some(dl(&xn, s * hid)) + } else { + None + }; + // FP4-direct: actq_pack gathers on read from the ungathered + // [s,hid] f16 — skip building the gathered xs entirely. + let fp4_skip_gather = is_cuda && moe_dev.is_some() && xn_f16_router.is_some() + && std::env::var("NEMOTRON_FP4_DIRECT_OFF").is_err() + && std::env::var("NEMOTRON_FP4_SIM").is_err() + && fp4w.contains_key(&format!("{p}.moe_up_proj_fp4")) && !use_w4a16; + let (xs, xs_dev_f16_opt) = if fp4_skip_gather { + (Vec::new(), None) + } else if use_w4a16_marlin && moe_dev.is_some() { + // PATH B (graph-safe): Marlin gathers tokens ON-DEVICE via the sorted_tok + // list (st_dev) -- no host xs copy, no host triples. [mt,hid] f16, sorted by expert. + let (_, st_dev, _) = moe_dev.as_ref().unwrap(); + let xn_f16g = match &xn_f16_router { Some(t) => t.clone(), None => pf!(15, 0.0, cast_f32_f16(d, &xn).unwrap()) }; + let xn2d = xn_f16g.reshaped(vec![s, hid]); + let xs_f16 = pf!(12, 0.0, gather(d, &xn2d, st_dev).unwrap()); + (Vec::new(), Some(xs_f16)) + } else if use_bgemm || use_w4a16 || use_grouped_gemm || use_metal_grouped { + // BGEMM/W4A16: host gather → upload xs as one [mt, hid] f16 tensor. + let xn_h = xn_h_opt.as_ref().unwrap(); + let mut xs_h = vec![0.0f32; mt * hid]; + for (r,(_e,t,_)) in triples.iter().enumerate() { + xs_h[r*hid..(r+1)*hid].copy_from_slice(&xn_h[t*hid..(t+1)*hid]); + } + let xs_f16 = cast_f32_f16(d, &upm(&xs_h, vec![mt, hid])).unwrap(); + (Vec::new(), Some(xs_f16)) + } else if dev_gather || ondevice_moe { + // On-device gather: upload token indices, gather xn rows on device + // into one [mt,hid] f16 buffer. The fewer_syncs per-expert GEMM + // reads device slices of this (offset g0*hid*2) → no host xs, + // no dl(&xn), no host scatter, no per-expert re-upload. + // sorted_tok (st_dev) IS the device token-index gather list (== + // triples tokens) → use it DIRECTLY when devsort, skipping the host + // tok_idx build + re-upload (one step toward a host-sync-free forward). + let tok_owned: Option = if moe_dev.is_some() { None } else { + let tok_idx: Vec = triples.iter().map(|(_,t,_)| *t as u32).collect(); + Some(Tensor::new(d.upload(&tbu(&tok_idx)).unwrap(), vec![mt], DType::U32)) + }; + let tok_dev = match &moe_dev { Some((_,st,_)) => st, None => tok_owned.as_ref().unwrap() }; + // f16-FIRST: cast [s,hid] once (or reuse the router's cast) + // then gather in f16 — 3.2x less traffic than the old + // gather-f32 [mt,hid] + cast-down chain (mt = 6s). + let xn_f16g = match &xn_f16_router { + Some(t) => t.clone(), + None => pf!(15, 0.0, cast_f32_f16(d, &xn).unwrap()), + }; + let xn2d = xn_f16g.reshaped(vec![s, hid]); + let xs_f16 = pf!(12, 0.0, gather(d, &xn2d, tok_dev).unwrap()); // [mt, hid] f16 + (Vec::new(), Some(xs_f16)) + } else { + // Host gather: download xn, scatter rows. + let xn_h = dl(&xn, s * hid); + let mut xs = vec![0.0f32; mt * hid]; + for (r,(_e,t,_)) in triples.iter().enumerate() { + xs[r*hid..(r+1)*hid].copy_from_slice(&xn_h[t*hid..(t+1)*hid]); + } + (xs, None) + }; + // ── Expert GEMM: batched (NEMOTRON_BGEMM) or per-expert cuBLAS ── + // NEMOTRON_BGEMM=1: replace the per-expert cuBLAS loop with + // moe_bgemm_q4_bm64 (one dispatch for all experts, Q4 native, + // no per-expert dequant) + on-device relu2_scale_f16 + another + // bm64 for the down pass. Reduces O(n_active_experts*2) cuBLAS + // calls to 2 dispatches + 1 download per E-layer. + // Default: per-expert cuBLAS loop (existing, validated path). + let (uqs, usc, _, _) = &qw[&format!("{p}.moe_up_all")]; + let (dqs, dsc, _, _) = &qw[&format!("{p}.moe_down_all")]; + let up_bpr = hid / 32; // Q4 blocks per up-weight row + let down_bpr = inter / 32; // Q4 blocks per down-weight row + let inv = 1.0f32 / 256.0; + // use_bgemm already defined above (before gather block). + let mut acc_h = vec![0.0f32; s * hid]; + // ONDEVICE_MOE: when the fewer_syncs scatter leaves the routed + // accumulator on device, keep it here so the shared expert + + // residual add stay on-device (no dl(acc)+upm(acc) round-trip). + let mut acc_dev_keep: Option = None; + // set when the shared expert was folded into the routed grouped + // GEMM (acc already holds routed + shared) — skip the sd block. + let mut shared_folded = false; + if use_w4a16 { + // ── W4A16 WMMA path (NEMOTRON_W4A16=1 or NEMOTRON_W4A16_MARLIN=1) ── + // Small S (mt ≤ W4A16_THRESH, standard path only): inline Q4 dequant + // + WMMA — wins because inline dequant saves 50% weight BW. + // Large S (mt > W4A16_THRESH, standard path only): fall back to + // grouped_gemm (dequant-once + cuBLAS) — cuBLAS tiles are much larger. + // Marlin path (use_w4a16_marlin=true): always use moe_w4a16_marlin for + // all S (no grouped_gemm fallback — grouped_gemm expects standard + // layout, but Marlin-layout weights would produce wrong results). + let w4a16_thresh: usize = std::env::var("NEMOTRON_W4A16_THRESH") + .ok().and_then(|v| v.parse().ok()).unwrap_or(12288); + let xs_f16 = xs_dev_f16_opt.as_ref().unwrap().clone(); + let marlin_dev = use_w4a16_marlin && moe_dev.is_some(); + if marlin_dev { + // PATH B (graph-safe): fully on-device Marlin W4A16 MoE. Per-row expert + // ids from CSR offsets-expand, scatter via det_dev (st_dev/sw_dev), acc on + // device (kept unconditionally: ondevice_moe is hardwired off for use_w4a16, + // but PATH B is fully device + the shared expert emits sd_dev_opt, so the + // downstream device merge is the correct graph-safe consumer). No host dl. + let (off_dev, st_dev, sw_dev) = moe_dev.as_ref().unwrap(); + let idx_dev = moe_expert_ids_from_offsets(d, off_dev, n_exp, mt).unwrap(); + let (muqs, musc, _, _) = &qw[&format!("{p}.moe_up_all_marlin")]; + let (mdqs, mdsc, _, _) = &qw[&format!("{p}.moe_down_all_marlin")]; + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + moe_w4a16_marlin(d, &xs_f16, muqs, musc, &idx_dev, mt, inter, hid).unwrap()); + let up_relu2 = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + let dn_out_f16 = pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + moe_w4a16_marlin(d, &up_relu2, mdqs, mdsc, &idx_dev, mt, hid, inter).unwrap()); + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + moe_scatter_add_det_dev(d, &dn_out_f16, st_dev, sw_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + acc_dev_keep = Some(acc_dev); + } else { + let wts_h: Vec = triples.iter().map(|(_,_,w)| *w).collect(); + let tidx_h: Vec = triples.iter().map(|(_,t,_)| *t as u32).collect(); + let wts_dev = upm(&wts_h, vec![mt]); + let tidx_dev2 = Tensor::new(d.upload(&tbu(&tidx_h)).unwrap(), vec![mt], DType::U32); + let acc_dev = upm(&acc_h, vec![s, hid]); + if use_w4a16_marlin || mt <= w4a16_thresh { + // Marlin path: always inline WMMA (handles all mt, Marlin layout). + // Standard W4A16 path: inline WMMA for small mt ≤ threshold. + let idx_u32: Vec = triples.iter().map(|(e,_,_)| *e).collect(); + let idx_dev = Tensor::new(d.upload(&tbu(&idx_u32)).unwrap(), vec![mt], DType::U32); + let dn_out_f16 = if use_w4a16_marlin { + // Marlin coalesced path (NEMOTRON_W4A16_MARLIN=1): + // read the MARLIN-layout copies under the `_marlin` + // keys, NOT the base standard-layout weights that the + // sequential step path (moe_fused_ffn) consumes. + let (muqs, musc, _, _) = &qw[&format!("{p}.moe_up_all_marlin")]; + let (mdqs, mdsc, _, _) = &qw[&format!("{p}.moe_down_all_marlin")]; + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + moe_w4a16_marlin(d, &xs_f16, muqs, musc, &idx_dev, mt, inter, hid).unwrap()); + let up_relu2 = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + moe_w4a16_marlin(d, &up_relu2, mdqs, mdsc, &idx_dev, mt, hid, inter).unwrap()) + } else { + // Standard scattered-nibble W4A16 path (NEMOTRON_W4A16=1) + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + moe_w4a16(d, &xs_f16, uqs, usc, &idx_dev, mt, inter, hid).unwrap()); + let up_relu2 = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + moe_w4a16(d, &up_relu2, dqs, dsc, &idx_dev, mt, hid, inter).unwrap()) + }; + let dn_out = cast_f16_f32(d, &dn_out_f16).unwrap(); + moe_scatter_add(d, &dn_out, &tidx_dev2, &wts_dev, &acc_dev, mt, hid, 256.0f32).unwrap(); + } else { + // Large S (standard W4A16 only): grouped_gemm (dequant-once + cuBLAS). + // NOTE: never reached when use_w4a16_marlin=true (weights in Marlin layout). + let mut g_starts_l: Vec = vec![0]; + let mut expert_ids_l: Vec = Vec::new(); + let mut gi = 0usize; + while gi < mt { + let eid = triples[gi].0 as usize; + expert_ids_l.push(eid); + let mut gi2 = gi + 1; + while gi2 < mt && triples[gi2].0 as usize == eid { gi2 += 1; } + g_starts_l.push(gi2); + gi = gi2; + } + let dn_out_f16 = pf!(11, 2.0*mt as f64*(inter+hid) as f64*hid as f64, + moe_grouped_gemm(d, uqs, usc, dqs, dsc, &xs_f16, + &g_starts_l, &expert_ids_l, hid, inter, up_bpr, down_bpr, + grouped_up_scratch.as_ref(), grouped_dn_scratch.as_ref()).unwrap()); + let dn_out = cast_f16_f32(d, &dn_out_f16).unwrap(); + moe_scatter_add(d, &dn_out, &tidx_dev2, &wts_dev, &acc_dev, mt, hid, 256.0f32).unwrap(); + } + acc_h = dl(&acc_dev, s * hid); + } + } else if use_bgemm { + // ── Batched bm64 BGEMM path ─────────────────────────────────── + // xs_dev_f16 from device gather (set above when use_bgemm=true). + let xs_f16 = xs_dev_f16_opt.as_ref().unwrap().clone(); + let idx_u32: Vec = triples.iter().map(|(e,_,_)| *e).collect(); + let idx_dev = Tensor::new(d.upload(&tbu(&idx_u32)).unwrap(), vec![mt], DType::U32); + // UP: [mt, inter] = xs[mt,hid] · Wup[n_exp*inter,hid]^T (Q4 bm64 BGEMM) + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + moe_bgemm_q4_bm64(d, &xs_f16, uqs, usc, &idx_dev, mt, inter, hid).unwrap()); + // relu2 + scale: fused on device (no host round-trip). + let up_relu2 = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + // DOWN: [mt, hid] = relu2_out[mt,inter] · Wdn[n_exp*hid,inter]^T (Q4 bm64 BGEMM) + let dn_out_f16 = pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + moe_bgemm_q4_bm64(d, &up_relu2, dqs, dsc, &idx_dev, mt, hid, inter).unwrap()); + let dn_out = cast_f16_f32(d, &dn_out_f16).unwrap(); // [mt, hid] f32 + // Scatter-weight + unscale (×256): + // NEMOTRON_BGEMM: on-device atomic scatter (no dl per expert). + // Routed-expert weights and token indices for the scatter-add. + let wts_h: Vec = triples.iter().map(|(_,_,w)| *w).collect(); + let tidx_h: Vec = triples.iter().map(|(_,t,_)| *t as u32).collect(); + let wts_dev = upm(&wts_h, vec![mt]); + let tidx_dev2 = Tensor::new(d.upload(&tbu(&tidx_h)).unwrap(), vec![mt], DType::U32); + // acc_dev: pre-zeroed [s, hid] f32 output accumulator. + let acc_dev = upm(&acc_h, vec![s, hid]); // already vec of 0.0f32 + // Deterministic scatter by default (atomicAdd nondeterministic). + if std::env::var("NEMOTRON_ATOMIC_SCATTER").is_ok() { + moe_scatter_add(d, &dn_out, &tidx_dev2, &wts_dev, &acc_dev, mt, hid, 256.0f32).unwrap(); + } else { + let _ = &tidx_dev2; + moe_scatter_add_det(d, &dn_out, &tidx_h, &wts_dev, &acc_dev, s, mt, hid, 256.0f32, false).unwrap(); + } + // Download accumulated output for residual add. + acc_h = dl(&acc_dev, s * hid); + } else if use_cutlass_moe { + // ── CUTLASS grouped MoE GEMM (NEMOTRON_CUTLASS_MOE=1) ───────── + // Contiguous f16 expert slabs (dequant the FULL Q4 weight once, + // resident-cached) + ONE CUTLASS grouped GEMM per up/down over + // the sorted tokens; on-device relu²+scatter. Beats cuBLAS + // per-expert on the skinny m≈96 shape (17.1 vs 15.7 TFLOP/s). + let xs_f16 = xs_dev_f16_opt.as_ref().unwrap().clone(); + let mut g_starts: Vec = vec![0]; + let mut expert_ids: Vec = Vec::new(); + { let mut gi=0usize; while gi = triples.iter().map(|(_,_,w)| *w).collect(); + let tidx_h: Vec = triples.iter().map(|(_,t,_)| *t as u32).collect(); + let wts_dev = upm(&wts_h, vec![mt]); + let acc_dev = upm(&acc_h, vec![s, hid]); + let dn_out = cast_f16_f32(d, &dn_out_f16).unwrap(); + moe_scatter_add_det(d, &dn_out, &tidx_h, &wts_dev, &acc_dev, s, mt, hid, 256.0f32, false).unwrap(); + acc_h = dl(&acc_dev, s * hid); + } else if use_q4_grouped { + // ── Q4-NATIVE grouped MoE GEMM (NEMOTRON_Q4_GROUPED=1) ──────── + // moe_q4_grouped_mma reads the signed Q4 weights directly (no + // f16 slab, no dequant). ONE launch per up/down. Device gather + // gives xs_f16; on-device relu²(1/256)+det-scatter(×256). + // None in FP4-direct mode (gather skipped upstream) — dummy. + let xs_f16 = xs_dev_f16_opt.clone().unwrap_or_else(|| { + Tensor::new(d.upload(&[0u8; 2]).unwrap(), vec![1], DType::F16) + }); + // Host g_starts/expert_ids only for the host-descriptor path; the + // devdesc path builds them on-device (triples is empty there). + let mut g_starts: Vec = vec![0]; + let mut expert_ids: Vec = Vec::new(); + if !triples.is_empty() { let mut gi=0usize; while gi Tensor { + let hf = dl(&cast_f16_f32(d, t).unwrap(), rows * kk); + let rt = nvfp4_roundtrip(&hf, rows, kk); + cast_f32_f16(d, &upm(&rt, vec![rows, kk])).unwrap() + }; + // FP4 direct mode: skip the gathered xs entirely — actq_pack + // gathers on read via st_dev from the ungathered [s,hid] f16 + // (and the amax pass scans 6x fewer elements). + let fp4_direct = !fp4_sim && moe_dev.is_some() && xn_f16_router.is_some() + && std::env::var("NEMOTRON_FP4_DIRECT_OFF").is_err() + && !use_fp4_cutlass_env // CUTLASS path gathers in its own quant + && fp4w.contains_key(&format!("{p}.moe_up_proj_fp4")); + let xs_in = if fp4_direct { xn_f16_router.as_ref().unwrap().clone() } + else if fp4_sim { fp4rt(&xs_f16, mt, hid) } else { xs_f16.clone() }; + // Native NVFP4 W4A4 grouped mma (NEMOTRON_FP4_MOE=1, devdesc only): + // dynamic act quant+pack + Blackwell block-scaled FP4 mma. Needs + // METALTILE_CUDA_ARCH=compute_121a. + let fp4_moe = devdesc && fp4w.contains_key(&format!("{p}.moe_up_proj_fp4")); + // Shared expert FOLDED into the grouped GEMM as two always- + // selected groups (up: two N=inter slabs; down: K-split halves + // whose scatter-adds sum to the full down GEMM). Same act quant + // (identity gather rows of xn), same deterministic scatter — + // kills the separate shared up/down f16 GEMMs + relu2 + merge. + let fold_keys = fp4_moe && fp4_direct + && fp4w.contains_key(&format!("{p}.moe_up_proj_fp4_fold")); + let fp4_cutlass = fp4_moe && use_fp4_cutlass_env + && fp4w_cutlass.contains_key(&format!("{p}.moe_up_proj_fp4_cutlass")); + let cutlass_dev = fp4_cutlass && std::env::var("NEMOTRON_CUTLASS_DEV").is_ok(); + // ── W4A8: fp8 e4m3 acts x mxfp4 weights (mxf8f6f4). Self- + // contained host-desc path (gathers sorted rows itself); + // group-aware per-32 act-quant; relu²·(1/256) between; + // same det-scatter. Quality-holding (256 act levels). ── + let w8a8_moe = use_w8a8_env && w8a8w.contains_key(&format!("{p}.moe_up_proj_w8a8")); + let w4a8_moe = use_w4a8_env && w4a8w.contains_key(&format!("{p}.moe_up_proj_w4a8")); + if w8a8_moe { + let off_dev = off_dev_ref.unwrap(); + let (_, st_dev, sw_dev) = moe_dev.as_ref().unwrap(); + let xn2d = xn_f16_router.as_ref().unwrap().reshaped(vec![s, hid]); + let xs_g = pf!(12, 0.0, gather(d, &xn2d, st_dev).unwrap()); + let mut ob = vec![0u8; (n_exp + 1) * 4]; + d.download(off_dev.buffer.as_ref(), &mut ob).unwrap(); + let offs: Vec = ob.chunks(4).map(|c| u32::from_le_bytes([c[0],c[1],c[2],c[3]]) as usize).collect(); + let mut g_starts_c: Vec = vec![0]; + let mut eids_c: Vec = Vec::new(); + for e in 0..n_exp { if offs[e+1] > offs[e] { eids_c.push(e); g_starts_c.push(offs[e+1]); } } + let grows: Vec = g_starts_c.windows(2).map(|w| (w[1]-w[0]) as i32).collect(); + let (uwp, uwsf, ueb) = &w8a8w[&format!("{p}.moe_up_proj_w8a8")]; + let (dwp, dwsf, deb) = &w8a8w[&format!("{p}.moe_down_proj_w8a8")]; + let (uoff, ubytes) = ffai_ops::w4a8_sfa_offsets(&g_starts_c, hid); + let (ap, asf) = pf!(15, 0.0, + ffai_ops::w8a8_actquant_grouped(d, &xs_g, &grows, &uoff, ubytes, mt, hid).unwrap()); + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + ffai_ops::moe_grouped_gemm_w8a8(d, &ap, &asf, &uoff, uwp, uwsf, *ueb, &g_starts_c, &eids_c, inter, hid).unwrap()); + let a2 = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + let (boff, bbytes) = ffai_ops::w4a8_sfa_offsets(&g_starts_c, inter); + let (bp, bsf) = pf!(15, 0.0, + ffai_ops::w8a8_actquant_grouped(d, &a2, &grows, &boff, bbytes, mt, inter).unwrap()); + let dn_out = pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + ffai_ops::moe_grouped_gemm_w8a8(d, &bp, &bsf, &boff, dwp, dwsf, *deb, &g_starts_c, &eids_c, hid, inter).unwrap()); + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + moe_scatter_add_det_dev(d, &dn_out, st_dev, sw_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + if ondevice_moe { acc_dev_keep = Some(acc_dev); } else { acc_h = dl(&acc_dev, s * hid); } + } else if w4a8_moe { + let off_dev = off_dev_ref.unwrap(); + let (_, st_dev, sw_dev) = moe_dev.as_ref().unwrap(); + let xn2d = xn_f16_router.as_ref().unwrap().reshaped(vec![s, hid]); + let xs_g = pf!(12, 0.0, gather(d, &xn2d, st_dev).unwrap()); + let mut ob = vec![0u8; (n_exp + 1) * 4]; + d.download(off_dev.buffer.as_ref(), &mut ob).unwrap(); + let offs: Vec = ob.chunks(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]) as usize).collect(); + let mut g_starts_c: Vec = vec![0]; + let mut eids_c: Vec = Vec::new(); + for e in 0..n_exp { if offs[e + 1] > offs[e] { eids_c.push(e); g_starts_c.push(offs[e + 1]); } } + let grows: Vec = g_starts_c.windows(2).map(|w| (w[1] - w[0]) as i32).collect(); + let (uwp, uwsf, ueb) = &w4a8w[&format!("{p}.moe_up_proj_w4a8")]; + let (dwp, dwsf, deb) = &w4a8w[&format!("{p}.moe_down_proj_w4a8")]; + let hid_pad = hid.div_ceil(128) * 128; + let inter_pad = inter.div_ceil(128) * 128; + let xs_p = ffai_ops::pad_rows_f16(d, &xs_g, mt, hid, hid_pad).unwrap(); + let (uoff, ubytes) = ffai_ops::w4a8_sfa_offsets(&g_starts_c, hid_pad); + let (ap, asf) = pf!(15, 0.0, + ffai_ops::w4a8_actquant_grouped(d, &xs_p, &grows, &uoff, ubytes, mt, hid_pad).unwrap()); + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + ffai_ops::moe_grouped_gemm_w4a8(d, &ap, &asf, &uoff, uwp, uwsf, *ueb, &g_starts_c, &eids_c, inter, hid_pad).unwrap()); + let a2 = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + let a2p = ffai_ops::pad_rows_f16(d, &a2, mt, inter, inter_pad).unwrap(); + let (boff, bbytes) = ffai_ops::w4a8_sfa_offsets(&g_starts_c, inter_pad); + let (bp, bsf) = pf!(15, 0.0, + ffai_ops::w4a8_actquant_grouped(d, &a2p, &grows, &boff, bbytes, mt, inter_pad).unwrap()); + let dn_out = pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + ffai_ops::moe_grouped_gemm_w4a8(d, &bp, &bsf, &boff, dwp, dwsf, *deb, &g_starts_c, &eids_c, hid, inter_pad).unwrap()); + { + use std::sync::atomic::{AtomicUsize, Ordering}; + static DBG: AtomicUsize = AtomicUsize::new(0); + if std::env::var("NEMOTRON_W4A8_DBG").is_ok() && DBG.fetch_add(1, Ordering::Relaxed) < 1 { + let nrm = |t: &Tensor, n: usize| -> f64 { + let v = dl(&cast_f16_f32(d, t).unwrap(), n); + (v.iter().map(|x| (*x as f64)*(*x as f64)).sum::() / n as f64).sqrt() + }; + eprintln!(" [W4A8-DBG {p}] n_active={} mt={mt} g_starts_c.last={:?} | rms: xs_g={:.4} up_out={:.4} a2={:.4} dn_out={:.4}", + eids_c.len(), g_starts_c.last(), + nrm(&xs_g, mt*hid), nrm(&up_out, mt*inter), nrm(&a2, mt*inter), nrm(&dn_out, mt*hid)); + } + } + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + moe_scatter_add_det_dev(d, &dn_out, st_dev, sw_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + if ondevice_moe { acc_dev_keep = Some(acc_dev); } else { acc_h = dl(&acc_dev, s * hid); } + } else if cutlass_dev { + // ── device-descriptor CUTLASS grouped NVFP4 (zero host + // work per call): persistent prepared handles per layer; + // per call = grouped act quant (device sfa offsets) + + // alpha refresh + ONE fill kernel + gemm.run, per + // up/down. Groups = ALL n_exp experts (M=0 groups run + // zero tiles). ──────────────────────────────────────── + let off_dev = off_dev_ref.unwrap(); + let (uwp, uwsf, ugw) = &fp4w_cutlass[&format!("{p}.moe_up_proj_fp4_cutlass")]; + let (dwp, dwsf, dgw) = &fp4w_cutlass[&format!("{p}.moe_down_proj_fp4_cutlass")]; + if !cutlass_handles.borrow().contains_key(p.as_str()) { + let alpha_u = Tensor::new(d.alloc_zeroed(n_exp * 4).unwrap(), vec![n_exp], DType::F32); + let alpha_d = Tensor::new(d.alloc_zeroed(n_exp * 4).unwrap(), vec![n_exp], DType::F32); + let hu = d.moe_grouped_cutlass_fp4_prepare( + uwp.buffer.as_ref(), uwsf.buffer.as_ref(), alpha_u.buffer.as_ref(), + n_exp, inter, hid, mt).unwrap(); + let hd = d.moe_grouped_cutlass_fp4_prepare( + dwp.buffer.as_ref(), dwsf.buffer.as_ref(), alpha_d.buffer.as_ref(), + n_exp, hid, inter, mt).unwrap(); + cutlass_handles.borrow_mut().insert(p.clone(), (hu, hd, alpha_u, alpha_d)); + } + let hb = cutlass_handles.borrow(); + let (hu, hd, alpha_u, alpha_d) = hb.get(p.as_str()).unwrap(); + // gather-on-read up-quant from the UNGATHERED xn — kills the + // explicit xs gather pass; amax scans s rows instead of mt. + let (_, st_dev0, _) = moe_dev.as_ref().unwrap(); + let (ap, asf, ags) = if let Some(xnr) = xn_f16_router.as_ref() { + pf!(15, 0.0, ffai_ops::lt_fp4_quant_grouped_dev(d, xnr, mt, hid, off_dev, n_exp, 0, 0.0, Some((st_dev0, s))).unwrap()) + } else { + pf!(15, 0.0, ffai_ops::lt_fp4_quant_grouped_dev(d, &xs_in, mt, hid, off_dev, n_exp, 0, 0.0, None).unwrap()) + }; + ffai_ops::fp4_group_alpha_id(d, &ags, ugw, alpha_u, n_exp).unwrap(); + let up_out = Tensor::empty(d, vec![mt, inter], DType::F16).unwrap(); + pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + d.moe_grouped_cutlass_fp4_run(*hu, ap.buffer.as_ref(), asf.buffer.as_ref(), up_out.buffer.as_ref(), off_dev.buffer.as_ref()).unwrap()); + // relu²·(1/256) fused into the down-quant (no standalone pass) + let (bp, bsf, bgs) = pf!(15, 0.0, + ffai_ops::lt_fp4_quant_grouped_dev(d, &up_out, mt, inter, off_dev, n_exp, 1, inv, None).unwrap()); + ffai_ops::fp4_group_alpha_id(d, &bgs, dgw, alpha_d, n_exp).unwrap(); + let dn_out = Tensor::empty(d, vec![mt, hid], DType::F16).unwrap(); + pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + d.moe_grouped_cutlass_fp4_run(*hd, bp.buffer.as_ref(), bsf.buffer.as_ref(), dn_out.buffer.as_ref(), off_dev.buffer.as_ref()).unwrap()); + drop(hb); + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + let (_, st_dev, sw_dev) = moe_dev.as_ref().unwrap(); + moe_scatter_add_det_dev(d, &dn_out, st_dev, sw_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + if ondevice_moe { acc_dev_keep = Some(acc_dev); } else { acc_h = dl(&acc_dev, s * hid); } + } else if fp4_cutlass { + // ── CUTLASS grouped NVFP4 GEMM (NEMOTRON_CUTLASS_MOE=1 + // on top of FP4_MOE) ────────────────────────────────── + // Host group descriptors from the device routing offsets + // (one small [n_exp+1] dl per E-layer; device-side build + // is a graph-safety follow-up), CUTLASS-layout act quant + // (linear e2m1 + per-group swizzled SF), ONE grouped + // block-scaled GEMM per up/down, relu²·(1/256) between, + // then the same det-scatter as the mma path. + let off_dev = off_dev_ref.unwrap(); + let mut ob = vec![0u8; (n_exp + 1) * 4]; + d.download(off_dev.buffer.as_ref(), &mut ob).unwrap(); + let offs: Vec = ob.chunks(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]) as usize).collect(); + let mut g_starts_c: Vec = vec![0]; + let mut eids_c: Vec = Vec::new(); + for e in 0..n_exp { + if offs[e + 1] > offs[e] { eids_c.push(e); g_starts_c.push(offs[e + 1]); } + } + let (uwp, uwsf, ugw) = &fp4w_cutlass[&format!("{p}.moe_up_proj_fp4_cutlass")]; + let (dwp, dwsf, dgw) = &fp4w_cutlass[&format!("{p}.moe_down_proj_fp4_cutlass")]; + let (ap, asf, aoff, ags) = pf!(15, 0.0, + ffai_ops::lt_fp4_quant_grouped(d, &xs_in, mt, hid, &g_starts_c).unwrap()); + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + ffai_ops::moe_grouped_gemm_cutlass_fp4(d, &ap, &asf, &aoff, &ags, uwp, uwsf, ugw, &g_starts_c, &eids_c, inter, hid).unwrap()); + let a2 = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + let (bp, bsf, boff, bgs) = pf!(15, 0.0, + ffai_ops::lt_fp4_quant_grouped(d, &a2, mt, inter, &g_starts_c).unwrap()); + let dn_out = pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + ffai_ops::moe_grouped_gemm_cutlass_fp4(d, &bp, &bsf, &boff, &bgs, dwp, dwsf, dgw, &g_starts_c, &eids_c, hid, inter).unwrap()); + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + let (_, st_dev, sw_dev) = moe_dev.as_ref().unwrap(); + moe_scatter_add_det_dev(d, &dn_out, st_dev, sw_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + if ondevice_moe { acc_dev_keep = Some(acc_dev); } else { acc_h = dl(&acc_dev, s * hid); } + } else if fold_keys { + let (uwp, uwsc, ugw) = &fp4w[&format!("{p}.moe_up_proj_fp4_fold")]; + let (dwp, dwsc, dgw) = &fp4w[&format!("{p}.moe_down_proj_fp4_fold")]; + let amax_dn = Tensor::new(d.alloc_zeroed(4).unwrap(), vec![1], DType::U32); + let (off0, st0, sw0) = moe_dev.as_ref().unwrap(); + let (st_e, sw_e, off_e) = ffai_ops::moe_extend_groups(d, st0, sw0, off0, mt, s, n_exp).unwrap(); + let mt_e = mt + 2 * s; + let up_out = pf!(11, 2.0*mt_e as f64*inter as f64*hid as f64, + ffai_ops::moe_fp4_grouped_mma_dev(d, &xs_in, uwp, uwsc, ugw, &off_e, n_exp + 2, mt_e, inter, hid, 0, 0.0, None, Some((&amax_dn, 1, inv)), Some(&st_e)).unwrap()); + let dn_out = pf!(11, 2.0*mt_e as f64*hid as f64*inter as f64, + ffai_ops::moe_fp4_grouped_mma_dev(d, &up_out, dwp, dwsc, dgw, &off_e, n_exp + 2, mt_e, hid, inter, 1, inv, Some(&amax_dn), None, None).unwrap()); + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + moe_scatter_add_det_dev(d, &dn_out, &st_e, &sw_e, &acc_dev, s, mt_e, hid, 256.0f32, true).unwrap(); + shared_folded = true; + if ondevice_moe { acc_dev_keep = Some(acc_dev); } else { acc_h = dl(&acc_dev, s * hid); } + } else if fp4_moe { + let (uwp, uwsc, ugw) = &fp4w[&format!("{p}.moe_up_proj_fp4")]; + let (dwp, dwsc, dgw) = &fp4w[&format!("{p}.moe_down_proj_fp4")]; + // up-GEMM epilogue also computes the DOWN-GEMM's activation + // amax (of relu²-transformed outputs) — kills the full 45MB + // re-read the down amax pass used to do. + let amax_dn = Tensor::new(d.alloc_zeroed(4).unwrap(), vec![1], DType::U32); + let up_gidx = if fp4_direct { moe_dev.as_ref().map(|(_, st, _)| st) } else { None }; + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + ffai_ops::moe_fp4_grouped_mma_dev(d, &xs_in, uwp, uwsc, ugw, off_dev_ref.unwrap(), n_exp, mt, inter, hid, 0, 0.0, None, Some((&amax_dn, 1, inv)), up_gidx).unwrap()); + // relu²·(1/256) fused into the down act-quant (no standalone pass) + let dn_out = pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + ffai_ops::moe_fp4_grouped_mma_dev(d, &up_out, dwp, dwsc, dgw, off_dev_ref.unwrap(), n_exp, mt, hid, inter, 1, inv, Some(&amax_dn), None, None).unwrap()); + // acc_h is still all-zero on this path — seed the accumulator + // with a device-side memset instead of uploading 22MB of zeros. + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + let (_, st_dev, sw_dev) = moe_dev.as_ref().unwrap(); + moe_scatter_add_det_dev(d, &dn_out, st_dev, sw_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + if ondevice_moe { acc_dev_keep = Some(acc_dev); } else { acc_h = dl(&acc_dev, s * hid); } + } else { + // UP: [mt,inter] = xs[mt,hid] · Wup_q4[eid; inter,hid] (n=inter,k=hid) + let up_out = if devdesc { + pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + moe_q4_grouped_mma_dev(d, &xs_in, uqs, usc, off_dev_ref.unwrap(), n_exp, mt, inter, hid).unwrap()) + } else { + pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + moe_q4_grouped_mma(d, &xs_in, uqs, usc, &g_starts, &expert_ids, inter, hid).unwrap()) + }; + let a2_raw = pf!(3, 0.0, relu2_scale_f16(d, &up_out, inv).unwrap()); + let a2 = if fp4_sim { fp4rt(&a2_raw, mt, inter) } else { a2_raw }; + // DOWN: [mt,hid] = a2[mt,inter] · Wdn_q4[eid; hid,inter] (n=hid,k=inter) + let dn_out = if devdesc { + pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + moe_q4_grouped_mma_dev(d, &a2, dqs, dsc, off_dev_ref.unwrap(), n_exp, mt, hid, inter).unwrap()) + } else { + pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + moe_q4_grouped_mma(d, &a2, dqs, dsc, &g_starts, &expert_ids, hid, inter).unwrap()) + }; + // acc_h is still all-zero here — device-side memset, no 22MB upload. + let acc_dev = Tensor::new(d.alloc_zeroed(s * hid * 4).unwrap(), vec![s, hid], DType::F32); + if devdesc { + // Fully on-device scatter: sorted_tok/sorted_wt (st/sw) ARE the + // device token-idx + weight tensors → atomic scatter, NO host + // tidx_h/wts_h (host-sync-free, toward graph capture). + let (_, st_dev, sw_dev) = moe_dev.as_ref().unwrap(); + // DETERMINISTIC device scatter (device CSR build) → host-sync- + // free AND argmax-stable (the atomic scatter flipped 1104/1776). + moe_scatter_add_det_dev(d, &dn_out, st_dev, sw_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + } else { + let wts_h: Vec = triples.iter().map(|(_,_,w)| *w).collect(); + let tidx_h: Vec = triples.iter().map(|(_,t,_)| *t as u32).collect(); + let wts_dev = upm(&wts_h, vec![mt]); + moe_scatter_add_det(d, &dn_out, &tidx_h, &wts_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + } + if ondevice_moe { acc_dev_keep = Some(acc_dev); } else { acc_h = dl(&acc_dev, s * hid); } + } // end !fp4_moe (Q4 grouped path) + } else if use_grouped_gemm { + // ── Grouped-GEMM path (NEMOTRON_GROUPED_GEMM=1) ─────────────── + // All UP dequants (async) → all UP GEMMs (async) → relu2_scale → + // all DN dequants (async) → all DN GEMMs (async) → scatter-add. + // No per-expert host sync. Uses on-device scatter for output. + let xs_f16 = xs_dev_f16_opt.as_ref().unwrap().clone(); + // Build group boundaries (sorted by expert: triples is already sorted). + let mut g_starts: Vec = vec![0]; + let mut expert_ids: Vec = Vec::new(); + { + let mut gi = 0usize; + while gi < mt { + let eid = triples[gi].0 as usize; + expert_ids.push(eid); + let mut gi2 = gi + 1; + while gi2 < mt && triples[gi2].0 as usize == eid { gi2 += 1; } + g_starts.push(gi2); + gi = gi2; + } + } + let dn_out_f16 = pf!(11, 2.0*mt as f64*(inter+hid) as f64*hid as f64, + moe_grouped_gemm(d, uqs, usc, dqs, dsc, &xs_f16, + &g_starts, &expert_ids, hid, inter, up_bpr, down_bpr, + grouped_up_scratch.as_ref(), grouped_dn_scratch.as_ref()).unwrap()); + // Scatter-weight + unscale (×256): same as BGEMM path. + let wts_h: Vec = triples.iter().map(|(_,_,w)| *w).collect(); + let tidx_h: Vec = triples.iter().map(|(_,t,_)| *t as u32).collect(); + let wts_dev = upm(&wts_h, vec![mt]); + let tidx_dev2 = Tensor::new(d.upload(&tbu(&tidx_h)).unwrap(), vec![mt], DType::U32); + let acc_dev = upm(&acc_h, vec![s, hid]); + // dn_out_f16 is [mt, hid] f16 unscaled (relu2 already applied 1/256×); + // scatter_add needs to multiply by w×256 → net effect: w×256/256 = w. + // But scatter_add signature multiplies by (scale/256): pass scale=256 + // so net = w×(256/256) = w (matches the bgemm path). + let dn_out = cast_f16_f32(d, &dn_out_f16).unwrap(); + // DETERMINISTIC scatter by default: the atomicAdd variant is + // run-to-run nondeterministic (FP atomic accumulation order) + // and flips the deep-context argmax. NEMOTRON_ATOMIC_SCATTER=1 + // restores the old atomic kernel (A/B perf only). + if std::env::var("NEMOTRON_ATOMIC_SCATTER").is_ok() { + moe_scatter_add(d, &dn_out, &tidx_dev2, &wts_dev, &acc_dev, mt, hid, 256.0f32).unwrap(); + } else { + let _ = &tidx_dev2; + moe_scatter_add_det(d, &dn_out, &tidx_h, &wts_dev, &acc_dev, s, mt, hid, 256.0f32, false).unwrap(); + } + acc_h = dl(&acc_dev, s * hid); + } else if use_metal_grouped { + // ── Metal GROUPED Q4 GEMM path (NEMOTRON_MOE_GROUPED=1) ─────── + // Token-sort already done (triples sorted by expert; xs_f16 is + // the [mt, hid] f16 gather in expert order). Run ONE grouped + // Q4 MMA GEMM per pass via moe_bgemm_q4_bm64 — the kernel reads + // each row's expert id from `idx_dev` and dequants that expert's + // Q4 nibbles inline (no per-expert f16 weight slab, no per-expert + // dispatch). relu²+scale stays host-f32 (squares can exceed f16 + // max → overflow; this also keeps EXACT-MATCH numerics with the + // validated per-expert path). Host scatter back to token order. + // + // GOTCHA (relu² fusion placement): the CUDA path fuses relu² on + // device (relu2_scale_f16, dispatch_raw_cuda → unavailable on + // Metal). We keep relu² on host f32 between the two grouped GEMMs. + // The down-GEMM's grouping is IDENTICAL to the up-GEMM's (same + // sorted order / same idx_dev), so no re-sort between passes. + let xs_f16 = xs_dev_f16_opt.as_ref().unwrap().clone(); + // Per-row expert ids (sorted): drives the bm64 weight selection. + let idx_u32: Vec = triples.iter().map(|(e,_,_)| *e).collect(); + let idx_dev = Tensor::new(d.upload(&tbu(&idx_u32)).unwrap(), vec![mt], DType::U32); + // UP: [mt, inter] = xs[mt,hid] · Wup[expert][inter,hid]ᵀ (Q4 bm64 MMA) + let up_out = pf!(11, 2.0*mt as f64*inter as f64*hid as f64, + moe_bgemm_q4_bm64(d, &xs_f16, uqs, usc, &idx_dev, mt, inter, hid).unwrap()); + // relu² + scale (÷256) on host f32 (overflow-safe, exact-match). + let up_h = dl(&pf!(15, 0.0, cast_f16_f32(d, &up_out).unwrap()), mt * inter); + let a2_h: Vec = up_h.iter().map(|&v| { let r = v.max(0.0); r*r*inv }).collect(); + let a2_f16 = pf!(15, 0.0, cast_f32_f16(d, &upm(&a2_h, vec![mt, inter])).unwrap()); + // DOWN: [mt, hid] = a2[mt,inter] · Wdn[expert][hid,inter]ᵀ (Q4 bm64 MMA) + let dn_out = pf!(11, 2.0*mt as f64*hid as f64*inter as f64, + moe_bgemm_q4_bm64(d, &a2_f16, dqs, dsc, &idx_dev, mt, hid, inter).unwrap()); + let dn_h = dl(&pf!(15, 0.0, cast_f16_f32(d, &dn_out).unwrap()), mt * hid); + // Host scatter back to token order (router-weighted, ×256 unscale). + for (r,(_e2, t, w)) in triples.iter().enumerate() { + let dr = &dn_h[r*hid..(r+1)*hid]; + let ah = &mut acc_h[(*t)*hid..(*t+1)*hid]; + for i in 0..hid { ah[i] += w * dr[i] * 256.0; } + } + } else { + // ── Per-expert cuBLAS loop ──────────────────────────────────── + // NEMOTRON_TWO_PASS=1: two-pass variant — all UP GEMMs first + // (no intermediate CPU sync), then batch relu2_scale, then all + // DOWN GEMMs, then batch downloads + scatter. This keeps the + // cuBLAS stream uninterrupted for all ~128 experts per layer. + // Default: interleaved per-expert (original validated path). + // NEMOTRON_DEV_RELU2=1: per-expert with on-device relu2 (regresses). + // two_pass uses gemm_cublas; dev_relu2 uses relu2_scale_f16 + // (CUDA-only). On Metal force the interleaved host-relu2 path. + let two_pass = is_cuda && std::env::var("NEMOTRON_TWO_PASS").is_ok(); + // NEMOTRON_FEWER_SYNCS=1: keep the per-expert cuBLAS UP/DOWN + // GEMMs (near-best), but fuse relu² ON DEVICE (relu2_scale_f16) + // and accumulate each expert group into a DEVICE acc via + // moe_scatter_add — so NOTHING is downloaded per expert. One + // dl(acc_dev) at the end of the layer replaces the ~2×128 + // per-expert dl()/cuStreamSynchronize pairs. Implies dev relu². + // DEFAULT-ON for CUDA: FEWER_SYNCS is now deterministic (the htod + // null-stream race was fixed in metaltile) and +26% at zero cost. + // NEMOTRON_FEWER_SYNCS_OFF=1 reverts to the per-expert dl()/sync path. + let fewer_syncs = is_cuda && !two_pass && std::env::var("NEMOTRON_FEWER_SYNCS_OFF").is_err(); + let dev_relu2 = is_cuda && !two_pass && (fewer_syncs || std::env::var("NEMOTRON_DEV_RELU2").is_ok()); + if fewer_syncs { + // ── Batched per-expert path (NEMOTRON_FEWER_SYNCS=1) ────────── + // Keep the per-expert cuBLAS UP/DOWN GEMMs (near-best), but + // collapse the ~2×128 per-expert dl()/cuStreamSynchronize pairs + // into work that stays on the ordered stream, ending in ONE + // dl(acc_dev) per E-layer: + // 1. All UP GEMMs (async) write into one [mt, inter] f16 buffer + // (gemm_tc_off) → on-device relu²+scale (relu2_scale_f16). + // 2. All DOWN GEMMs (async, reading the same buffer) write into + // one [mt, hid] f16 buffer → on-device moe_scatter_add into + // acc_dev → ONE dl(acc_dev). + // Matches the already-shipped GROUPED_GEMM/BGEMM device-MoE math + // (same relu2_scale_f16 + atomic scatter) and the same correctness + // envelope: argmax is bit-stable across most runs but can flip on a + // near-tie, because cuBLAS tensor-core GEMM (gemm_tc_off, shared by + // GROUPED_GEMM) is itself run-to-run nondeterministic (split-K + // accumulation). This is NOT a race introduced here — proven by the + // host-relu²/host-scatter variant below jittering identically, and by + // GROUPED_GEMM landing on the same alt-tokens. The per-expert host + // dl()-per-expert path (default, no flag) stays bit-deterministic. + // NEMOTRON_FEWER_SYNCS_HOST swaps relu²+scatter to host f32 (same + // 2-buffer batching: 2 dl/layer instead of ~256) for A/B. + // CRITICAL: every async-GEMM input (xg/wup/wdn) and the shared + // up_all/a2_all/dn_all buffers must stay alive until the dl() + // drains the stream (else a freed buffer is read mid-GEMM → race). + let host_path = std::env::var("NEMOTRON_FEWER_SYNCS_HOST").is_ok(); + let up_all = Tensor::new(d.alloc(mt * inter * 2).unwrap(), vec![mt, inter], DType::F16); + // ── Phase 1: all UP GEMMs → up_all ── + let mut keep_up: Vec = Vec::new(); + let mut g0 = 0usize; + while g0 < mt { + let e = triples[g0].0 as usize; + let mut g1 = g0 + 1; + while g1 < mt && triples[g1].0 as usize == e { g1 += 1; } + let rows = g1 - g0; + // ONDEVICE_MOE: read A from the device-gathered xs_f16 at + // byte offset g0*hid*2 (no host slice/upload). Else host path. + let (xa_buf, xa_off, xg_keep) = if ondevice_moe { + let xsd = xs_dev_f16_opt.as_ref().unwrap(); + (xsd.buffer.clone(), g0 * hid * 2, None) + } else { + let xg = cast_f32_f16(d, &upm(&xs[g0*hid..g1*hid], vec![rows, hid])).unwrap(); + (xg.buffer.clone(), 0usize, Some(xg)) + }; + let wup = deq16c(&format!("{p}.up.{e}"), uqs, usc, inter, hid, e*inter*up_bpr, resident_w16); + pf!(11, 2.0*rows as f64*inter as f64*hid as f64, + d.gemm_tc_off( + xa_buf.as_ref(), xa_off, + wup.buffer.as_ref(), 0, + up_all.buffer.as_ref(), g0 * inter * 2, + rows, inter, hid, DType::F16).unwrap()); + if let Some(xg) = xg_keep { keep_up.push(xg); } + keep_up.push(wup); + g0 = g1; + } + // relu²+scale: on device (default, fast) or host f32 (deterministic). + let a2_all = if host_path { + let up_h = dl(&cast_f16_f32(d, &up_all).unwrap(), mt * inter); + drop(std::mem::take(&mut keep_up)); // dl drained stream + let a2_h: Vec = up_h.iter().map(|&v| { let r = v.max(0.0); r*r*inv }).collect(); + cast_f32_f16(d, &upm(&a2_h, vec![mt, inter])).unwrap() + } else { + pf!(3, 0.0, relu2_scale_f16(d, &up_all, inv).unwrap()) + }; + // ── Phase 2: all DOWN GEMMs → dn_all ── + let dn_all = Tensor::new(d.alloc(mt * hid * 2).unwrap(), vec![mt, hid], DType::F16); + let mut keep_dn: Vec = vec![a2_all.clone()]; + keep_dn.append(&mut keep_up); // keep UP inputs alive on device path + let mut g0 = 0usize; + while g0 < mt { + let e = triples[g0].0 as usize; + let mut g1 = g0 + 1; + while g1 < mt && triples[g1].0 as usize == e { g1 += 1; } + let rows = g1 - g0; + let wdn = deq16c(&format!("{p}.dn.{e}"), dqs, dsc, hid, inter, e*hid*down_bpr, resident_w16); + pf!(11, 2.0*rows as f64*hid as f64*inter as f64, + d.gemm_tc_off( + a2_all.buffer.as_ref(), g0 * inter * 2, + wdn.buffer.as_ref(), 0, + dn_all.buffer.as_ref(), g0 * hid * 2, + rows, hid, inter, DType::F16).unwrap()); + keep_dn.push(wdn); + g0 = g1; + } + if host_path { + // ONE dl of all DOWN outputs → deterministic host scatter + // (same accumulation order as the validated per-expert path). + let dn_h = dl(&cast_f16_f32(d, &dn_all).unwrap(), mt * hid); + drop(keep_dn); let _ = (&up_all, &dn_all); + for (r,(_e2, t, w)) in triples.iter().enumerate() { + let dr = &dn_h[r*hid..(r+1)*hid]; + let ah = &mut acc_h[(*t)*hid..(*t+1)*hid]; + for i in 0..hid { ah[i] += w * dr[i] * 256.0; } + } + } else { + // On-device cast + scatter-add → ONE dl(acc_dev). unscale=256 + // → net router weight w (matches host-path ×256). + let wts_h: Vec = triples.iter().map(|(_,_,w)| *w).collect(); + let tidx_h: Vec = triples.iter().map(|(_,t,_)| *t as u32).collect(); + let wts_dev = upm(&wts_h, vec![mt]); + let tidx_dev = Tensor::new(d.upload(&tbu(&tidx_h)).unwrap(), vec![mt], DType::U32); + let acc_dev = upm(&acc_h, vec![s, hid]); // acc_h all-zero here + // Deterministic scatter by default (atomicAdd is run-to-run + // nondeterministic). NEMOTRON_ATOMIC_SCATTER=1 → old kernel. + // Default det path reads dn_all (f16) DIRECTLY — skips the + // per-layer cast_f16_f32 + [mt,hid] f32 materialization (~66MB + // write/layer). Atomic fallback still needs f32, cast lazily. + if std::env::var("NEMOTRON_ATOMIC_SCATTER").is_ok() { + let dn_f32 = cast_f16_f32(d, &dn_all).unwrap(); + moe_scatter_add(d, &dn_f32, &tidx_dev, &wts_dev, &acc_dev, mt, hid, 256.0f32).unwrap(); + } else { + let _ = &tidx_dev; + moe_scatter_add_det(d, &dn_all, &tidx_h, &wts_dev, &acc_dev, s, mt, hid, 256.0f32, true).unwrap(); + } + if ondevice_moe { + // Keep the routed accumulator on device — the shared + // expert + residual merge below consume it on-device. + acc_dev_keep = Some(acc_dev); + } else { + acc_h = dl(&acc_dev, s * hid); + } + drop(keep_dn); let _ = (&up_all, &dn_all); + } + } else if two_pass { + // ── Pass 1: All UP GEMMs (async, no sync between experts) ──── + // IMPORTANT: keep xg and wup tensors ALIVE until all GEMMs complete + // (GPU reads these async; dropping them before the stream sync would + // race with the pool reusing their buffers for later dequants). + struct ExpertBatch { + e: usize, g0: usize, g1: usize, + a: Tensor, + _xg: Tensor, // keep alive until after sync + _wup: Tensor, // keep alive until after sync + } + let mut up_batches: Vec = Vec::new(); + let mut g0 = 0usize; + while g0 < mt { + let e = triples[g0].0 as usize; + let mut g1 = g0 + 1; + while g1 < mt && triples[g1].0 as usize == e { g1 += 1; } + let rows = g1 - g0; + let xg = cast_f32_f16(d, &upm(&xs[g0*hid..g1*hid], vec![rows, hid])).unwrap(); + let wup = deq16c(&format!("{p}.up.{e}"), uqs, usc, inter, hid, e*inter*up_bpr, resident_w16); + let a = gemm_cublas(d, &xg, &wup, rows, inter, hid).unwrap(); + up_batches.push(ExpertBatch { e, g0, g1, a, _xg: xg, _wup: wup }); + g0 = g1; + } + // ── Pass 2: Sync once, then host relu2 in f32 (avoids f16 overflow + // in device relu2_scale for large activations), then upload. + // The single d.synchronize() waits for ALL 128 UP GEMMs at once. + d.synchronize().ok(); + let a2s: Vec = up_batches.iter().map(|b| { + let rows = b.g1 - b.g0; + let a_h = dl(&cast_f16_f32(d, &b.a).unwrap(), rows*inter); + let a2_h: Vec = a_h.iter().map(|&v| { let r = v.max(0.0); r*r*inv }).collect(); + cast_f32_f16(d, &upm(&a2_h, vec![rows, inter])).unwrap() + }).collect(); + // ── Pass 3: All DOWN GEMMs (async, no sync between) ──────── + // Keep wdn tensors alive for the same reason as wup. + struct DownBatch { dn: Tensor, g0: usize, g1: usize, _wdn: Tensor } + let mut dn_batches: Vec = Vec::new(); + for (b, a2) in up_batches.iter().zip(&a2s) { + let rows = b.g1 - b.g0; + let wdn = deq16c(&format!("{p}.dn.{}", b.e), dqs, dsc, hid, inter, b.e*hid*down_bpr, resident_w16); + let dn = gemm_cublas(d, a2, &wdn, rows, hid, inter).unwrap(); + dn_batches.push(DownBatch { dn, g0: b.g0, g1: b.g1, _wdn: wdn }); + } + // All inputs (up_batches, a2s) can drop after down GEMMs are enqueued. + drop(up_batches); drop(a2s); + // ── Pass 4+5: Download all dn + scatter ──────────────────── + // The FIRST dl() syncs the stream (GPU already finished all GEMMs). + // Subsequent dl() for other experts: GPU already done, instant sync. + for db in dn_batches { + let rows = db.g1 - db.g0; + let dn_h = dl(&cast_f16_f32(d, &db.dn).unwrap(), rows*hid); + for r in 0..rows { + let (_e2, t, w) = triples[db.g0+r]; + let dr = &dn_h[r*hid..(r+1)*hid]; + let ah = &mut acc_h[t*hid..(t+1)*hid]; + for i in 0..hid { ah[i] += w * dr[i] * 256.0; } + } + } + } else { + // ── Interleaved per-expert (default, validated) ─────────── + let mut g0 = 0usize; + while g0 < mt { + let e = triples[g0].0 as usize; + let mut g1 = g0 + 1; + while g1 < mt && triples[g1].0 as usize == e { g1 += 1; } + let rows = g1 - g0; + let dn_h = if metal_f16_experts { + // ── Metal per-expert GEMM, f16 compute (default) ──────── + // dequant_q4_off → f16 expert weight slab (block offset into + // the shared up/down qs/sc), cast activation → f16, run the + // matmul in f16 (ffai_gemm[F16]). relu2 in host f32 (squares + // can exceed f16 max → overflow). EXACT MATCH vs the f32 path + // (argmax unchanged). No resident cache — re-dequant each + // forward (the f16 cache is a bandwidth loss on Metal). + let xg = pf!(15, 0.0, cast_f32_f16(d, &upm(&xs[g0*hid..g1*hid], vec![rows, hid])).unwrap()); // f16 + let wup = pf!(3, (inter*hid) as f64, dequant_q4_off(d, uqs, usc, inter, hid, DType::F16, e*inter*up_bpr).unwrap()); + let a = pf!(11, 2.0*rows as f64*inter as f64*hid as f64, matmul(d, &wup, &xg).unwrap()); // [rows, inter] f16 + let a_h = dl(&pf!(15, 0.0, cast_f16_f32(d, &a).unwrap()), rows*inter); + let a2_h: Vec = a_h.iter().map(|&v| { let r = v.max(0.0); r*r*inv }).collect(); + let a2 = pf!(15, 0.0, cast_f32_f16(d, &upm(&a2_h, vec![rows, inter])).unwrap()); // f16 + let wdn = pf!(3, (hid*inter) as f64, dequant_q4_off(d, dqs, dsc, hid, inter, DType::F16, e*hid*down_bpr).unwrap()); + let dn = pf!(11, 2.0*rows as f64*hid as f64*inter as f64, matmul(d, &wdn, &a2).unwrap()); // [rows, hid] f16 + dl(&pf!(15, 0.0, cast_f16_f32(d, &dn).unwrap()), rows*hid) + } else if !is_cuda { + // ── Portable Metal per-expert GEMM, f32 (NEMOTRON_METAL_F32_EXPERTS=1) ── + // dequant_q4_off → f32 expert weight slab (block offset into + // the shared up/down qs/sc), then portable f32 `matmul`. + // relu2 in host f32 (matches the CUDA host-relu2 default). + let xg = upm(&xs[g0*hid..g1*hid], vec![rows, hid]); // f32 + let wup = pf!(3, (inter*hid) as f64, dequant_q4_off(d, uqs, usc, inter, hid, DType::F32, e*inter*up_bpr).unwrap()); + let a = pf!(11, 2.0*rows as f64*inter as f64*hid as f64, matmul(d, &wup, &xg).unwrap()); // [rows, inter] f32 + let a_h = dl(&a, rows*inter); + let a2_h: Vec = a_h.iter().map(|&v| { let r = v.max(0.0); r*r*inv }).collect(); + let a2 = upm(&a2_h, vec![rows, inter]); // f32 + let wdn = pf!(3, (hid*inter) as f64, dequant_q4_off(d, dqs, dsc, hid, inter, DType::F32, e*hid*down_bpr).unwrap()); + let dn = pf!(11, 2.0*rows as f64*hid as f64*inter as f64, matmul(d, &wdn, &a2).unwrap()); // [rows, hid] f32 + dl(&dn, rows*hid) + } else { + let xg = cast_f32_f16(d, &upm(&xs[g0*hid..g1*hid], vec![rows, hid])).unwrap(); + let wup = deq16c(&format!("{p}.up.{e}"), uqs, usc, inter, hid, e*inter*up_bpr, resident_w16); + let a = pf!(11, 2.0*rows as f64*inter as f64*hid as f64, gemm_cublas(d, &xg, &wup, rows, inter, hid).unwrap()); + let a2 = if dev_relu2 { + pf!(3, 0.0, relu2_scale_f16(d, &a, inv).unwrap()) + } else { + let a_h = dl(&cast_f16_f32(d, &a).unwrap(), rows*inter); + let a2_h: Vec = a_h.iter().map(|&v| { let r = v.max(0.0); r*r*inv }).collect(); + cast_f32_f16(d, &upm(&a2_h, vec![rows, inter])).unwrap() + }; + let wdn = deq16c(&format!("{p}.dn.{e}"), dqs, dsc, hid, inter, e*hid*down_bpr, resident_w16); + let dn = pf!(11, 2.0*rows as f64*hid as f64*inter as f64, gemm_cublas(d, &a2, &wdn, rows, hid, inter).unwrap()); + dl(&cast_f16_f32(d, &dn).unwrap(), rows*hid) + }; + for r in 0..rows { + let (_e2, t, w) = triples[g0+r]; + let dr = &dn_h[r*hid..(r+1)*hid]; + let ah = &mut acc_h[t*hid..(t+1)*hid]; + for i in 0..hid { ah[i] += w * dr[i] * 256.0; } + } + g0 = g1; + } + } + } + // ── Shared expert: dense over all S (up→relu2→down) ── + // relu2 squares activations → can exceed f16 max (65504) → + // inf/NaN. NEMOTRON_SHARED_GEMV=1 uses the known-correct decode + // gemv per token (A/B oracle); default uses the MMA GEMM (f32 + // to avoid the relu2 overflow). + let (suqs, susc, sm_up, sk_up) = &qw[&format!("{p}.mixer.shared_experts.up_proj.weight")]; + let (sdqs, sdsc, sm_dn, sk_dn) = &qw[&format!("{p}.mixer.shared_experts.down_proj.weight")]; + // Metal shared-expert MMA gate (DEFAULT ON): route the up/down + // GEMMs through the Q4-native cooperative-tensor MMA (gemm_q4_mpp, + // same kernel as the dense projections / per-expert experts) instead + // of dequant_q4→f32 + scalar matmul. relu2 stays in HOST f32 (squares + // can exceed f16 max → overflow) — identical to the routed per-expert + // f16 path (argmax-exact). The GEMM itself moving to Q4-native f16 + // MMA is the win (~2 → ~14 TFLOP/s). Set NEMOTRON_METAL_F32_SHARED=1 + // (or NEMOTRON_MOE_SHARED_MMA=0) to fall back to the f32 dequant+matmul + // path for A/B. This is the Metal DEFAULT (matches metal_f16_experts for + // the routed experts); NEMOTRON_MOE_SHARED_MMA=0 force-disables. + let shared_mma_flag = std::env::var("NEMOTRON_MOE_SHARED_MMA").ok(); + let metal_mma_shared = !is_cuda + && shared_mma_flag.as_deref() != Some("0") + && !std::env::var("NEMOTRON_METAL_F32_SHARED").is_ok(); + // devdesc: fully ON-DEVICE shared expert (no dl) for graph capture. + // device cuBLAS GEMMs + device relu²(full scale — shared activations + // are small, sa²≪f16max so no /256 needed) + device merge below. + let mut sd_dev_opt: Option = None; + let sd_h: Vec = if shared_folded { + Vec::new() // shared already accumulated in the grouped scatter + } else if devdesc_skip && is_cuda { + // devdesc: fully ON-DEVICE shared expert (no dl) — device cuBLAS + // (cublasLt, fast tensor-core) + device relu²(full scale — shared + // activations small, sa²≪f16max) + device merge below. NOTE: this + // is NOT CUDA-graph-capturable (the f16-out cublasLt wmma kernel + // faults on replay), but graphs are a dead end here anyway — the + // host-free forward alone pegs the GPU at ~98% busy, so the win is + // killing the host round-trip idle, NOT graph replay. + // reuse the router's f16 cast of xn (same tensor) when present + let xnh = match &xn_f16_router { + Some(t) => t.clone(), + None => pf!(15, 0.0, cast_f32_f16(d, &xn).unwrap()), + }; + let fp4_shared = fp4w.contains_key(&format!("{p}.sh_up_proj_fp4")); + let sd = if fp4_shared { + // NVFP4 W4A4 shared expert: same grouped kernel, n_exp=1 + // (offsets [0, s] — one run covering all rows). + let off1 = cdev(format!("__off1.{s}"), &|| Tensor::new(d.upload(&tbu(&[0u32, s as u32])).unwrap(), vec![2], DType::U32)); + let (uwp, uwsc, ugw) = &fp4w[&format!("{p}.sh_up_proj_fp4")]; + let (dwp, dwsc, dgw) = &fp4w[&format!("{p}.sh_down_proj_fp4")]; + let amax_sd = Tensor::new(d.alloc_zeroed(4).unwrap(), vec![1], DType::U32); + let sa = pf!(12, 2.0*s as f64*shared_inter as f64*hid as f64, + ffai_ops::moe_fp4_grouped_mma_dev(d, &xnh, uwp, uwsc, ugw, &off1, 1, s, shared_inter, hid, 0, 0.0, None, Some((&amax_sd, 1, 1.0)), None).unwrap()); + pf!(12, 2.0*s as f64*hid as f64*shared_inter as f64, + ffai_ops::moe_fp4_grouped_mma_dev(d, &sa, dwp, dwsc, dgw, &off1, 1, s, hid, shared_inter, 1, 1.0, Some(&amax_sd), None, None).unwrap()) + } else if std::env::var("NEMOTRON_FP4_SHARED_LT").is_ok() { + let q46 = std::env::var("NEMOTRON_FP4_46").is_ok(); + let q46w = q46 || std::env::var("NEMOTRON_FP4_46W").is_ok(); + let q46a = q46 || std::env::var("NEMOTRON_FP4_46A").is_ok(); + // Lt block-scaled FP4 shared expert (~4x the f16 GEMM rate): + // weights packed once (cross-forward cache), activations + // quantized per call. Quant-regime lever (changes numerics). + let (uwq, uwsf, uwg) = { + let key = format!("{p}.shup.lt4"); + if let (Some(t), Some(g)) = (w4lt.borrow().get(&key).cloned(), w4g.borrow().get(&key).cloned()) { (t.0, t.1, g) } else { + let wf = deq16(&format!("{p}.shup"), suqs, susc, *sm_up, *sk_up, 0); + let (wp, wsf, wg) = if q46w { ffai_ops::lt_fp4_quant_g_46(d, &wf, *sm_up, *sk_up).unwrap() } else { ffai_ops::lt_fp4_quant_g(d, &wf, *sm_up, *sk_up).unwrap() }; + w4lt.borrow_mut().insert(key.clone(), (wp.clone(), wsf.clone())); w4g.borrow_mut().insert(key, wg.clone()); (wp, wsf, wg) + } + }; + let (dwq, dwsf, dwg) = { + let key = format!("{p}.shdn.lt4"); + if let (Some(t), Some(g)) = (w4lt.borrow().get(&key).cloned(), w4g.borrow().get(&key).cloned()) { (t.0, t.1, g) } else { + let wf = deq16(&format!("{p}.shdn"), sdqs, sdsc, *sm_dn, *sk_dn, 0); + let (wp, wsf, wg) = if q46w { ffai_ops::lt_fp4_quant_g_46(d, &wf, *sm_dn, *sk_dn).unwrap() } else { ffai_ops::lt_fp4_quant_g(d, &wf, *sm_dn, *sk_dn).unwrap() }; + w4lt.borrow_mut().insert(key.clone(), (wp.clone(), wsf.clone())); w4g.borrow_mut().insert(key, wg.clone()); (wp, wsf, wg) + } + }; + let (xq, xsf, xg) = pf!(15, 0.0, if q46a { ffai_ops::lt_fp4_quant_g_46(d, &xnh, s, hid).unwrap() } else { ffai_ops::lt_fp4_quant_g(d, &xnh, s, hid).unwrap() }); + let ds_u = ffai_ops::fp4_dscale(d, &xg, &uwg).unwrap(); + let sa = pf!(12, 2.0*s as f64*shared_inter as f64*hid as f64, + ffai_ops::gemm_fp4(d, &xq, &xsf, &uwq, &uwsf, s, *sm_up, *sk_up, true, Some(&ds_u)).unwrap()); + let sa2 = pf!(3, 0.0, relu2_scale_f16(d, &cast_f32_f16(d,&sa).unwrap(), 1.0f32).unwrap()); + let (aq, asf, ag) = pf!(15, 0.0, if q46a { ffai_ops::lt_fp4_quant_g_46(d, &sa2, s, shared_inter).unwrap() } else { ffai_ops::lt_fp4_quant_g(d, &sa2, s, shared_inter).unwrap() }); + let ds_d = ffai_ops::fp4_dscale(d, &ag, &dwg).unwrap(); + let sd_lt = pf!(12, 2.0*s as f64*hid as f64*shared_inter as f64, + ffai_ops::gemm_fp4(d, &aq, &asf, &dwq, &dwsf, s, *sm_dn, *sk_dn, true, Some(&ds_d)).unwrap()); + cast_f32_f16(d, &sd_lt).unwrap() + } else { + let wsu = deq16(&format!("{p}.shup"), suqs, susc, *sm_up, *sk_up, 0); + let sa = pf!(12, 2.0*s as f64*shared_inter as f64*hid as f64, + gemm_cublas(d, &xnh, &wsu, s, *sm_up, *sk_up).unwrap()); + let sa2 = pf!(3, 0.0, relu2_scale_f16(d, &sa, 1.0f32).unwrap()); // full scale, device + let wsd = deq16(&format!("{p}.shdn"), sdqs, sdsc, *sm_dn, *sk_dn, 0); + pf!(12, 2.0*s as f64*hid as f64*shared_inter as f64, + gemm_cublas(d, &sa2, &wsd, s, *sm_dn, *sk_dn).unwrap()) // [s,hid] f16 + }; + sd_dev_opt = Some(pf!(15, 0.0, cast_f16_f32(d, &sd).unwrap())); // device f32, full scale + Vec::new() + } else if metal_mma_shared { + // ── Portable Metal shared-expert MMA: gemm_q4_mpp up/down, host relu2 ── + // Cast xn → f16 once; up GEMM [s,hid]·Wupᵀ → [s,shared_inter] f16; + // relu2+(/256) in host f32 (overflow-safe); re-cast → f16; down GEMM + // [s,shared_inter]·Wdnᵀ → [s,hid] f16; (×256) on host. EXACT-MATCH + // numerics vs the f32 path (the f16 up-proj output fits f16 — the + // routed experts already prove this at the same activation scale). + let xnh = pf!(15, 0.0, cast_f32_f16(d, &xn).unwrap()); // [s, hid] f16 + let sa = pf!(12, 2.0*s as f64*shared_inter as f64*hid as f64, + gemm_q4_mpp(d, &xnh, suqs, susc, s, *sm_up, *sk_up).unwrap()); // [s, shared_inter] f16 + let sa_h = dl(&pf!(15, 0.0, cast_f16_f32(d, &sa).unwrap()), s * shared_inter); + let sa2_h: Vec = sa_h.iter().map(|&v| { let r = v.max(0.0); r * r / 256.0 }).collect(); + let sa2 = pf!(15, 0.0, cast_f32_f16(d, &upm(&sa2_h, vec![s, shared_inter])).unwrap()); // f16 + let sd = pf!(12, 2.0*s as f64*hid as f64*shared_inter as f64, + gemm_q4_mpp(d, &sa2, sdqs, sdsc, s, *sm_dn, *sk_dn).unwrap()); // [s, hid] f16 + let sd_h = dl(&pf!(15, 0.0, cast_f16_f32(d, &sd).unwrap()), s * hid); + sd_h.iter().map(|&v| v * 256.0).collect() + } else if !is_cuda { + // ── Portable Metal shared-expert: dequant→f32 + matmul, host relu2 ── + // relu2 squares activations (can exceed f16 max) so we keep the + // intermediate in f32 throughout — no overflow, fully portable. + // A/B fallback (NEMOTRON_METAL_F32_SHARED=1). + let xnf = xn.clone(); // [s, hid] f32 + let wsu = pf!(3, (*sm_up * *sk_up) as f64, dequant_q4(d, suqs, susc, *sm_up, *sk_up, DType::F32).unwrap()); + let sa = pf!(12, 2.0*s as f64*shared_inter as f64*hid as f64, matmul(d, &wsu, &xnf).unwrap()); // [s, shared_inter] f32 + let sa_h = dl(&sa, s * shared_inter); + let sa2_h: Vec = sa_h.iter().map(|&v| { let r = v.max(0.0); r * r / 256.0 }).collect(); + let sa2 = upm(&sa2_h, vec![s, shared_inter]); + let wsd = pf!(3, (*sm_dn * *sk_dn) as f64, dequant_q4(d, sdqs, sdsc, *sm_dn, *sk_dn, DType::F32).unwrap()); + let sd = pf!(12, 2.0*s as f64*hid as f64*shared_inter as f64, matmul(d, &wsd, &sa2).unwrap()); // [s, hid] f32 + let sd_h = dl(&sd, s * hid); + sd_h.iter().map(|&v| v * 256.0).collect() + } else if std::env::var("NEMOTRON_SHARED_GEMV").is_ok() { + let mut out = vec![0.0f32; s * hid]; + for ti in 0..s { + let x1 = slice(d, &xn, ti*hid, hid).unwrap(); + let sa = gemv_q4_relu2(d, suqs, susc, &x1, shared_inter, hid, shared_inter).unwrap(); + let acc1 = up(&vec![0.0f32; hid]); + gemv_q4_accum(d, sdqs, sdsc, &sa, &acc1, &up(&[1.0f32]), *sm_dn, *sk_dn, *sm_dn).unwrap(); + out[ti*hid..(ti+1)*hid].copy_from_slice(&dl(&acc1, hid)); + } + out + } else { + // cuBLAS tensor cores. relu2 squares → overflow f16; use on-device + // relu2_scale_f16 (NEMOTRON_BGEMM or NEMOTRON_DEV_RELU2) to avoid + // the host round-trip; otherwise fall back to host f32 path. + let dev_r2 = use_bgemm || std::env::var("NEMOTRON_DEV_RELU2").is_ok(); + let wsu = deq16(&format!("{p}.shup"), suqs, susc, *sm_up, *sk_up, 0); + let shared_up_flops = 2.0 * s as f64 * shared_inter as f64 * hid as f64; + let sa = pf!(12, shared_up_flops, gemm_cublas(d, &cast_f32_f16(d, &xn).unwrap(), &wsu, s, shared_inter, hid).unwrap()); + let sa2 = if dev_r2 { + // On-device: relu2_scale_f16 fuses relu2 + scale without dl. + pf!(3, 0.0, relu2_scale_f16(d, &sa, 1.0f32 / 256.0).unwrap()) + } else { + let sa_h = dl(&cast_f16_f32(d, &sa).unwrap(), s * shared_inter); + let sa2_h: Vec = sa_h.iter().map(|&v| { let r = v.max(0.0); r * r / 256.0 }).collect(); + cast_f32_f16(d, &upm(&sa2_h, vec![s, shared_inter])).unwrap() + }; + let wsd = deq16(&format!("{p}.shdn"), sdqs, sdsc, *sm_dn, *sk_dn, 0); + let shared_dn_flops = 2.0 * s as f64 * hid as f64 * shared_inter as f64; + let sd = pf!(12, shared_dn_flops, gemm_cublas(d, &sa2, &wsd, s, hid, shared_inter).unwrap()); + let sd_h = dl(&cast_f16_f32(d, &sd).unwrap(), s * hid); + sd_h.iter().map(|&v| v * 256.0).collect() + }; + if let Some(acc_dev) = acc_dev_keep.take() { + // ONDEVICE_MOE: routed acc is already on device. Merge the + // shared-expert output + residual on device — no dl/upm. Under + // devdesc sd is ALREADY device (sd_dev_opt, host-sync-free); + // else upload the host f32 shared output. + { + // fused residual add + NEXT layer's rms_norm (one pass, llama.cpp-style) + let nw = if l + 1 < PATTERN.len() { fwd.get(&format!("language_model.backbone.layers.{}.norm.weight", l + 1)) } else { fwd.get("norm_f") }; + if shared_folded { + // shared already accumulated into acc by the grouped scatter + xt = add(d, &xt, &acc_dev).unwrap(); + } else { + let sd_dev = sd_dev_opt.take().unwrap_or_else(|| upm(&sd_h, vec![s, hid])); + match nw { + Some(w) if is_cuda && std::env::var("NEMOTRON_FUSED_ADDNORM").is_ok() => { let merged = add(d, &acc_dev, &sd_dev).unwrap(); let (r, nx) = ffai_ops::add_rms_norm(d, &xt, &merged, w, eps).unwrap(); xt = r; xn_carry = Some(nx); } + // one launch, no intermediate: residual + routed acc + shared out + _ if is_cuda => { xt = ffai_ops::add3(d, &xt, &acc_dev, &sd_dev).unwrap(); } + _ => { let merged = add(d, &acc_dev, &sd_dev).unwrap(); xt = add(d, &xt, &merged).unwrap(); } + } + } + } + } else { + for i in 0..s*hid { acc_h[i] += sd_h[i]; } + let acc_up = upm(&acc_h, vec![s, hid]); + { + // fused residual add + NEXT layer's rms_norm (one pass, llama.cpp-style) + let nw = if l + 1 < PATTERN.len() { fwd.get(&format!("language_model.backbone.layers.{}.norm.weight", l + 1)) } else { fwd.get("norm_f") }; + match nw { + Some(w) if is_cuda && std::env::var("NEMOTRON_FUSED_ADDNORM").is_ok() => { let (r, nx) = ffai_ops::add_rms_norm(d, &xt, &acc_up, w, eps).unwrap(); xt = r; xn_carry = Some(nx); } + _ => { xt = add(d, &xt, &acc_up).unwrap(); } + } + } + } + } + '*' => { + // Cast xn→f16 ONCE, reuse for q/k/v (NEMOTRON_FUSE_QKV) — else per-proj cast. + let (q_all, k_all, v_all) = if fuse_qkv { + let xnh = pf!(15, 0.0, cast_f32_f16(d, &xn).unwrap()); + (qmm_h(&xnh, &format!("{p}.mixer.q_proj.weight")), + qmm_h(&xnh, &format!("{p}.mixer.k_proj.weight")), + qmm_h(&xnh, &format!("{p}.mixer.v_proj.weight"))) + } else { + (qmm(&xn, &format!("{p}.mixer.q_proj.weight")), + qmm(&xn, &format!("{p}.mixer.k_proj.weight")), + qmm(&xn, &format!("{p}.mixer.v_proj.weight"))) + }; + // [s, qdim]=[s,nq*hd]; k/v [s, kvdim]=[s,nkv*hd] + // PART C: eliminate dl(q)/dl(k)/dl(v) + per-token rope dispatches. + // Build positions [s] once, use batched rope_llama_many + kv_append_many. + if kvcache[l].is_none() { + kvcache[l] = Some((Tensor::new(d.alloc_zeroed(nkv*cap*hd*4).unwrap(), vec![nkv*cap*hd], DType::F32), Tensor::new(d.alloc_zeroed(nkv*cap*hd*4).unwrap(), vec![nkv*cap*hd], DType::F32))); + } + let positions_h: Vec = (0..s).map(|ti| (base + ti) as u32).collect(); + let positions_dev = Tensor::new( + d.upload(unsafe { std::slice::from_raw_parts(positions_h.as_ptr() as *const u8, positions_h.len() * 4) }).unwrap(), + vec![s], DType::U32); + // Batched rope: [s, n_heads, hd] each → rotated in one dispatch. + let qr = pf!(8, 0.0, rope_llama_many(d, &q_all.reshaped(vec![s, nq, hd]), &positions_dev, nq, hd, rope_theta, 1.0, 1.0, 1.0, 8192.0).unwrap()); + let kr = pf!(8, 0.0, rope_llama_many(d, &k_all.reshaped(vec![s, nkv, hd]), &positions_dev, nkv, hd, rope_theta, 1.0, 1.0, 1.0, 8192.0).unwrap()); + // Batched KV-cache append: 2 dispatches replace S*2 per-token kv_append calls. + let (kcache, vcache) = kvcache[l].as_ref().unwrap(); + kv_append_many(d, &kr, &positions_dev, kcache, nkv, hd, cap).unwrap(); + kv_append_many(d, &v_all.reshaped(vec![s, nkv, hd]), &positions_dev, vcache, nkv, hd, cap).unwrap(); + // sdpa_multi: Q [n_query, n_q_heads, hd], K/V [n_kv, cap, hd], causal, base_kv=base. + // Attention path auto-select: the tensor-core cuBLAS flash-attn + // (sdpa_multi_tc) wins big once KV is deep (9.5-14× stage @ d8192+), + // but its prep/transpose overhead regresses shallow KV (d0). So + // auto-enable it once total KV (base+s) crosses ~4096; below that use + // the software-MMA sdpa_multi. Override: NEMOTRON_PREFILL_TCATTN=1 force + // on, =0 force off. + let avg_kv = base as f64 + s as f64 / 2.0; + let attn_flops = 4.0 * nq as f64 * hd as f64 * avg_kv * s as f64; + // sdpa_multi_tc is the cuBLAS tensor-core flash-attn (CUDA-only). + // On Metal always use the portable software-MMA sdpa_multi. + let use_tc_attn = is_cuda && match std::env::var("NEMOTRON_PREFILL_TCATTN").ok().as_deref() { + Some("0") => false, + Some(_) => true, + // Default-on the tensor-core flash-attn for any real prefill + // chunk (s>=512): the software-MMA sdpa_multi runs at ~0.5% MFU + // and was 16% of the S2048 forward; the TC path is 9-14x faster. + // (Was base+s>=4096, which left S2048/d0 on the slow path.) + None => s >= 512 || (base + s) >= 4096, + }; + let attn = if use_tc_attn { + if let Some(seg_lo) = seg_lo_dev.as_ref() { + // A4: A/B scores-in-shared flash kernels vs the HBM-materializing + // varlen path. Single-segment prefill = plain causal [base,base+s). + match std::env::var("NEMOTRON_PACKED_ATTN").ok().as_deref() { + Some("fused") if hd==128 => { let _=(seg_lo,pk_seg_len); + pf!(7, attn_flops, ffai_ops::sdpa_flash_fused(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) } + Some("wmma") if hd==128 => { let _=(seg_lo,pk_seg_len); + pf!(7, attn_flops, ffai_ops::sdpa_flash_wmma(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) } + _ => { + // Packed: block-diagonal varlen flash-attn (each query stays in its segment). + pf!(7, attn_flops, sdpa_multi_tc_varlen(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), seg_lo, pk_seg_len, hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) + } + } + } else if hd == 128 && std::env::var("NEMOTRON_FLASH_MMA_OFF").is_err() + && std::env::var("NEMOTRON_FLASH_WMMA").is_err() + && std::env::var("NEMOTRON_FLASH_FUSED").is_err() { + // DEFAULT (CUDA, non-packed, hd=128): FlashAttention-2 with + // register-resident O (mma.sync) — beats cuBLAS-TC attention + // 1.5-3.3× (no HBM score materialization, causal-skipped, high + // occupancy). Argmax-exact. NEMOTRON_FLASH_MMA_OFF=1 → cuBLAS-TC. + pf!(7, attn_flops, ffai_ops::sdpa_flash_mma(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) + } else if std::env::var("NEMOTRON_FLASH_WMMA").is_ok() { + // Fused tensor-core (wmma) causal FlashAttention: scores + // stay in shared (no HBM round-trip), causal skipped. + pf!(7, attn_flops, ffai_ops::sdpa_flash_wmma(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) + } else if std::env::var("NEMOTRON_FLASH_FUSED").is_ok() { + // Fused single-kernel causal FlashAttention: no HBM + // score/prep round-trip, causal triangle skipped. + pf!(7, attn_flops, ffai_ops::sdpa_flash_fused(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) + } else { + pf!(7, attn_flops, sdpa_multi_tc(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) + } + } else { + pf!(7, attn_flops, sdpa_multi(d, &qr.reshaped(vec![s, nq, hd]), &kcache.reshaped(vec![nkv, cap, hd]), &vcache.reshaped(vec![nkv, cap, hd]), hd, nq as u32, base as u32, s as u32, cap as u32, (nq/nkv) as u32, true, ascale).unwrap()) + }; + // attn [s, nq, hd] = [s, qdim]; o_proj batched. + let o = qmm(&attn.reshaped(vec![s, qdim]), &format!("{p}.mixer.o_proj.weight")); + { + // fused residual add + NEXT layer's rms_norm (one pass, llama.cpp-style) + let nw = if l + 1 < PATTERN.len() { fwd.get(&format!("language_model.backbone.layers.{}.norm.weight", l + 1)) } else { fwd.get("norm_f") }; + match nw { + Some(w) if is_cuda && std::env::var("NEMOTRON_FUSED_ADDNORM").is_ok() => { let (r, nx) = ffai_ops::add_rms_norm(d, &xt, &o, w, eps).unwrap(); xt = r; xn_carry = Some(nx); } + _ => { xt = add(d, &xt, &o).unwrap(); } + } + } + } + _ => unreachable!(), + } + // INSTRUMENTATION (revert): dump a token's hidden after layer l. + // DUMP_TOK0=1 → token 0 (position 0, no cross-token influence); + // else last token. + if std::env::var("NEMOTRON_DUMP_LAYERS").is_ok() { + let tsel = if std::env::var("NEMOTRON_DUMP_TOK0").is_ok() { 0 } else { s-1 }; + let h = dl(&slice(d, &xt, tsel*hid, hid).unwrap(), hid); + BATCHED_LAYER_TRACE.with(|c| { let mut v = c.borrow_mut(); if v.len() <= l { v.resize(l+1, Vec::new()); } v[l] = h; }); + } + } + // final norm + lm_head on the LAST token only — DEVICE-CLEAN (no dl→up + // roundtrip): slice the last token on device, lm_head GEMM on device → + // device logits. Store the device logits for the graph path; dl for argmax + // ONLY when not capturing (a dl aborts cuda-graph stream capture). + let xf = match xn_carry.take() { + Some(t) => t, + None => rms_norm(d, &xt, &fwd["norm_f"], eps).unwrap(), // [s, hid] + }; + let last_dev = slice(d, &xf, (s-1)*hid, hid).unwrap(); // [hid] device + let logits_dev = pf!(14, 2.0 * vocab as f64 * hid as f64, qmv(&last_dev, "language_model.lm_head.weight")); + LAST_BATCHED_LOGITS_DEV.with(|c| *c.borrow_mut() = Some(logits_dev.clone())); + if CAPTURING.with(|c| c.get()) { 0usize } + else { + let logits = dl(&logits_dev, vocab); + let am = ffai_runtime::argmax(&logits); + LAST_BATCHED_LOGITS.with(|c| *c.borrow_mut() = logits); + am + } + }; + + // Fixed prompt id list (deterministic ramp). The correctness gate runs BOTH + // the sequential and batched paths over THIS SAME list and compares the + // last-token argmax — the KV cache + conv/SSM final states must agree. + let ids: Vec = { + let base_ids: Vec = (0..s).map(|i| (tok + i) % vocab).collect(); + // NEMOTRON_PACKED=N: replicate the prompt into N packed sequences (s·N + // tokens) to fill the GPU; forward_batched runs them block-diagonally. + let np = std::env::var("NEMOTRON_PACKED").ok() + .and_then(|v| v.parse::().ok()).unwrap_or(1).max(1); + if np > 1 { base_ids.iter().cloned().cycle().take(s * np).collect() } else { base_ids } + }; + + // ── Correctness gate (NEMOTRON_PREFILL_CHECK=1) ────────────────────── + // Sequential reference over the fixed ids: feed ids[i] at pos fakectx+i, + // ignore the chained argmax, take the argmax after the last token. + if std::env::var("NEMOTRON_PREFILL_CHECK").is_ok() { + let mut seq_conv: Vec> = vec![Vec::new(); 52]; + let seq_convdev: std::cell::RefCell>> = std::cell::RefCell::new((0..52).map(|_| None).collect()); + let mut seq_ssm: Vec> = (0..52).map(|_| None).collect(); + let mut seq_kv: Vec> = (0..52).map(|_| None).collect(); + // swap in the sequential conv_dev (step closure captured conv_dev by ref + // via parameter; the all-device Mamba uses conv_dev borrow). To keep the + // reference independent we run step with its own conv_dev: the step + // closure references the outer `conv_dev` RefCell, so reset it first. + { let mut cd = conv_dev.borrow_mut(); for c in cd.iter_mut() { *c = None; } } + let _ = &seq_convdev; let _ = &mut seq_conv; + let mut seq_argmax = 0usize; + let dump_tok0 = std::env::var("NEMOTRON_DUMP_TOK0").is_ok(); + for (i, &id) in ids.iter().enumerate() { + seq_argmax = step(id, fakectx + i, &mut seq_conv, &mut seq_ssm, &mut seq_kv); + // INSTRUMENTATION (revert): when comparing token 0, freeze the + // sequential trace at the FIRST step (it's overwritten each call). + if dump_tok0 && i == 0 { + let frozen = STEP_LAYER_TRACE.with(|c| c.borrow().clone()); + STEP0_FROZEN.with(|c| *c.borrow_mut() = frozen); + } + } + if dump_tok0 { + let f = STEP0_FROZEN.with(|c| c.borrow().clone()); + STEP_LAYER_TRACE.with(|c| *c.borrow_mut() = f); + } + d.synchronize().ok(); + // reset shared conv_dev (step mutated the OUTER one) before batched run. + // INSTRUMENTATION: snapshot the sequential reference logits BEFORE any + // batched run can overwrite shared thread_locals. + let seq_logits = LAST_STEP_LOGITS.with(|c| c.borrow().clone()); + // ── NONDETERMINISM PROBE (correctness audit; REVERT) ───────────────── + // Run the batched forward N times with IDENTICAL ids + fresh states. + // Record per-run last-token argmax + max-abs logit delta vs run 0. + let n_repeat: usize = std::env::var("NEMOTRON_REPEAT").ok().and_then(|s| s.parse().ok()).unwrap_or(1); + let mut run0_logits: Vec = Vec::new(); + let mut last_bat_logits: Vec = Vec::new(); + let mut bat_argmax = 0usize; + for r in 0..n_repeat.max(1) { + { let mut cd = conv_dev.borrow_mut(); for c in cd.iter_mut() { *c = None; } } + let mut chk_ssm: Vec> = (0..52).map(|_| None).collect(); + let mut chk_kv: Vec> = (0..52).map(|_| None).collect(); + let am = forward_batched(&ids, &conv_dev, &mut chk_ssm, &mut chk_kv, fakectx); + d.synchronize().ok(); + let bl = LAST_BATCHED_LOGITS.with(|c| c.borrow().clone()); + if r == 0 { run0_logits = bl.clone(); bat_argmax = am; } + // max-abs delta vs run0 + let maxd = if run0_logits.len() == bl.len() { + run0_logits.iter().zip(bl.iter()).map(|(a,b)| (a-b).abs()).fold(0.0f32, f32::max) + } else { f32::NAN }; + // top-2 gap of this run (near-tie indicator) + let mut sorted: Vec = bl.clone(); sorted.sort_by(|a,b| b.partial_cmp(a).unwrap()); + let top1 = sorted.get(0).copied().unwrap_or(0.0); + let top2 = sorted.get(1).copied().unwrap_or(0.0); + eprintln!(" [REPEAT {r}] batched argmax={am} maxAbsΔ(vs run0)={maxd:.6} top1={top1:.5} top2={top2:.5} top1-top2={:.6}", top1-top2); + last_bat_logits = bl; + } + let _ = last_bat_logits; + // Near-tie diagnosis: rank the sequential argmax within the batched + // logit distribution + report the gap between batched-argmax and the + // sequential token's logit. If they differ but the gap is < a few %, + // it's a precision near-tie (Q4-GEMV vs dequant→f32-GEMM), not a bug. + let blog = if !run0_logits.is_empty() { run0_logits.clone() } else { LAST_BATCHED_LOGITS.with(|c| c.borrow().clone()) }; + // A/B hook: dump batched logits to a file for cross-run comparison + // (e.g. SSD-on vs SSD-off). NEMOTRON_DUMP_BLOGITS=. + if let Ok(path) = std::env::var("NEMOTRON_DUMP_BLOGITS") { + let bytes: Vec = blog.iter().flat_map(|x| x.to_le_bytes()).collect(); + let _ = std::fs::write(&path, &bytes); + eprintln!(" [dumped {} batched logits to {path}]", blog.len()); + } + let (rank, seq_logit, top_logit) = if !blog.is_empty() && seq_argmax < blog.len() { + let sv = blog[seq_argmax]; + let top = blog[bat_argmax]; + let rank = blog.iter().filter(|&&x| x > sv).count(); + (rank, sv, top) + } else { (usize::MAX, 0.0, 0.0) }; + // PASS criterion for a precision-differing path: exact argmax match, + // OR the sequential argmax sits in the batched top-5 within a <2% logit + // gap (a Q4-GEMV-vs-dequant-f32-GEMM near-tie, not a structural bug). + let gap_pct = (top_logit - seq_logit).abs() / top_logit.abs().max(1e-6) * 100.0; + // A benign precision flip = the two argmaxes are a reshuffle WITHIN the shared + // top-5 (same candidate set), not a wrong prediction. Accumulated f16/Q4 drift + // over long S widens the top-1/top-2 gap (~5% at S=2048) while the top-5 stays + // identical (cosine >0.998) — still benign. So key the verdict on top-5 agreement, + // not just a fixed gap%. (Fixed-2% flagged the S=2048 near-tie as a false "bug".) + let top5q = |v: &[f32]| { let mut idx: Vec = (0..v.len()).collect(); + idx.sort_by(|&a,&b| v[b].partial_cmp(&v[a]).unwrap()); idx.truncate(5); idx }; + let t5_overlap = if !seq_logits.is_empty() && seq_logits.len()==blog.len() { + let (a,b)=(top5q(&seq_logits),top5q(&blog)); a.iter().filter(|x| b.contains(x)).count() + } else { 0 }; + let near_tie = rank < 5 && (gap_pct < 2.0 || t5_overlap >= 4); + let pass = seq_argmax == bat_argmax || near_tie; + eprintln!("──────── PREFILL CORRECTNESS GATE (S={s}) ────────"); + eprintln!(" sequential last-token argmax = {seq_argmax}"); + eprintln!(" batched last-token argmax = {bat_argmax}"); + if seq_argmax != bat_argmax { + eprintln!(" near-tie diag: seq token rank in batched dist = {rank} (0=would-be-argmax)"); + eprintln!(" logit[batched_argmax]={top_logit:.5} logit[seq_argmax]={seq_logit:.5} gap={:.5} ({gap_pct:.3}%)", + top_logit - seq_logit); + } + eprintln!(" {}", if seq_argmax == bat_argmax { "EXACT MATCH ✓" } + else if near_tie { "NEAR-TIE PASS ✓ (precision flip within shared top-5)" } + else { "MISMATCH ✗ (structural bug)" }); + let _ = pass; + // ── LOGIT-LEVEL AGREEMENT (correctness audit; REVERT) ──────────────── + // The real correctness question: does the FULL batched logit vector + // agree with the trusted sequential reference? argmax can flip on a + // near-tie; cosine + top-5 overlap + max-abs/rel err are the signal. + if seq_logits.len() == blog.len() && !blog.is_empty() { + let n = blog.len(); + // cosine similarity + let (mut dot, mut na, mut nb) = (0f64, 0f64, 0f64); + let (mut maxabs, mut sumsq_err, mut sumsq_ref) = (0f64, 0f64, 0f64); + for i in 0..n { + let (a, b) = (seq_logits[i] as f64, blog[i] as f64); + dot += a*b; na += a*a; nb += b*b; + let e = (a-b).abs(); + if e > maxabs { maxabs = e; } + sumsq_err += (a-b)*(a-b); sumsq_ref += a*a; + } + let cos = dot / (na.sqrt()*nb.sqrt()).max(1e-30); + let rel_l2 = (sumsq_err.sqrt()) / (sumsq_ref.sqrt()).max(1e-30); + // top-5 of each + let top5 = |v: &[f32]| { let mut idx: Vec = (0..v.len()).collect(); + idx.sort_by(|&a,&b| v[b].partial_cmp(&v[a]).unwrap()); idx.truncate(5); idx }; + let t5s = top5(&seq_logits); let t5b = top5(&blog); + let overlap = t5s.iter().filter(|x| t5b.contains(x)).count(); + eprintln!("──────── LOGIT-LEVEL AGREEMENT (seq-ref vs batched, S={s}) ────────"); + eprintln!(" cosine similarity = {cos:.8}"); + eprintln!(" max-abs logit error = {maxabs:.6}"); + eprintln!(" relative L2 error = {rel_l2:.6}"); + eprintln!(" top-5 overlap = {overlap}/5"); + eprintln!(" seq-ref top5 = {:?}", t5s.iter().map(|&i| (i, seq_logits[i])).collect::>()); + eprintln!(" batched top5 = {:?}", t5b.iter().map(|&i| (i, blog[i])).collect::>()); + } else { + eprintln!(" LOGIT-LEVEL: length mismatch seq={} bat={} — skipped", seq_logits.len(), blog.len()); + } + // ── PER-LAYER DIVERGENCE TRACE (NEMOTRON_DUMP_LAYERS=1; REVERT) ─────── + if std::env::var("NEMOTRON_DUMP_LAYERS").is_ok() { + let st = STEP_LAYER_TRACE.with(|c| c.borrow().clone()); + let bt = BATCHED_LAYER_TRACE.with(|c| c.borrow().clone()); + eprintln!("──────── PER-LAYER LAST-TOKEN DIVERGENCE (seq vs batched) ────────"); + eprintln!(" layer mix : cosine maxAbs relL2"); + let nl = st.len().min(bt.len()); + for l in 0..nl { + if st[l].len() != bt[l].len() || st[l].is_empty() { continue; } + let mix = PATTERN.chars().nth(l).unwrap_or('?'); + let (mut dot, mut na, mut nb, mut maxa, mut se, mut sr) = (0f64,0f64,0f64,0f64,0f64,0f64); + for i in 0..st[l].len() { + let (a,b) = (st[l][i] as f64, bt[l][i] as f64); + dot+=a*b; na+=a*a; nb+=b*b; let e=(a-b).abs(); if e>maxa {maxa=e;} se+=(a-b)*(a-b); sr+=a*a; + } + let cos = dot/(na.sqrt()*nb.sqrt()).max(1e-30); + let rl2 = se.sqrt()/sr.sqrt().max(1e-30); + eprintln!(" L{l:>3} {mix} : {cos:.6} {maxa:.5} {rl2:.5}"); + } + eprintln!("──────────────────────────────────────────────────"); + } + // ── L0 SSM IN/OUT COMPARISON (NEMOTRON_DUMP_SSM=1; REVERT) ─────────── + if std::env::var("NEMOTRON_DUMP_SSM").is_ok() { + let (sx,sb,sc,sy) = SEQ_SSM_DUMP.with(|c| c.borrow().clone()); + let (bx,bb,bc,by) = BAT_SSM_DUMP.with(|c| c.borrow().clone()); + let cmp = |name: &str, a: &[f32], b: &[f32]| { + if a.len()!=b.len() || a.is_empty() { eprintln!(" {name}: len mismatch {}/{}", a.len(), b.len()); return; } + let (mut dot,mut na,mut nb,mut mx)=(0f64,0f64,0f64,0f64); + for i in 0..a.len(){let(x,y)=(a[i]as f64,b[i]as f64);dot+=x*y;na+=x*x;nb+=y*y;let e=(x-y).abs();if e>mx{mx=e;}} + let cos=dot/(na.sqrt()*nb.sqrt()).max(1e-30); + eprintln!(" {name:>6}: cosine={cos:.7} maxAbs={mx:.6}"); + }; + eprintln!("──────── L0 SSM IN/OUT (seq vs batched, LAST token) ────────"); + cmp("x_ssm", &sx, &bx); + cmp("B", &sb, &bb); + cmp("C", &sc, &bc); + cmp("y(SSM)", &sy, &by); + eprintln!("──────────────────────────────────────────────────"); + } + eprintln!("──────────────────────────────────────────────────"); + // reset shared state after the gate. + { let mut cd = conv_dev.borrow_mut(); for c in cd.iter_mut() { *c = None; } } + } + + // Warm (JIT) then timed. + let _ = forward_batched(&ids, &conv_dev, &mut ssm_state, &mut kvcache, fakectx); + d.synchronize().ok(); + // reset states for the timed run (warm mutated them). + { let mut cd = conv_dev.borrow_mut(); for c in cd.iter_mut() { *c = None; } } + for s2 in ssm_state.iter_mut() { *s2 = None; } + for kv in kvcache.iter_mut() { *kv = None; } + ROUTER_HOST_T.with(|c| c.set(0.0)); + // NEMOTRON_GRAPH=1 (CUDA, needs NEMOTRON_Q4_DEVDESC for a dl-free forward): + // capture the whole forward into a CUDA graph, then time the REPLAY — kills + // the host-launch-serialized GPU idle (the ~2× prefill lever vs vLLM). + let graph_mode = std::env::var("NEMOTRON_GRAPH").is_ok() && d.backend() == Backend::Cuda; + let (next, pf_s) = if graph_mode { + // Pre-warm the caching pool to its high-water mark so NO cuMemAlloc fires + // during capture (alloc is illegal while the stream is capturing). Two extra + // forwards + resets fill the pool free-list with every scratch size the + // forward needs concurrently. + for _ in 0..2 { + let _ = forward_batched(&ids, &conv_dev, &mut ssm_state, &mut kvcache, fakectx); + d.synchronize().ok(); + { let mut cd = conv_dev.borrow_mut(); for c in cd.iter_mut() { *c = None; } } + for s2 in ssm_state.iter_mut() { *s2 = None; } + for kv in kvcache.iter_mut() { *kv = None; } + } + CAPTURING.with(|c| c.set(true)); + d.begin_capture().expect("begin_capture"); + let _ = forward_batched(&ids, &conv_dev, &mut ssm_state, &mut kvcache, fakectx); + let exec = d.end_capture().expect("end_capture"); + CAPTURING.with(|c| c.set(false)); + d.synchronize().ok(); + // timed replay (graph_launch re-runs the recorded ops on the captured buffers) + let t = Instant::now(); + d.graph_launch(exec).expect("graph_launch"); + d.synchronize().ok(); + let rep = t.elapsed().as_secs_f64(); + let logits = LAST_BATCHED_LOGITS_DEV.with(|c| c.borrow().clone()).map(|t| dl(&t, vocab)).unwrap_or_default(); + let am = ffai_runtime::argmax(&logits); + LAST_BATCHED_LOGITS.with(|c| *c.borrow_mut() = logits); + eprintln!(" ── CUDA-GRAPH replay: {rep:.3}s = {:.2} tok/s (captured forward, argmax {am}) ──", ids.len() as f64 / rep); + // I1: BF16/W4A16-oracle metric. NEMOTRON_DUMP_LOGITS= writes last-token + // logits (run a high-precision/no-fp4-act config to make the oracle). + // NEMOTRON_BF16_REF= loads it and reports cosine/argmax/top5 vs current. + { + let bl = LAST_BATCHED_LOGITS.with(|c| c.borrow().clone()); + if let Ok(p) = std::env::var("NEMOTRON_DUMP_LOGITS") { + let by: Vec = bl.iter().flat_map(|f| f.to_le_bytes()).collect(); + std::fs::write(&p, &by).ok(); + eprintln!(" [ORACLE] dumped {} logits -> {p}", bl.len()); + } + if let Ok(p) = std::env::var("NEMOTRON_BF16_REF") { + let nf = bl.iter().filter(|x| !x.is_finite()).count(); + if nf > 0 { eprintln!(" [BF16-REF] {nf} non-finite logits — skip"); } + else if let Ok(rb) = std::fs::read(&p) { + let rl: Vec = rb.chunks(4).map(|c| f32::from_le_bytes([c[0],c[1],c[2],c[3]])).collect(); + if rl.len()==bl.len() && !bl.is_empty() { + let (mut dot,mut na,mut nb)=(0f64,0f64,0f64); + for (a,b) in bl.iter().zip(rl.iter()){ dot+=(*a as f64)*(*b as f64); na+=(*a as f64).powi(2); nb+=(*b as f64).powi(2); } + let cos=dot/(na.sqrt()*nb.sqrt()+1e-30); + let amb=ffai_runtime::argmax(&bl); let amr=ffai_runtime::argmax(&rl); + let t5=|v:&[f32]|{let mut ix:Vec=(0..v.len()).collect(); ix.sort_by(|&a,&b| v[b].partial_cmp(&v[a]).unwrap()); ix.truncate(5); ix}; + let (tb,tr)=(t5(&bl),t5(&rl)); let ov=tr.iter().filter(|x| tb.contains(x)).count(); + eprintln!(" [BF16-REF vs {p}] cosine={cos:.6} argmax={amb} (oracle {amr}){} top5-overlap={ov}/5", if amb==amr {" EXACT*"} else {" MISMATCH"}); + } else { eprintln!(" [BF16-REF] len mismatch {} vs {}", bl.len(), rl.len()); } + } + } + } + (am, rep) + } else { + let t_pf = Instant::now(); + let next = forward_batched(&ids, &conv_dev, &mut ssm_state, &mut kvcache, fakectx); + d.synchronize().ok(); + (next, t_pf.elapsed().as_secs_f64()) + }; + let n_tok = ids.len(); + let tps_batched = n_tok as f64 / pf_s; + let conv_dev_on = std::env::var("NEMOTRON_CONV_DEVICE").is_ok() + || (d.backend() == Backend::Cuda && std::env::var("NEMOTRON_CONV_DEVICE_OFF").is_err()); + let conv_dev_label = if conv_dev_on { "CONV_DEVICE=1" } else { "CONV_DEVICE=0(host)" }; + let ondevice_moe_on = d.backend() == Backend::Cuda && std::env::var("NEMOTRON_ONDEVICE_MOE_OFF").is_err(); + eprintln!("──────── NemotronH-Nano BATCHED PREFILL on {plat} ────────"); + eprintln!(" prefill {n_tok} tok in {pf_s:.3}s = {tps_batched:.2} tok/s ({:.2} ms/tok) [batched forward, {conv_dev_label}, ONDEVICE_MOE={}]", pf_s * 1000.0 / n_tok as f64, if ondevice_moe_on { "1" } else { "0" }); + eprintln!(" last-token argmax = {next}"); + if std::env::var("NEMOTRON_ROUTER_TIME").is_ok() { + eprintln!(" host router loop (sigmoid+topk+sort, all E-layers) = {:.1} ms ({:.1}% of wall)", + ROUTER_HOST_T.with(|c| c.get()) * 1000.0, ROUTER_HOST_T.with(|c| c.get()) / pf_s * 100.0); + } + eprintln!("──────────────────────────────────────────────────────────────"); + // Append to ~/prefill_overnight.log. + { + use std::io::Write; + let logpath = format!("{}/prefill_overnight.log", std::env::var("HOME").unwrap_or_else(|_| "/tmp".into())); + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let line = format!("[{ts}] {plat} S={s} {conv_dev_label} CHUNKED={} tps={tps_batched:.2} ms/tok={:.2} argmax={next}\n", + if std::env::var("NEMOTRON_CHUNKED_SCAN").is_ok() { "1" } else { "0" }, + pf_s * 1000.0 / s as f64); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&logpath) { + let _ = f.write_all(line.as_bytes()); + } + } + + // ── PREFILL PER-OP PROFILING MAP (NEMOTRON_PROFILE=1) ─────────────── + // The timed run above had profiling OFF (clean throughput). Now run one + // sync-bracketed profiled pass and emit the per-op map (ms / % / calls / + // GFLOP/s). GB10 Blackwell tensor-core peak (bf16, dense) ≈ 1000 TFLOP/s; + // we print effective GFLOP/s and %-of-peak for the compute-bound ops. + if pf_on { + { let mut cd = conv_dev.borrow_mut(); for c in cd.iter_mut() { *c = None; } } + for s2 in ssm_state.iter_mut() { *s2 = None; } + for kv in kvcache.iter_mut() { *kv = None; } + for c in pf_t.iter() { c.set(0.0); } for c in pf_n.iter() { c.set(0); } for c in pf_flop.iter() { c.set(0.0); } + let t_prof = Instant::now(); + let _ = forward_batched(&ids, &conv_dev, &mut ssm_state, &mut kvcache, fakectx); + d.synchronize().ok(); + let prof_wall = t_prof.elapsed().as_secs_f64(); + let names = ["embed","rms_norm","proj_gemm","dequant_q4","conv_prefill","ssm_scan","gated_norm","sdpa_prefill","rope","kv_append","moe_router","moe_experts","moe_shared","add","lm_head","slice/cast"]; + const PEAK_TFLOPS: f64 = 1000.0; // GB10 Blackwell bf16 dense tensor-core peak (approx) + let sum_t: f64 = pf_t.iter().map(|c| c.get()).sum(); + let mut rows: Vec<(usize,f64,u64,f64)> = (0..NPF).map(|i| (i, pf_t[i].get(), pf_n[i].get(), pf_flop[i].get())).filter(|r| r.2 > 0).collect(); + rows.sort_by(|a,b| b.1.partial_cmp(&a.1).unwrap()); + let is_cuda = d.backend() == Backend::Cuda; + let mut md = String::new(); + md.push_str(&format!("# Nemotron-Nano-30B BATCHED PREFILL — per-op profiling map\n\n")); + if is_cuda { + md.push_str(&format!("- Device: {plat} (GB10 Blackwell)\n- S (prompt tokens): {s}\n")); + } else { + md.push_str(&format!("- Device: {plat} (Apple/Metal)\n- S (prompt tokens): {s}\n")); + } + md.push_str(&format!("- Clean batched throughput: **{:.1} tok/s** ({:.2} ms/tok)\n", s as f64 / pf_s, pf_s * 1000.0 / s as f64)); + md.push_str(&format!("- Profiled pass wall (sync-bracketed, inflated): {prof_wall:.3}s; summed op time: {sum_t:.3}s\n")); + if is_cuda { + md.push_str(&format!("- vLLM-on-GB10 reference: pp2048@d0=6395, @d8192=4993, @d32768=2734 tok/s\n")); + md.push_str(&format!("- Tensor-core peak assumed: {PEAK_TFLOPS:.0} TFLOP/s (bf16 dense)\n\n")); + } else { + md.push_str("\n"); + } + md.push_str("| op | ms | % | calls | TFLOP/s | %peak |\n|---|---:|---:|---:|---:|---:|\n"); + for (i,t,n,fl) in &rows { + let ms = t * 1000.0; + let pct = if sum_t > 0.0 { t / sum_t * 100.0 } else { 0.0 }; + let tflops = if *t > 0.0 && *fl > 0.0 { fl / t / 1e12 } else { 0.0 }; + let peakpct = tflops / PEAK_TFLOPS * 100.0; + let tfs = if tflops > 0.0 { format!("{tflops:.3}") } else { "—".into() }; + let pps = if tflops > 0.0 { format!("{peakpct:.2}%") } else { "—".into() }; + md.push_str(&format!("| {} | {ms:.2} | {pct:.1}% | {n} | {tfs} | {pps} |\n", names[*i])); + } + if is_cuda { + md.push_str("\n## Gap analysis (Path A — cuBLAS tensor cores LANDED)\n"); + md.push_str("- **The GEMMs now hit the tensor cores via the cuBLAS escape hatch (`gemm_cublas`/`cublasGemmEx`, f16×f16→f32 accumulate).** proj_gemm jumped ~1→90 TFLOP/s (0.1%→9% of peak), moe_shared ~1→74, moe_experts ~0.8→28 — a 35-82× per-GEMM speedup vs the software-emulated coop_tile MMA. End-to-end prefill went from ~80 to 178-259 tok/s (peak at S=512).\n"); + md.push_str("- **New #1 bottleneck: `ssm_scan` (~41%)** — the sequential-in-T Mamba `ssm_step_record`. With the GEMMs fast, this is now the critical path → the deferred chunked/parallel SSD scan is the next target.\n"); + md.push_str("- **`dequant_q4` ~26% (5500 calls):** Q4 weights are re-dequanted to f16 EVERY forward (per layer + per routed expert). This is pure redundant work → cache the f16-dequanted weights resident once at load (trades VRAM for ~26% of prefill time).\n"); + md.push_str("- `sdpa_prefill`: software-MMA coop_tile by default; set NEMOTRON_PREFILL_TCATTN=1 for the cuBLAS tensor-core FlashAttention path (`sdpa_multi_tc`). At deep context (zero-KV synthetic) the TC path cuts this stage ~9.5× @d8192 (1754→185ms, 54.7%→11.3%), ~14× @d16384 (4616→328ms), ~13.7× @d32768 (8958→652ms), lifting end-to-end prefill from 104→165 tok/s @d32768.\n"); + md.push_str("- `moe_experts` ~12%: the GEMM is fast (28 TFLOP/s) but the per-expert cuBLAS loop + host relu2/scatter round-trips remain serial → fuse on-device or use cublasGemmStridedBatched.\n"); + md.push_str("- Throughput peaks at S=512 then declines (S=2048→178, S=8192→117): the per-expert host round-trips + dequant grow with S/expert-count. Removing dequant-per-forward + the host MoE bridges should restore monotonic scaling toward the vLLM band.\n"); + } else { + md.push_str("\n## Notes (Apple/Metal path)\n"); + md.push_str("- GEMMs run on Apple GPU hardware MMA: dense projections via `gemm_q4_mpp` (Q4-native cooperative-tensor MMA), per-expert/shared MoE via dequant_q4_off + `matmul`. No cuBLAS/tensor-core escape hatches (those are CUDA-only).\n"); + md.push_str("- Per-expert MoE GEMM runs in **f16 by default** (dequant→f16 + f16 matmul); set NEMOTRON_METAL_F32_EXPERTS=1 for the f32 fallback. The f16-compute path is the measured Metal prefill win (M5 Max: S=512 +26.6%, S=2048 98.8→113.5 tok/s) at EXACT-MATCH numerics.\n"); + md.push_str("- A resident f16 expert cache was tried and is a NET LOSS on Metal (bandwidth/residency bound): re-dequanting the compact Q4 each forward streams better than a fat f16 working set — so weights are re-dequanted per forward, not cached.\n"); + md.push_str("- The %peak column above is computed against the GB10 tensor-core peak and is not meaningful on Metal; read the ms / % columns instead.\n"); + } + let out_path = std::env::var("NEMOTRON_PROFILE_OUT").unwrap_or_else(|_| "/tmp/PROFILING_PREFILL.md".into()); + let _ = std::fs::write(&out_path, &md); + eprintln!("\n{md}\n(written to {out_path})"); + } + let _ = next; + return; + } + + if prefill > 0 { + let warm = step(tok, pos, &mut conv_state, &mut ssm_state, &mut kvcache); tok = warm; pos += 1; + let t_pf = Instant::now(); + for _ in 1..prefill { let nxt = step(tok, pos, &mut conv_state, &mut ssm_state, &mut kvcache); tok = nxt; pos += 1; } + d.synchronize().ok(); + let pf_s = t_pf.elapsed().as_secs_f64(); + let pf_toks = (prefill - 1) as f64; + eprintln!("──────── NemotronH-Nano SEQUENTIAL PREFILL on {plat} ────────"); + eprintln!(" prefill {} tok in {pf_s:.3}s = {:.2} tok/s ({:.1} ms/tok) [baseline, sequential decode steps]", prefill - 1, pf_toks / pf_s, pf_s * 1000.0 / pf_toks); + eprintln!("──────────────────────────────────────────────────────────────"); + } + let first = step(tok, pos, &mut conv_state, &mut ssm_state, &mut kvcache); // 1 warm step (JIT) + correctness peek + tok = first; pos += 1; + if prefill == 0 && fakectx == 0 { eprintln!("Nemotron decode: token1234 → next argmax {first} (F32 ref argmax=1234; Q8 may perturb the near-tie)"); } + // Fast internal A/B: one model load, toggle an env flag (e.g. MT_GEMV_2ROW) + // between alternating decode batches IN-PROCESS — same thermal/clock state, + // order-alternated to cancel drift. Resets pos each batch so the KV cap holds. + if let Ok(ab) = std::env::var("NEMOTRON_AB") { + // "on" sets the flag to NEMOTRON_AB_VAL (default "1"); "off" unsets it. + // For value-flags like MT_MOE_RPT/MT_GEMV_RPT use NEMOTRON_AB_VAL=2 so + // ON=rpt2 vs OFF=rpt1 (unset default), not the no-op "1" vs unset(=1). + let ab_val = std::env::var("NEMOTRON_AB_VAL").unwrap_or_else(|_| "1".to_string()); + let rounds = 6usize; + let base_pos = pos; + let (mut t_off, mut t_on) = (0f64, 0f64); + for r in 0..rounds { + // alternate which config runs first each round to cancel position bias + let first_on = r % 2 == 1; + for &on in &[first_on, !first_on] { + unsafe { if on { std::env::set_var(&ab, &ab_val); } else { std::env::remove_var(&ab); } } + pos = base_pos; + let s = Instant::now(); + for _ in 0..n_decode { let nxt = step(tok, pos, &mut conv_state, &mut ssm_state, &mut kvcache); tok = nxt; pos += 1; } + let e = s.elapsed().as_secs_f64(); + if on { t_on += e; } else { t_off += e; } + } + } + let off_tps = (rounds * n_decode) as f64 / t_off; + let on_tps = (rounds * n_decode) as f64 / t_on; + eprintln!("──── AB[{ab}] internal ({rounds} rounds × {n_decode} tok, ctx {}) ────", fakectx.max(prefill)); + eprintln!(" OFF {off_tps:.2} tok/s | ON {on_tps:.2} tok/s | delta {:+.1}%", (on_tps / off_tps - 1.0) * 100.0); + let _ = tok; + return; + } + let t0 = Instant::now(); + for _ in 0..n_decode { let nxt = step(tok, pos, &mut conv_state, &mut ssm_state, &mut kvcache); tok = nxt; pos += 1; } + let dt = t0.elapsed().as_secs_f64(); + let eager_tps = n_decode as f64 / dt; + let recipe_desc = if use_nvfp4_recipe { + "NVFP4-recipe [Mamba in/out_proj, shared-expert up/down, attn o_proj → Q8; routed experts, q/k/v, lm_head → Q4]" + } else { + "all-Q4 [uniform 4-bit]" + }; + eprintln!("──────── NemotronH-Nano DECODE on {plat} ────────"); + eprintln!(" quant {recipe_desc}"); + eprintln!(" resident {:.2} GB ({:.2} GB Q4 + {:.2} GB Q8)", q4_gb + q8_gb, q4_gb, q8_gb); + eprintln!(" context start {} + {n_decode} timed (pos→{pos})", fakectx.max(prefill)); + eprintln!(" decode {n_decode} tok in {dt:.2}s = {eager_tps:.2} tok/s ({:.1} ms/tok)", dt * 1000.0 / n_decode as f64); + eprintln!(" (vs naive-reload baseline 0.003 tok/s; resident weights uploaded once in {load_s:.0}s setup)"); + eprintln!("──────────────────────────────────────────────────────────────"); + + if prof { + // Per-op breakdown table. We accumulated sync-bracketed wall times for each op + // across (n_decode+1) steps (the warm step + timed steps). Divide by step_count + // to get per-token averages. ms/tok = total_s * 1000 / n_steps. + let n_steps = step_count.get().max(1) as f64; + let tok_ms = dt * 1000.0 / n_decode as f64; // actual ms/tok (no sync overhead) + + // Op names aligned with indices used in pt!() above + let op_names = [ + "rms_norm", // 0 + "m_in_proj", // 1 gemv_q4 Mamba in_proj 10304×2688 + "slice", // 2 slice (multiple per Mamba layer) + "conv1d", // 3 + "silu", // 4 + "conv_roll", // 5 + "softplus_add", // 6 + "ssm_step", // 7 + "gated_grm_norm", // 8 + "m_out_proj", // 9 gemv_q4 Mamba out_proj 2688×4096 + "moe_gate_gemv", // 10 f32 gemv router 128×2688 + "moe_router_dev", // 11 device top-k kernel + "moe_gather_up", // 12 moe_gather_up_relu2 + "moe_gather_down",// 13 + "moe_wsum", // 14 + "shared_up_q4", // 15 qrelu2 3712×2688 + "shared_down_acc",// 16 qacc 2688×3712 + "rope", // 17 + "q_proj", // 18 gemv_q4 4096×2688 + "k_proj", // 19 gemv_q4 256×2688 + "v_proj", // 20 gemv_q4 256×2688 + "kv_append", // 21 + "sdpa_2pass", // 22 + "o_proj", // 23 gemv_q4 2688×4096 + "add_residual", // 24 (unused, subsumed into 27/28/29) + "norm_f", // 25 + "lm_head", // 26 + "add_M_resid", // 27 + "add_E_resid", // 28 + "add_A_resid", // 29 + "cast_f16", // 30 + ]; + + // Sum all GPU op times (this is the "GPU-attributed" total; the rest is host) + let gpu_total_s: f64 = op_t.iter().map(|c| c.get()).sum(); + let host_overhead_s = (step_wall.get() / n_steps - gpu_total_s / n_steps).max(0.0); + let roofline_gbs = 189.0f64; // GB10 peak BW GB/s (measured ~189 with bandwidthTest) + + eprintln!("──────── PER-OP PROFILE (ctx {} avg over {:.0} steps, NEMOTRON_PROFILE=1) ────────", + fakectx.max(prefill), n_steps); + eprintln!(" Note: times include sync overhead. Use ratios; GPU total ≠ wall time."); + eprintln!(" {:<20} {:>8} {:>7} {:>8} {:>12} {:>8}", + "op", "ms/tok", "%GPU", "calls/tok", "GB_read/tok", "eff GB/s"); + eprintln!(" {}", "-".repeat(75)); + + let mut sorted_idx: Vec = (0..N_OPS).collect(); + sorted_idx.sort_by(|&a, &b| op_t[b].get().partial_cmp(&op_t[a].get()).unwrap()); + + for &i in &sorted_idx { + let t_s = op_t[i].get(); + if t_s < 1e-9 { continue; } // skip unexercised ops + let ms_per_tok = t_s * 1000.0 / n_steps; + let pct = 100.0 * t_s / gpu_total_s; + let calls_per_tok = op_calls[i].get() as f64 / n_steps; + let gb_per_tok = op_bytes[i].get() / n_steps / 1e9; + // eff GB/s = bytes_read_per_tok / (ms_per_tok / 1000) + let eff_gbs = if ms_per_tok > 1e-4 { gb_per_tok / (ms_per_tok / 1000.0) } else { 0.0 }; + eprintln!(" {:<20} {:>7.2}ms {:>6.1}% {:>8.1} {:>11.3}GB {:>7.1} GB/s (roofline {:.0}%)", + op_names[i], ms_per_tok, pct, calls_per_tok, gb_per_tok, eff_gbs, + if eff_gbs > 0.0 { 100.0 * eff_gbs / roofline_gbs } else { 0.0 }); + } + eprintln!(" {}", "-".repeat(75)); + eprintln!(" {:<20} {:>7.2}ms {:>6.1}% (GPU-attributed total, incl sync overhead)", + "GPU subtotal", gpu_total_s * 1000.0 / n_steps, 100.0); + eprintln!(" {:<20} {:>7.2}ms (host overhead = wall - gpu subtotal)", + "host overhead", host_overhead_s * 1000.0); + eprintln!(" {:<20} {:>7.2}ms (actual wall time per token)", + "wall time/tok", tok_ms); + eprintln!("────────────────────────────────────────────────────────────────────────────"); + } + + // ── CUDA GRAPH CAPTURE/REPLAY measurement ────────────────────────────────── + // Activated by NEMOTRON_GRAPH=1. Requires NEMOTRON_DEVROUTER=1 (device router, + // no host sync in the step). Collapses ~390 per-token launches into one + // cuGraphLaunch to remove host-launch-gap overhead; measures vs eager at the + // SAME thermal/clock state in one process. + // + // Protocol: + // a. Run 5 more warm eager steps (pool stays fully populated, clocks steady). + // b. Time M eager steps (skip_dl=false so argmax fires for comparison baseline). + // c. One more eager step (pool free-list in exact capture-step state). + // d. begin_capture → step (skip_dl=true, no devrouter host-sync) → end_capture. + // e. synchronize; time M graph_launches. + // f. Print eager_tps_graph_run, graph_tps, ratio. + if use_graph { + if no_devrouter { + eprintln!("NEMOTRON_GRAPH: WARNING — NEMOTRON_DEVROUTER not set. Host router mid-token sync will break capture. Re-run with NEMOTRON_DEVROUTER=1."); + } + let gm = n_decode; // use same step count for apples-to-apples + // Use `fakectx` as the fixed position for all graph-section measurements. + // This keeps `pos` within bounds (KV cap = fakectx + n_decode + 8) AND gives + // the correct 32K SDPA length. We don't advance pos in the graph section. + let gpos = fakectx.max(prefill); // fixed position for graph-section steps + // (a) 5 warm steps at gpos (untimed) — populates pool, reaches steady clocks. + for _ in 0..5 { step(tok, gpos, &mut conv_state, &mut ssm_state, &mut kvcache); } + // (b) Eager baseline at fixed gpos (skip_dl=false — argmax fires for comparison). + let t_eager = Instant::now(); + for _ in 0..gm { step(tok, gpos, &mut conv_state, &mut ssm_state, &mut kvcache); } + d.synchronize().ok(); + let eager_dt = t_eager.elapsed().as_secs_f64(); + let eager_tps_hot = gm as f64 / eager_dt; + // (c) One pool-alignment step (pool free-list in exact same state the capture step sees). + step(tok, gpos, &mut conv_state, &mut ssm_state, &mut kvcache); + // (d) Capture: skip_dl = true so no host-sync in step. + skip_dl.set(true); + d.begin_capture().expect("NEMOTRON_GRAPH: begin_capture failed — check DEVROUTER and that no cuMemAlloc slipped into the step"); + step(tok, gpos, &mut conv_state, &mut ssm_state, &mut kvcache); + let graph_handle = d.end_capture().expect("NEMOTRON_GRAPH: end_capture failed — check for stray sync/alloc in the captured region"); + // (e) Synchronize then time M graph launches — two modes: + // serial: graph_launch (sync-per-token) measures single-token latency + // batched: graph_launch_batch (N enqueues, one sync) eliminates per-token + // host-GPU handoff, giving maximum pipeline throughput + d.synchronize().ok(); + // Serial: measures true per-token wall time (GPU + host round-trip). + let t_graph_serial = Instant::now(); + for _ in 0..gm { d.graph_launch(graph_handle).expect("graph_launch failed"); } + let graph_serial_dt = t_graph_serial.elapsed().as_secs_f64(); + let graph_serial_tps = gm as f64 / graph_serial_dt; + // Batched: N cuGraphLaunch enqueues, one cuStreamSynchronize — removes + // per-token host-GPU handoff to reveal the true GPU throughput ceiling. + d.synchronize().ok(); + let t_graph_batch = Instant::now(); + d.graph_launch_batch(graph_handle, gm).expect("graph_launch_batch failed"); + let graph_batch_dt = t_graph_batch.elapsed().as_secs_f64(); + let graph_batch_tps = gm as f64 / graph_batch_dt; + let ratio_serial = graph_serial_tps / eager_tps_hot; + let ratio_batch = graph_batch_tps / eager_tps_hot; + eprintln!("──────── CUDA GRAPH CAPTURE/REPLAY (ctx {}, {} tok each) ────────", fakectx.max(prefill), gm); + eprintln!(" eager (hot) {eager_tps_hot:.2} tok/s ({:.2} ms/tok)", eager_dt * 1000.0 / gm as f64); + eprintln!(" graph serial {graph_serial_tps:.2} tok/s ({:.2} ms/tok) ratio {ratio_serial:.3}x", graph_serial_dt * 1000.0 / gm as f64); + eprintln!(" graph batched {graph_batch_tps:.2} tok/s ({:.2} ms/tok) ratio {ratio_batch:.3}x", graph_batch_dt * 1000.0 / gm as f64); + if graph_batch_tps >= 75.0 { + eprintln!(" ✓ TARGET MET: graph_batch_tps {graph_batch_tps:.1} >= 75 tok/s"); + } else { + eprintln!(" ✗ target 75 tok/s not yet met (gap {:.1} tok/s)", 75.0 - graph_batch_tps); + } + eprintln!("──────────────────────────────────────────────────────────────────────"); + } +} diff --git a/rust/crates/ffai-ops/Cargo.toml b/rust/crates/ffai-ops/Cargo.toml new file mode 100644 index 00000000..af725806 --- /dev/null +++ b/rust/crates/ffai-ops/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ffai-ops" +description = "FFAI semantic ops — the seam between model code and metaltile kernels. Builds/looks up a Kernel and dispatches it via the Device trait." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +rustc-hash.workspace = true +parking_lot.workspace = true +ffai-core.workspace = true +metaltile-core.workspace = true +# the kernel registry (all_kernels) + the crate that registers the model +# kernels (must be linked so `inventory` collects mt_rms_norm/mt_gemv/…). +metaltile-codegen.workspace = true +metaltile-std.workspace = true diff --git a/rust/crates/ffai-ops/src/lib.rs b/rust/crates/ffai-ops/src/lib.rs new file mode 100644 index 00000000..9aad9d3d --- /dev/null +++ b/rust/crates/ffai-ops/src/lib.rs @@ -0,0 +1,8104 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-ops +//! +//! The **seam** between model code and kernels — the Rust analog of +//! FFAI-Swift's `Ops/`. Each function takes Tensors, builds (or looks up) +//! the corresponding metaltile [`Kernel`](ffai_core::Kernel), and dispatches +//! it through the [`Device`](ffai_core::Device) trait. Model code calls +//! these; it never touches a GPU API or a kernel directly. Re-implementing +//! this layer once is what lets the whole model surface above it run on +//! every backend. +//! +//! Every op here is real and runs on any backend implementing [`Device`] +//! (verified vs HF on CUDA + Metal). Elementwise ops hand-build their IR; +//! the heavier ones (matmul / rms_norm / layer_norm / sdpa / rope / conv1d / +//! ssm …) resolve a registered metaltile kernel via `lookup`, which carries +//! the correct dispatch mode. + +use ffai_core::{Binding, DType, Device, Error, Grid, Kernel, Result, Tensor}; +use metaltile_core::ir::{ActKind, BinOpKind, IndexExpr, Op, Param, ParamKind, ValueId}; +use metaltile_core::shape::Shape; +use parking_lot::Mutex; +use rustc_hash::FxHashMap; +use std::sync::OnceLock; + +/// Device-resident scalar-constant cache: tiny buffers (norm `eps` values) +/// uploaded once per (device, bit-pattern) instead of per call. The norms run +/// hundreds of times per prefill forward, and each 4-byte upload pays a full +/// pageable H2D + driver round-trip. +fn scalar_buf(dev: &dyn Device, bits: u32) -> Result> { + use std::cell::RefCell; + thread_local! { + static CACHE: RefCell>> = + RefCell::new(FxHashMap::default()); + } + let key = (dev as *const dyn Device as *const () as usize, bits); + if let Some(b) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + return Ok(b); + } + let b = dev.upload(&bits.to_le_bytes())?; + CACHE.with(|c| c.borrow_mut().insert(key, b.clone())); + Ok(b) +} + +mod ssd_scan; +pub use ssd_scan::ssm_prefill_scan_ssd; + +mod ssd_scan_portable; +pub use ssd_scan_portable::ssm_prefill_scan_ssd_portable; + +/// Cache of resolved kernel IR keyed by (name, dtype). Resolving walks the +/// whole test registry building setups, which is expensive — a forward pass +/// dispatches the same handful of kernels hundreds of times, so cache them. +/// FxHashMap (single-cycle hash) + parking_lot Mutex (unpoisoned, inlinable). +fn kernel_cache() -> &'static Mutex> { + static C: OnceLock>> = OnceLock::new(); + C.get_or_init(|| Mutex::new(FxHashMap::default())) +} + +/// Cache an inline-built kernel IR by (name, dtype). `kernel_ir_for` rebuilds the +/// full IR (~30+ ops) on every call — on the decode hot path that's ~250 +/// rebuilds/token. Build once, clone thereafter. +fn cached_ir(name: &str, dtype: DType, build: impl FnOnce() -> Kernel) -> Kernel { + let key = (name.to_string(), dtype); + if let Some(k) = kernel_cache().lock().get(&key) { + return k.clone(); + } + let k = build(); + kernel_cache().lock().insert(key, k.clone()); + k +} + +/// Look up a registered metaltile kernel by name and instantiate its IR for +/// `dtype`, **with the correct dispatch mode already set**. We pull it from +/// the test registry (`metaltile_std::all_tests`) rather than the raw kernel +/// registry: the test/bench setup is where each kernel's `KernelMode` +/// (Elementwise / Reduction / Grid3D …) is configured, and that mode drives +/// codegen (e.g. whether `tid` vs `gid` is defined, the `_n_elems` arg). This +/// is the same path the CUDA corpus dispatches, so we inherit its correctness. +fn lookup(name: &str, dtype: DType) -> Result { + let key = (name.to_string(), dtype); + if let Some(k) = kernel_cache().lock().get(&key) { + return Ok(k.clone()); + } + for entry in metaltile_std::all_tests() { + let t = entry.test(); + if !t.dtypes().contains(&dtype) { + continue; + } + let setup = t.setup(dtype); + let k = setup.kernel(); + if k.name == name { + kernel_cache().lock().insert(key, k.clone()); + return Ok(k.clone()); + } + } + Err(Error::Msg(format!( + "kernel '{name}' [{dtype}] not found in the metaltile test registry" + ))) +} + +/// Build an Elementwise `out[i] = a[i] b[i]` kernel for `dtype`. +fn binop_kernel(name: &str, dtype: DType, op: BinOpKind) -> Kernel { + let mut k = Kernel::new(name); + for (pname, is_out) in [("a", false), ("b", false), ("c", true)] { + k.params.push(Param { + name: pname.into(), + dtype, + shape: Shape::scalar(), + is_output: is_out, + kind: ParamKind::Tensor, + }); + } + k.body.push_op(Op::ProgramId { axis: 0 }, ValueId::new(0)); + k.body.push_op( + Op::Load { + src: "a".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + mask: None, + other: None, + }, + ValueId::new(1), + ); + k.body.push_op( + Op::Load { + src: "b".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + mask: None, + other: None, + }, + ValueId::new(2), + ); + k.body.push_op( + Op::BinOp { op, lhs: ValueId::new(1), rhs: ValueId::new(2) }, + ValueId::new(3), + ); + k.body.push_op_no_result(Op::Store { + dst: "c".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + value: ValueId::new(3), + mask: None, + }); + k +} + +/// Shared implementation for elementwise binary ops over matching-shape +/// tensors. Allocates a fresh output on `dev` and dispatches through the +/// backend-neutral [`Device`] trait. +fn elementwise( + dev: &dyn Device, + a: &Tensor, + b: &Tensor, + op: BinOpKind, + name: &str, +) -> Result { + if a.shape != b.shape { + return Err(Error::Msg(format!( + "{name}: shape mismatch {:?} vs {:?}", + a.shape, b.shape + ))); + } + if a.dtype != b.dtype { + return Err(Error::Msg(format!("{name}: dtype mismatch"))); + } + let out = Tensor::empty(dev, a.shape.clone(), a.dtype)?; + let k = binop_kernel(name, a.dtype, op); + let n = a.elem_count() as u32; + let grid = Grid::d1(n.div_ceil(256), 256); + dev.dispatch( + &k, + &[ + Binding::Buffer(a.buffer.clone()), + Binding::Buffer(b.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + ], + grid, + )?; + Ok(out) +} + +/// Elementwise sum `a + b` (e.g. residual connections). +pub fn add(dev: &dyn Device, a: &Tensor, b: &Tensor) -> Result { + elementwise(dev, a, b, BinOpKind::Add, "ffai_add") +} + +/// Elementwise product `a * b` (e.g. gating). +pub fn mul(dev: &dyn Device, a: &Tensor, b: &Tensor) -> Result { + elementwise(dev, a, b, BinOpKind::Mul, "ffai_mul") +} + +const ADD3_SRC: &str = r#" +extern "C" __global__ void ffai_add3( + const float* __restrict__ a, const float* __restrict__ b, + const float* __restrict__ c, float* __restrict__ out, long n) +{ + long i=(long)blockIdx.x*blockDim.x+threadIdx.x; + long stride=(long)gridDim.x*blockDim.x; + // association matches the unfused path exactly: xt + (acc + sd) — a + // left-assoc a+b+c flipped the d32768 argmax via last-ulp drift. + for(;i Result { + let out = Tensor::empty(dev, a.shape.clone(), a.dtype)?; + let n = a.elem_count(); + let l = |v: i64| v.to_le_bytes().to_vec(); + dev.dispatch_raw_cuda(ADD3_SRC, "add3.cu", "ffai_add3", + &[(a.buffer.as_ref(), a.offset), (b.buffer.as_ref(), b.offset), + (c.buffer.as_ref(), c.offset), (out.buffer.as_ref(), 0)], + &[l(n as i64)], + [(n as u32).div_ceil(256 * 4).max(1), 1, 1], [256, 1, 1], 0, false)?; + Ok(out) +} + +// ── DeepSeek-V4 MoE ops ───────────────────────────────────────────────── + +/// DSv4 clamped SwiGLU: `out = silu(min(gate, limit)) * clip(up, ±limit)`. +/// Dispatches `ffai_dsv4_swiglu_limit` (limit = 10 for DSv4). +pub fn swiglu_limit(dev: &dyn Device, gate: &Tensor, up: &Tensor, limit: f32) -> Result { + if gate.shape != up.shape { + return Err(Error::Msg("swiglu_limit: gate/up shape mismatch".into())); + } + let k = lookup("ffai_dsv4_swiglu_limit", gate.dtype)?; + let out = Tensor::empty(dev, gate.shape.clone(), gate.dtype)?; + let n = gate.elem_count() as u32; + let grid = Grid::d1(n.div_ceil(256), 256); + dev.dispatch( + &k, + &[ + Binding::Buffer(gate.buffer.clone()), + Binding::Buffer(up.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Scalar(limit.to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out) +} + +/// DSv4 MoE router scoring: `score_unbiased[e] = sqrt(softplus(logit[e]))`, +/// `score_biased[e] = score_unbiased[e] + bias[e]`. Computed **host-side** — +/// the router operates on the tiny `[n_experts]` logit vector that the MoE +/// already downloads for top-k selection, so a GPU kernel buys nothing and +/// avoids the multi-output dispatch path. `biased` selects the top-k experts; +/// `unbiased` weights the combine. +pub fn sqrtsoftplus_route(logits: &[f32], bias: &[f32]) -> (Vec, Vec) { + let unbiased: Vec = logits + .iter() + .map(|&x| (x.max(0.0) + (1.0 + (-x.abs()).exp()).ln()).sqrt()) + .collect(); + let biased: Vec = unbiased.iter().zip(bias).map(|(u, b)| u + b).collect(); + (unbiased, biased) +} + +// ── Causal conv1d step (Mamba2 short-conv / audio front-ends) ─────────── + +/// Causal depthwise conv1d decode step (one thread per channel): +/// `y[d] = b[d] + w[K-1,d]·x[d] + Σ_{k Result { + let k = lookup("conv1d_causal_step", x.dtype)?; + let y = Tensor::empty(dev, vec![n_channels as usize], x.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let bindings = vec![ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(w.buffer.clone()), + Binding::Buffer(b.buffer.clone()), + Binding::Buffer(state.buffer.clone()), // in-place ring state + Binding::Buffer(y.buffer.clone()), + u(n_channels), + u(kernel_size), + ]; + // TIER-1 launch-geometry fix: move the n_channels extent from grid.x into + // block.x (product unchanged → byte-identical program_id under Grid3D + // codegen, where gid_x = blockIdx.x*blockDim.x + threadIdx.x). b is the + // largest power-friendly block that divides n_channels (gcd capped at 256). + let b = gcd_u32(n_channels, 256).max(1); + let grid = Grid { grid: [n_channels / b, 1, 1], block: [b, 1, 1] }; + dev.dispatch(&k, &bindings, grid)?; + Ok(y) +} + +/// gcd for launch-geometry block sizing (Tier-1 grid→block extent moves). +#[inline] +fn gcd_u32(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a +} + +// ── SSM (Mamba2 SSD selective scan) ───────────────────────────────────── + +/// Mamba2 SSD selective-scan **decode step** (single token, batch=1): +/// `da = exp(-exp(a_log)·dt)`; `state' = da·state + x·dt·B`; +/// `out = Σ_s C·state' + x·D`. Dispatches `mt_ssm_step`. Returns +/// `(state_out [n_heads·dh·ds], out [n_heads·dh])`. `ds` must be a multiple +/// of 32. Shapes: x `[n_heads·dh]`, a_log/d_skip/dt `[n_heads]`, +/// b_mat/c_mat `[n_groups·ds]`, state_in `[n_heads·dh·ds]`. +#[allow(clippy::too_many_arguments)] +pub fn ssm_step( + dev: &dyn Device, + x: &Tensor, + a_log: &Tensor, + b_mat: &Tensor, + c_mat: &Tensor, + d_skip: &Tensor, + dt: &Tensor, + state_in: &Tensor, + dh: u32, + ds: u32, + n_heads: u32, + heads_per_group: u32, +) -> Result<(Tensor, Tensor)> { + let k = lookup("mt_ssm_step", x.dtype)?; + let state_out = Tensor::empty(dev, state_in.shape.clone(), x.dtype)?; + let out = Tensor::empty(dev, vec![(n_heads * dh) as usize], x.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let bindings = vec![ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(a_log.buffer.clone()), + Binding::Buffer(b_mat.buffer.clone()), + Binding::Buffer(c_mat.buffer.clone()), + Binding::Buffer(d_skip.buffer.clone()), + Binding::Buffer(dt.buffer.clone()), + Binding::Buffer(state_in.buffer.clone()), + Binding::Buffer(state_out.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(dh), + u(ds), + u(n_heads), + u(heads_per_group), + ]; + let grid = Grid { grid: [dh, n_heads, 1], block: [32, 1, 1] }; + dev.dispatch(&k, &bindings, grid)?; + Ok((state_out, out)) +} + +// ── DeepSeek-V4 mHC (hyper-connection 4-channel residual) ─────────────── + +/// mHC sinkhorn split (single token, host-side): the 24-value `mixes` +/// vector → `pre[4]`, `post[4]`, `comb[4×4]`. Faithful transcription of +/// `ffai_dsv4_mhc_sinkhorn_split` (a 3-output kernel; trivial at one token, +/// so it runs on the host). `scale` is `[pre, post, comb]`, `base` is `[24]`. +/// - pre[c] = sigmoid(mixes[c]·pre_scale + base[c]) + eps +/// - post[c] = 2·sigmoid(mixes[4+c]·post_scale + base[4+c]) +/// - comb = per-row softmax of (mixes·comb_scale + base), then +/// `iters` Sinkhorn steps (column-normalize, then row-normalize). +pub fn dsv4_mhc_sinkhorn_split( + mixes: &[f32], + scale: [f32; 3], + base: &[f32], + eps: f32, + iters: u32, +) -> (Vec, Vec, Vec) { + let sig = |x: f32| 1.0 / (1.0 + (-x).exp()); + let pre: Vec = (0..4).map(|c| sig(mixes[c] * scale[0] + base[c]) + eps).collect(); + let post: Vec = (0..4).map(|c| 2.0 * sig(mixes[4 + c] * scale[1] + base[4 + c])).collect(); + + let mut c = [[0.0f32; 4]; 4]; + for i in 0..4 { + let r: [f32; 4] = std::array::from_fn(|j| mixes[8 + i * 4 + j] * scale[2] + base[8 + i * 4 + j]); + let m = r.iter().cloned().fold(f32::MIN, f32::max); + let e: [f32; 4] = std::array::from_fn(|j| (r[j] - m).exp()); + let s: f32 = e.iter().sum(); + for j in 0..4 { + c[i][j] = e[j] / s + eps; + } + } + for _ in 0..iters { + for j in 0..4 { + let cs: f32 = (0..4).map(|i| c[i][j]).sum::() + eps; + for row in c.iter_mut() { + row[j] /= cs; + } + } + for row in c.iter_mut() { + let rs: f32 = row.iter().sum::() + eps; + for v in row.iter_mut() { + *v /= rs; + } + } + } + let comb: Vec = (0..4).flat_map(|i| (0..4).map(move |j| c[i][j])).collect(); + (pre, post, comb) +} + + +/// mHC collapse: `out[d] = Σ_c pre[c] · state[c, d]` — mix the 4-channel +/// residual state `[n_hc, hidden]` down to `[hidden]` using the per-channel +/// `pre` weights. Single token. Dispatches `ffai_dsv4_mhc_collapse`. +pub fn dsv4_mhc_collapse( + dev: &dyn Device, + state: &Tensor, + pre: &Tensor, + hidden_dim: u32, + n_hc: u32, +) -> Result { + let k = lookup("ffai_dsv4_mhc_collapse", state.dtype)?; + let out = Tensor::empty(dev, vec![hidden_dim as usize], state.dtype)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let grid = Grid::d1((hidden_dim).div_ceil(256), 256); + dev.dispatch( + &k, + &[ + Binding::Buffer(state.buffer.clone()), + Binding::Buffer(pre.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(hidden_dim), + u(n_hc), + u(1), + ], + grid, + )?; + Ok(out) +} + +/// mHC expand: write the new 4-channel residual state +/// `state[dst, d] = block_out[d]·post[dst] + Σ_src comb[dst, src]·residual[src, d]`. +/// Single token. Returns `[n_hc, hidden]`. Dispatches `ffai_dsv4_mhc_expand`. +#[allow(clippy::too_many_arguments)] +pub fn dsv4_mhc_expand( + dev: &dyn Device, + block_out: &Tensor, + post: &Tensor, + comb: &Tensor, + residual_state: &Tensor, + hidden_dim: u32, + n_hc: u32, +) -> Result { + let k = lookup("ffai_dsv4_mhc_expand", block_out.dtype)?; + let state = Tensor::empty(dev, vec![(n_hc * hidden_dim) as usize], block_out.dtype)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let grid = Grid::d1((hidden_dim).div_ceil(256), 256); + dev.dispatch( + &k, + &[ + Binding::Buffer(block_out.buffer.clone()), + Binding::Buffer(post.buffer.clone()), + Binding::Buffer(comb.buffer.clone()), + Binding::Buffer(residual_state.buffer.clone()), + Binding::Buffer(state.buffer.clone()), + u(hidden_dim), + u(n_hc), + u(1), + ], + grid, + )?; + Ok(state) +} + +// ── Heavier ops — dispatch the registered metaltile kernels ───────────── + +/// Row-wise RMS norm: `out[r] = x[r] * rsqrt(mean(x[r]²) + eps) * weight`. +/// Dispatches the registered `mt_rms_norm` reduction kernel — the same one +/// the Swift side runs. The last dim is the row width `n`; the kernel owns 4 +/// elements per thread, so `n` must be a multiple of 128 and ≤ 4096 (the +/// `mt_rms_norm_wide` variant lifts this — wired later). +pub fn rms_norm(dev: &dyn Device, x: &Tensor, weight: &Tensor, eps: f32) -> Result { + let n = *x.shape.last().ok_or_else(|| Error::Msg("rms_norm: scalar input".into()))?; + let rows = x.elem_count() / n; + let out = Tensor::empty(dev, x.shape.clone(), x.dtype)?; + let eps_buf = scalar_buf(dev, eps.to_bits())?; + + // Fast path: 4 elems/thread, needs n a multiple of 128 and ≤ 4096. + // Otherwise the strided wide variant handles any row width. + let (kname, block) = if n % 128 == 0 && n <= 4096 { + ("mt_rms_norm", (n / 4) as u32) + } else { + ("mt_rms_norm_wide", 256u32) + }; + let k = lookup(kname, x.dtype)?; + let grid = Grid { grid: [rows as u32, 1, 1], block: [block, 1, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(weight.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Buffer(eps_buf), + Binding::Scalar((n as u32).to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out) +} + +/// Matrix-vector product `mat @ vec`: `mat` is `[m, k]` row-major, `vec` is +/// `[k]`, result is `[m]`. Dispatches the registered `mt_gemv` kernel (one +/// threadgroup per output row). This is the decode-time projection path; the +/// batched/prefill cooperative matmul is a separate kernel, wired later. +pub fn gemv(dev: &dyn Device, mat: &Tensor, vec: &Tensor) -> Result { + if mat.shape.len() != 2 { + return Err(Error::Msg(format!("gemv: mat must be 2-D, got {:?}", mat.shape))); + } + let (m, kdim) = (mat.shape[0], mat.shape[1]); + if vec.elem_count() != kdim { + return Err(Error::Msg(format!( + "gemv: vec len {} != mat K {kdim}", + vec.elem_count() + ))); + } + let k = lookup("mt_gemv", mat.dtype)?; + let out = Tensor::empty(dev, vec![m], mat.dtype)?; + + let grid = Grid { grid: [m as u32, 1, 1], block: [256, 1, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(mat.buffer.clone()), + Binding::Buffer(vec.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Scalar((kdim as u32).to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out) +} + +/// Q8_0 grouped matvec — weights stay QUANTIZED resident (int8, block 32, one +/// f32 scale/block): `out[r] = Σ_k dequant(qs[r,k], scales[r, k/32]) · +/// x[(r/rows_per_group)·k_in + k]`. `qs` is u32-packed (4 int8/u32, 8 u32/block), +/// `scales` is `[m_out · k_in/32]` f32. Dense gemv ⇒ `rows_per_group = m_out` +/// (n_groups=1, `x = [k_in]`); MoE ⇒ per-expert grouping. Dispatches the +/// Reduction kernel `ffai_grouped_gemv_q8_rows` (8-bit ⇒ ~4× less weight DRAM +/// than F32, the resident-decode bandwidth win). `k_in` must be a multiple of 32. +pub fn gemv_q8( + dev: &dyn Device, + qs: &Tensor, + scales: &Tensor, + x: &Tensor, + m_out: usize, + k_in: usize, + rows_per_group: usize, +) -> Result { + if k_in % 32 != 0 { + return Err(Error::Msg(format!("gemv_q8: k_in {k_in} must be a multiple of 32"))); + } + // Not in the test registry, so build the kernel IR directly (constexpr dims + // are JIT-specialized by the runtime from the scalar bindings below). + // Coalesced variant: consecutive lanes read consecutive qs words (~2× the + // strided original's DRAM bandwidth on GB10). + let k = cached_ir("ffai_gemv_q8_coalesced", x.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_gemv_q8_coalesced::kernel_ir_for(x.dtype); k.mode = metaltile_core::ir::KernelMode::Reduction; k }); + let out = Tensor::empty(dev, vec![m_out], x.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let grid = Grid { grid: [m_out as u32, 1, 1], block: [32, 1, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(qs.buffer.clone()), + Binding::Buffer(scales.buffer.clone()), + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(k_in as u32), + u(m_out as u32), + u(rows_per_group as u32), + ], + grid, + )?; + Ok(out) +} + +/// Batched Q8 GEMM — the **prefill** projection path. `out[r,o] = Σ_k W[o,k]·x[r,k]` +/// for `n_rows` tokens at once, dispatching metaltile's `ffai_gemm_q8_mpp` +/// (SimdGroup-scope CoopTile 64×64×32 tile). On the Vulkan/RDNA4 backend this +/// kernel picks up the gated `VK_KHR_cooperative_matrix` fragment ops when +/// `MT_VK_COOPMAT=1` (≈6× the scalar GEMM) — the realized prefill win. With the +/// gate unset it runs the bit-exact scalar `SoftwareLocalC` tiles. Either way it +/// replaces the `n_rows`× sequential `gemv_q8` matvecs of the per-token path. +/// +/// The coopmat staging tiles are fp16, so the activation/output are carried as +/// f16 across the kernel: `x` (f32 `[n_rows, k_in]`) is cast to f16 on entry and +/// the f16 result is widened back to f32 `[n_rows, m_out]` so the rest of the +/// f32 forward graph is unchanged. Weight layout (`qs`/`scales`) is identical to +/// [`gemv_q8`] (dense, `rows_per_group = m_out`). `k_in` must be a multiple of 32. +pub fn gemm_q8_mpp( + dev: &dyn Device, + qs: &Tensor, + scales: &Tensor, + x: &Tensor, + n_rows: usize, + m_out: usize, + k_in: usize, +) -> Result { + if k_in % 32 != 0 { + return Err(Error::Msg(format!("gemm_q8_mpp: k_in {k_in} must be a multiple of 32"))); + } + // Coopmat fragments stage through fp16 → drive the kernel at f16. Cast the + // f32 residual-stream activations in, widen the result back out. + let xf16 = if x.dtype == DType::F16 { x.clone() } else { cast_f32_f16(dev, x)? }; + // `ffai_gemm_q8_mpp` in Reduction mode (matches the metaltile coopmat test + // + bench). Buffer order: x, qs, d_f32, out; constexpr n_rows, out_dim, k_in. + let k = cached_ir("ffai_gemm_q8_mpp", DType::F16, || { + let mut k = metaltile_std::ffai::gemm_q8_mpp::ffai_gemm_q8_mpp::kernel_ir_for(DType::F16); + k.mode = metaltile_core::ir::KernelMode::Reduction; + k + }); + let out = Tensor::empty(dev, vec![n_rows * m_out], DType::F16)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + // grid (threadgroups) = [ceil(out_dim/64), ceil(n_rows/64), 1], tg [128,1,1]. + let grid = Grid { + grid: [(m_out as u32).div_ceil(64), (n_rows as u32).div_ceil(64), 1], + block: [128, 1, 1], + }; + dev.dispatch( + &k, + &[ + Binding::Buffer(xf16.buffer.clone()), + Binding::Buffer(qs.buffer.clone()), + Binding::Buffer(scales.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(n_rows as u32), + u(m_out as u32), + u(k_in as u32), + ], + grid, + )?; + let out_f32 = cast_f16_f32(dev, &out)?; + Ok(out_f32.reshaped(vec![n_rows, m_out])) +} + +/// Broadcast-add a per-feature bias `[n]` across every row of `x [n_rows, n]`: +/// `out[r,o] = x[r,o] + bias[o]`. The decode path adds a `[n]` bias to a single +/// `[n]` projection with plain [`add`]; the batched prefill path needs the same +/// bias on every token row, but [`add`] is shape-strict (no broadcast). This is +/// a fully on-device elementwise kernel (`out[i] = x[i] + bias[i % n]`) — no +/// host round-trip, so it works on device-local (non-mappable) weight buffers. +/// Returns `[n_rows, n]` f32. +pub fn add_bias_rows(dev: &dyn Device, x: &Tensor, bias: &Tensor, n_rows: usize, n: usize) -> Result { + // Fully on-device per-feature bias broadcast: `i = ProgramId(0)` (global + // flat id), row-local column `col = i % n`, `out[i] = x[i] + bias[col]`. + // `n` is a CONSTEXPR push-constant (bound as a `Binding::Scalar`) — NOT a + // baked `Op::Const` literal — so the one cached `ffai_add_bias_rows` + // pipeline serves every projection width (q: nq*hd, k/v: nkv*hd) instead + // of silently reusing the first call's `n`. The DSL `%` lowers to + // `BinOpKind::Mod` → GLSL `uint(i) % uint(n)` (integer modulo), validated + // bit-exact on Vulkan by `metaltile-std/tests/vulkan_add_bias_rows.rs`. + // No host round-trip — runs straight on the device-local weight buffer. + use metaltile_core::ir::ConstExprDecl; + use metaltile_core::ConstExpr; + let total = n_rows * n; + let out = Tensor::empty(dev, vec![total], DType::F32)?; + let mut k = Kernel::new("ffai_add_bias_rows"); + for (pname, is_out) in [("x", false), ("bias", false), ("out", true)] { + k.params.push(Param { + name: pname.into(), + dtype: DType::F32, + shape: Shape::scalar(), + is_output: is_out, + kind: ParamKind::Tensor, + }); + } + k.constexprs.push(ConstExprDecl { + name: ConstExpr::new("n"), + dtype: DType::U32, + value: None, + }); + // SSA: 0=i, 1=n, 2=col, 3=x[i], 4=bias[col], 5=sum. + k.body.push_op(Op::ProgramId { axis: 0 }, ValueId::new(0)); + k.body.push_op( + Op::Load { src: "n".into(), indices: vec![], mask: None, other: None }, + ValueId::new(1), + ); + k.body.push_op( + Op::BinOp { op: BinOpKind::Mod, lhs: ValueId::new(0), rhs: ValueId::new(1) }, + ValueId::new(2), + ); + k.body.push_op( + Op::Load { + src: "x".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + mask: None, + other: None, + }, + ValueId::new(3), + ); + k.body.push_op( + Op::Load { + src: "bias".into(), + indices: vec![IndexExpr::Value(ValueId::new(2))], + mask: None, + other: None, + }, + ValueId::new(4), + ); + k.body.push_op( + Op::BinOp { op: BinOpKind::Add, lhs: ValueId::new(3), rhs: ValueId::new(4) }, + ValueId::new(5), + ); + k.body.push_op_no_result(Op::Store { + dst: "out".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + value: ValueId::new(5), + mask: None, + }); + let grid = Grid::d1((total as u32).div_ceil(256), 256); + dev.dispatch( + &k, + &[ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(bias.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Scalar((n as u32).to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out.reshaped(vec![n_rows, n])) +} + +/// Q8 gemv with fused ReLU²: `out[r] = max(0, (Wq·x)[r])²` — a MoE expert's +/// `up` projection + activation in one dispatch. Dispatches `ffai_gemv_q8_coalesced_relu2`. +pub fn gemv_q8_relu2(dev: &dyn Device, qs: &Tensor, scales: &Tensor, x: &Tensor, m_out: usize, k_in: usize, rows_per_group: usize) -> Result { + let k = cached_ir("ffai_gemv_q8_coalesced_relu2", x.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_gemv_q8_coalesced_relu2::kernel_ir_for(x.dtype); k.mode = metaltile_core::ir::KernelMode::Reduction; k }); + let out = Tensor::empty(dev, vec![m_out], x.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let grid = Grid { grid: [m_out as u32, 1, 1], block: [32, 1, 1] }; + dev.dispatch(&k, &[Binding::Buffer(qs.buffer.clone()), Binding::Buffer(scales.buffer.clone()), Binding::Buffer(x.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(k_in as u32), u(m_out as u32), u(rows_per_group as u32)], grid)?; + Ok(out) +} + +/// Q8 gemv that scales + accumulates in place: `acc[r] += scale · (Wq · x)[r]`. +/// Fuses an MoE expert's `down` projection with its router-weighted sum into the +/// layer accumulator — no per-expert scalar-broadcast upload or separate add. +/// `scale_buf` is a 1-element device buffer. Dispatches `ffai_gemv_q8_coalesced_accum`. +#[allow(clippy::too_many_arguments)] +pub fn gemv_q8_accum( + dev: &dyn Device, + qs: &Tensor, + scales: &Tensor, + x: &Tensor, + acc: &Tensor, + scale_buf: &Tensor, + m_out: usize, + k_in: usize, + rows_per_group: usize, +) -> Result<()> { + if k_in % 32 != 0 { + return Err(Error::Msg(format!("gemv_q8_accum: k_in {k_in} must be a multiple of 32"))); + } + let k = cached_ir("ffai_gemv_q8_coalesced_accum", acc.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_gemv_q8_coalesced_accum::kernel_ir_for(acc.dtype); k.mode = metaltile_core::ir::KernelMode::Reduction; k }); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let grid = Grid { grid: [m_out as u32, 1, 1], block: [32, 1, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(qs.buffer.clone()), + Binding::Buffer(scales.buffer.clone()), + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(acc.buffer.clone()), + Binding::Buffer(scale_buf.buffer.clone()), + u(k_in as u32), + u(m_out as u32), + u(rows_per_group as u32), + ], + grid, + )?; + Ok(()) +} + +/// Append one decode step's K (or V) into an in-place device KV cache: +/// `dst[h, pos, :] = src[h, :]`, where `dst` is `[nkv, cap, hd]`, `src` is +/// `[nkv*hd]`, and `posbuf` is a 1-element u32 device buffer. Keeps the growing +/// context entirely on-device — no host reorg/reupload per step (the 32K-context +/// fix). Dispatches `ffai_kv_append` (runtime `pos` ⇒ compiled once, not per step). +pub fn kv_append(dev: &dyn Device, src: &Tensor, dst: &Tensor, posbuf: &Tensor, hd: usize, cap: usize, n: usize) -> Result<()> { + let k = cached_ir("ffai_kv_append", src.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_kv_append::kernel_ir_for(src.dtype); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let grid = Grid { grid: [(n as u32).div_ceil(64), 1, 1], block: [64, 1, 1] }; + dev.dispatch(&k, &[Binding::Buffer(src.buffer.clone()), Binding::Buffer(dst.buffer.clone()), Binding::Buffer(posbuf.buffer.clone()), u(hd as u32), u(cap as u32)], grid)?; + Ok(()) +} + +/// Device slice `out[i] = src[off + i]` for `len` elements (no host round-trip). +pub fn slice(dev: &dyn Device, src: &Tensor, off: usize, len: usize) -> Result { + let k = cached_ir("ffai_slice", src.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_slice::kernel_ir_for(src.dtype); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + // Defensive bounds check. The kernel reads `src[off + i]` for i in [0, len), + // addressing a sub-slab purely via `off` against the RAW bound buffer (the + // tensor.offset is ignored by design). A caller binding a buffer that does + // not hold `off + len` elements triggers an out-of-bounds READ — silent + // garbage on Metal, a deterministic MMU-fault on CUDA. Fail loudly, naming + // the shapes, so the mis-sized caller is obvious on every backend. + let need = off + len; // max element read is src[off + len - 1] + let have = src.buffer.len() / src.dtype.size_bytes(); + if need > have { + return Err(Error::Msg(format!( + "slice OOB: off={off} len={len} needs {need} {:?} elems but bound buffer holds {have}", + src.dtype + ))); + } + let out = Tensor::empty(dev, vec![len], src.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(src.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(off as u32), u(len as u32)], Grid { grid: [(len as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(out) +} + +/// Cast f32 → f16 (KV-cache compaction: store attention K/V at half precision +/// to halve the 32K-context sdpa read). Returns a fresh F16 tensor. +pub fn cast_f32_f16(dev: &dyn Device, src: &Tensor) -> Result { + let k = cached_ir("ffai_cast_f32_f16", DType::F16, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_cast_f32_f16::kernel_ir(); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let n = src.elem_count(); + let out = Tensor::empty(dev, src.shape.clone(), DType::F16)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(src.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(n as u32)], Grid { grid: [(n as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(out) +} + +/// Cast f16 → f32 (reverse: widen the sdpa f16 output back to f32 for the +/// downstream o_proj Q4 GEMV, which consumes f32 activations). Fresh F32 tensor. +pub fn cast_f16_f32(dev: &dyn Device, src: &Tensor) -> Result { + let k = cached_ir("ffai_cast_f16_f32", DType::F32, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_cast_f16_f32::kernel_ir(); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let n = src.elem_count(); + let out = Tensor::empty(dev, src.shape.clone(), DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(src.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(n as u32)], Grid { grid: [(n as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(out) +} + +/// Cast f32 → bf16. BF16 keeps f32's exponent range, so the residual stream +/// (NEMOTRON_BF16_STREAM) doesn't overflow on NemotronH massive-activation +/// channels the way f16 (max 65504) does. +pub fn cast_f32_bf16(dev: &dyn Device, src: &Tensor) -> Result { + let k = cached_ir("ffai_cast_f32_bf16", DType::BF16, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_cast_f32_bf16::kernel_ir(); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let n = src.elem_count(); + let out = Tensor::empty(dev, src.shape.clone(), DType::BF16)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(src.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(n as u32)], Grid { grid: [(n as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(out) +} + +/// Cast bf16 → f32 (widen the residual back for ops that need f32 input). +pub fn cast_bf16_f32(dev: &dyn Device, src: &Tensor) -> Result { + let k = cached_ir("ffai_cast_bf16_f32", DType::F32, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_cast_bf16_f32::kernel_ir(); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let n = src.elem_count(); + let out = Tensor::empty(dev, src.shape.clone(), DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(src.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(n as u32)], Grid { grid: [(n as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(out) +} + +/// Device Mamba dt: `softplus(dt_raw + dt_bias)` — no host round-trip. +pub fn softplus_add(dev: &dyn Device, a: &Tensor, b: &Tensor) -> Result { + let k = cached_ir("ffai_softplus_add", DType::F32, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_softplus_add::kernel_ir(); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let n = a.elem_count(); + let out = Tensor::empty(dev, a.shape.clone(), DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(a.buffer.clone()), Binding::Buffer(b.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(n as u32)], Grid { grid: [(n as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(out) +} + +/// NemotronH/Zamba2 gated grouped RMSNorm ON-DEVICE: out = (y·silu(z)) normalized +/// per `gs`-group, ×w. Removes the per-Mamba-layer dl→host-norm→up sync. `y` is f32. +pub fn gated_group_rmsnorm(dev: &dyn Device, y: &Tensor, z: &Tensor, w: &Tensor, eps: f32, di: usize, gs: usize) -> Result { + let k = cached_ir("ffai_gated_group_rmsnorm", z.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_gated_group_rmsnorm::kernel_ir_for(z.dtype); k.mode = metaltile_core::ir::KernelMode::Reduction; k }); + let out = Tensor::empty(dev, vec![di], z.dtype)?; + let eps_buf = Tensor::new(scalar_buf(dev, eps.to_bits())?, vec![1], DType::F32); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let ng = (di / gs) as u32; + dev.dispatch(&k, &[Binding::Buffer(y.buffer.clone()), Binding::Buffer(z.buffer.clone()), Binding::Buffer(w.buffer.clone()), Binding::Buffer(out.buffer.clone()), Binding::Buffer(eps_buf.buffer.clone()), u(gs as u32)], Grid { grid: [ng, 1, 1], block: [(gs / 4) as u32, 1, 1] })?; + Ok(out) +} + +/// Roll a causal-conv state on-device: `new = [old[conv_dim..], xbc]`. +pub fn conv_roll(dev: &dyn Device, old: &Tensor, xbc: &Tensor, conv_dim: usize, kc: usize) -> Result { + let n = (kc - 1) * conv_dim; + let keep = (kc - 2) * conv_dim; + let k = cached_ir("ffai_conv_roll", old.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_conv_roll::kernel_ir_for(old.dtype); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let out = Tensor::empty(dev, vec![n], old.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(old.buffer.clone()), Binding::Buffer(xbc.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(conv_dim as u32), u(keep as u32), u(n as u32)], Grid { grid: [(n as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(out) +} + +/// Mamba2 batched-prefill causal depthwise conv1d with inline SiLU (NEMOTRON_CONV_DEVICE path). +/// +/// Processes all S prompt tokens in one dispatch with zero initial state (prefill +/// starts from scratch). Each thread computes one element of `y[s, conv_dim]` = +/// silu(bias[ch] + Σ_{k Result { + let kernel = lookup("conv1d_causal_prefill", DType::F32)?; + let y = Tensor::empty(dev, vec![s * conv_dim], DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch( + &kernel, + &[ + Binding::Buffer(xbc_in.buffer.clone()), + Binding::Buffer(w.buffer.clone()), + Binding::Buffer(bias.buffer.clone()), + Binding::Buffer(y.buffer.clone()), + u(conv_dim as u32), + u(kc as u32), + ], + // PERF: pack 256 threads/block (was block:[1,1,1] = 1 thread/block = 1/32 + // warp occupancy). The kernel uses gid_x = blockIdx*blockDim+threadIdx as a + // global linear element id, so block size is transparent as long as the grid + // covers exactly s*conv_dim threads. conv_dim (6144) is a multiple of 256, so + // s*conv_dim is always divisible by 256 → no over-launch / OOB. + { let n = (s * conv_dim) as u32; let b = 256u32; Grid { grid: [n / b, 1, 1], block: [b, 1, 1] } }, + )?; + Ok(y) +} + +/// Extract `[s, width]` columns from row-major `src [s, stride]` at column offset `col_off`. +/// Returns `dst [s * width]` — a contiguous slab. Used to carve z, xbc, dt_raw out of +/// the [s, in_proj_out] projection matrix on device (NEMOTRON_CONV_DEVICE path). +pub fn strided_col_copy( + dev: &dyn Device, + src: &Tensor, + s: usize, + stride: usize, + col_off: usize, + width: usize, +) -> Result { + let kernel = lookup("strided_col_copy", DType::F32)?; + // Defensive bounds check. The kernel reads `src[ti*stride + col_off + ci]` + // for ti in [0, s), ci in [0, width) — a sub-slab addressed purely via the + // host stride/col_off against the RAW bound buffer (tensor.offset ignored by + // design). A caller binding a row-stride/offset/width that overruns the + // bound buffer triggers an out-of-bounds READ — silent garbage on Metal, a + // deterministic MMU-fault on CUDA. Fail loudly, naming the shapes. + if s != 0 && width != 0 { + let need = (s - 1) * stride + col_off + width; // max read is src[need - 1] + let have = src.buffer.len() / DType::F32.size_bytes(); + if need > have { + return Err(Error::Msg(format!( + "strided_col_copy OOB: s={s} stride={stride} col_off={col_off} width={width} \ + needs {need} f32 elems but bound buffer holds {have}" + ))); + } + } + let dst = Tensor::empty(dev, vec![s * width], DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch( + &kernel, + &[ + Binding::Buffer(src.buffer.clone()), + Binding::Buffer(dst.buffer.clone()), + u(stride as u32), + u(col_off as u32), + u(width as u32), + ], + // PERF: 64 threads/block (was 1). `width` is one of {di=4096, ng*ds=1024, + // m_nh=64} — all multiples of 64, so s*width is divisible by 64 → exact grid. + { let n = (s * width) as u32; let b = 64u32; Grid { grid: [n / b, 1, 1], block: [b, 1, 1] } }, + )?; + Ok(dst) +} + +/// Batched softplus + tiled bias: `dst[ti*n + hi] = softplus(src[ti*n + hi] + bias[hi])`. +/// Converts the raw `[s, m_nh]` dt_raw tensor + dt_bias to `dt_all [s, m_nh]` on device. +/// (NEMOTRON_CONV_DEVICE path — replaces the CPU softplus loop.) +pub fn softplus_add_rows( + dev: &dyn Device, + src: &Tensor, + bias: &Tensor, + s: usize, + n: usize, +) -> Result { + let kernel = lookup("softplus_add_rows", DType::F32)?; + let dst = Tensor::empty(dev, vec![s * n], DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch( + &kernel, + &[ + Binding::Buffer(src.buffer.clone()), + Binding::Buffer(bias.buffer.clone()), + Binding::Buffer(dst.buffer.clone()), + u(n as u32), + ], + // PERF: pack threads/block (was 1). Kernel uses a global linear element id, + // so any block size works provided the grid covers exactly s*n threads. + // Pick the largest of {64,32,1} that divides s*n to avoid OOB over-launch. + { + let nt = (s * n) as u32; + let b = if nt % 64 == 0 { 64u32 } else if nt % 32 == 0 { 32u32 } else { 1u32 }; + Grid { grid: [nt / b, 1, 1], block: [b, 1, 1] } + }, + )?; + Ok(dst) +} + +/// Batched MoE up+ReLU²: gather `top_k` experts (indices `idx`) from the +/// contiguous `[n_exp*inter, hid]` Q4 weight into one `[top_k*inter]` GEMV. +pub fn moe_gather_up_relu2(dev: &dyn Device, qs: &Tensor, scales: &Tensor, x: &Tensor, idx: &Tensor, top_k: usize, inter: usize, hid: usize) -> Result { + let k = cached_ir("ffai_moe_gather_q4_relu2", x.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_moe_gather_q4_relu2::kernel_ir_for(x.dtype); k.mode = metaltile_core::ir::KernelMode::Reduction; k }); + let out = Tensor::empty(dev, vec![top_k * inter], x.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + // Default rpt=2: MoE-gather kernels are big + latency-bound; 2 warps/row hides + // global-load latency (clean internal-A/B: +1.8% @ctx4096, MoE cost is ctx-independent). + let rpt: u32 = std::env::var("MT_MOE_RPT").ok().and_then(|v| v.parse().ok()).filter(|&r| r >= 1).unwrap_or(2); + dev.dispatch(&k, &[Binding::Buffer(qs.buffer.clone()), Binding::Buffer(scales.buffer.clone()), Binding::Buffer(x.buffer.clone()), Binding::Buffer(idx.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(hid as u32), u(inter as u32), u(rpt)], Grid { grid: [(inter as u32).div_ceil(rpt), top_k as u32, 1], block: [32 * rpt, 1, 1] })?; + Ok(out) +} +/// Batched MoE down + router-weighted accumulate into `acc[hid]`. `qs` is the +/// contiguous `[n_exp*hid, inter]` Q4 weight; `x` is the `[top_k*inter]` up output. +#[allow(clippy::too_many_arguments)] +pub fn moe_gather_down_accum(dev: &dyn Device, qs: &Tensor, scales: &Tensor, x: &Tensor, idx: &Tensor, wts: &Tensor, acc: &Tensor, top_k: usize, inter: usize, hid: usize) -> Result<()> { + let k = cached_ir("ffai_moe_gather_q4_down_accum", x.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_moe_gather_q4_down_accum::kernel_ir_for(x.dtype); k.mode = metaltile_core::ir::KernelMode::Reduction; k }); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(qs.buffer.clone()), Binding::Buffer(scales.buffer.clone()), Binding::Buffer(x.buffer.clone()), Binding::Buffer(idx.buffer.clone()), Binding::Buffer(wts.buffer.clone()), Binding::Buffer(acc.buffer.clone()), u(inter as u32), u(hid as u32), u(top_k as u32)], Grid { grid: [hid as u32, 1, 1], block: [32, 1, 1] })?; + Ok(()) +} + +/// Batched MoE down gather → `[top_k*hid]` (one big GEMV, no accumulate). +pub fn moe_gather_down(dev: &dyn Device, qs: &Tensor, scales: &Tensor, x: &Tensor, idx: &Tensor, top_k: usize, inter: usize, hid: usize) -> Result { + let k = cached_ir("ffai_moe_gather_q4_down", x.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_moe_gather_q4_down::kernel_ir_for(x.dtype); k.mode = metaltile_core::ir::KernelMode::Reduction; k }); + let out = Tensor::empty(dev, vec![top_k * hid], x.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + // Default rpt=2: MoE-gather kernels are big + latency-bound; 2 warps/row hides + // global-load latency (clean internal-A/B: +1.8% @ctx4096, MoE cost is ctx-independent). + let rpt: u32 = std::env::var("MT_MOE_RPT").ok().and_then(|v| v.parse().ok()).filter(|&r| r >= 1).unwrap_or(2); + dev.dispatch(&k, &[Binding::Buffer(qs.buffer.clone()), Binding::Buffer(scales.buffer.clone()), Binding::Buffer(x.buffer.clone()), Binding::Buffer(idx.buffer.clone()), Binding::Buffer(out.buffer.clone()), u(inter as u32), u(hid as u32), u(rpt)], Grid { grid: [(hid as u32).div_ceil(rpt), top_k as u32, 1], block: [32 * rpt, 1, 1] })?; + Ok(out) +} +// ── Fused MoE FFN (NEMOTRON_MOE_FUSED=1) ───────────────────────────────────── +// Raw CUDA C++ source for the cooperative-groups two-phase fused kernel. +// +// Grid design for cooperative launch feasibility on GB10 (148 SMs, 2048 threads/SM): +// total_warps = hid = 2688 (one warp per acc[h] output element in phase 2) +// block = 256 (8 warps), grid = ceil(hid/8) = 336 blocks +// 336 blocks / 148 SMs ≈ 2.3 blocks/SM < 8 max → cooperative launch WORKS +// +// Phase 1: each warp serializes over its assigned slice of top_k*inter up-proj rows. +// rows_per_warp ≈ (top_k * inter) / hid ≈ 4.14 (integer: 4 or 5 rows per warp) +// → warp `w` computes scratch[row] for row in [w*n_up/hid .. (w+1)*n_up/hid) +// Phase 2: after grid.sync(), warp `w` computes acc[w] (one output element) by +// reading ALL of scratch and the down Q4 weights (down-proj GEMV over inter). +// +// The 44KB scratch (6×1856×4B) lives in L2 (~2TB/s GB10) throughout, not HBM. +const MOE_FUSED_SRC: &str = r#" +#include +#include +namespace cg = cooperative_groups; + +// Q4 (signed 4-bit, nibble-packed) + f16 scale per 32 → f32 warp dot product. +// lane 0 holds the result after return. +__device__ __forceinline__ float q4f16_dot_warp( + const unsigned* __restrict__ qs, + const __half* __restrict__ sc, + const float* __restrict__ x, + int row, int k_in, int lane) +{ + int bpr = k_in >> 5; + int nw = bpr * 4; + const unsigned* qrow = qs + (size_t)row * nw; + const __half* drow = sc + (size_t)row * bpr; + float dot = 0.f; + for (int j = lane; j < nw; j += 32) { + int blk = j >> 2, sub = j & 3; + unsigned p = qrow[j]; + float sc_f = __half2float(drow[blk]); + const float* xb = x + (blk << 5) + (sub << 3); + float a = 0.f; + #pragma unroll + for (int i = 0; i < 8; i++) { + int nb = (p >> (i * 4)) & 0xf; + a += (float)(nb > 7 ? nb - 16 : nb) * xb[i]; + } + dot += sc_f * a; + } + #pragma unroll + for (int o = 16; o; o >>= 1) dot += __shfl_down_sync(0xffffffff, dot, o); + return dot; +} + +__device__ __forceinline__ float relu2(float v) { + float r = v > 0.f ? v : 0.f; + return r * r; +} + +extern "C" __global__ void moe_fused_ffn( + const unsigned* __restrict__ up_qs, // [n_exp*inter, hid] Q4 up weights + const __half* __restrict__ up_sc, // [n_exp*inter, hid/32] f16 scales + const unsigned* __restrict__ dn_qs, // [n_exp*hid, inter] Q4 down weights + const __half* __restrict__ dn_sc, // [n_exp*hid, inter/32] f16 scales + const float* __restrict__ x, // [hid] input activation + const unsigned* __restrict__ idx, // [top_k] expert indices (u32) + const float* __restrict__ wts, // [top_k] router weights (f32) + float* __restrict__ acc, // [hid] output (pre-zeroed by caller) + float* __restrict__ scratch, // [top_k * inter] temp (pre-allocated) + int hid, int inter, int top_k) +{ + cg::grid_group gg = cg::this_grid(); + + // Global warp id — one warp per acc[h] for phase 2. + int tid = (int)(blockIdx.x * blockDim.x + threadIdx.x); + int lane = threadIdx.x & 31; + int warp_id = tid >> 5; + int n_warps = (int)gridDim.x * ((int)blockDim.x >> 5); + + // ── Phase 1: each warp serializes over its slice of up-proj rows ────── + // Linearised row space: row_lin = k * inter + i (k=expert index 0..top_k-1) + int n_up = top_k * inter; + // Use 64-bit division to avoid overflow; compiler optimises for small top_k. + int row_start = (int)((long long)warp_id * n_up / n_warps); + int row_end = (int)((long long)(warp_id + 1) * n_up / n_warps); + for (int row_lin = row_start; row_lin < row_end; row_lin++) { + int k = row_lin / inter; + int i = row_lin % inter; + int exp = (int)idx[k]; + int row = exp * inter + i; + float v = q4f16_dot_warp(up_qs, up_sc, x, row, hid, lane); + if (lane == 0) scratch[k * inter + i] = relu2(v); + } + + // ── Global grid barrier: all up-proj results are in scratch ────────── + gg.sync(); + + // ── Phase 2: each warp computes one acc[h] via down-proj ───────────── + if (warp_id < hid) { + int h = warp_id; + float acc_h = 0.f; + for (int k = 0; k < top_k; k++) { + int exp = (int)idx[k]; + int row = exp * hid + h; + int bpr = inter >> 5; + int nw = bpr * 4; + const unsigned* qrow = dn_qs + (size_t)row * nw; + const __half* drow = dn_sc + (size_t)row * bpr; + const float* xb_base = scratch + k * inter; + float dot = 0.f; + for (int j = lane; j < nw; j += 32) { + int blk = j >> 2, sub = j & 3; + unsigned p = qrow[j]; + float sc_f = __half2float(drow[blk]); + const float* xb = xb_base + (blk << 5) + (sub << 3); + float a = 0.f; + #pragma unroll + for (int i = 0; i < 8; i++) { + int nb = (p >> (i * 4)) & 0xf; + a += (float)(nb > 7 ? nb - 16 : nb) * xb[i]; + } + dot += sc_f * a; + } + #pragma unroll + for (int o = 16; o; o >>= 1) dot += __shfl_down_sync(0xffffffff, dot, o); + if (lane == 0) acc_h += dot * wts[k]; + } + if (lane == 0) acc[h] += acc_h; + } +} +"#; + +/// Fused MoE FFN: up-proj + relu² + down-proj + router-weighted accumulate, +/// all in ONE cooperative-groups kernel. The `[top_k×inter]` intermediate lives +/// in L2 cache instead of HBM (scratch pre-allocated once by caller). +/// +/// Replaces `moe_gather_up_relu2` + `moe_gather_down` + `moe_weighted_sum` with +/// a single launch. Enabled by `NEMOTRON_MOE_FUSED=1`. +/// +/// `scratch` must be a device buffer of at least `top_k * inter * 4` bytes. +/// `acc` must be zero-initialised by the caller before the first expert's +/// contribution (matches `moe_gather_down_accum` contract). +#[allow(clippy::too_many_arguments)] +pub fn moe_fused_ffn( + dev: &dyn Device, + up_qs: &Tensor, up_sc: &Tensor, + dn_qs: &Tensor, dn_sc: &Tensor, + x: &Tensor, + idx: &Tensor, + wts: &Tensor, + acc: &Tensor, + scratch: &Tensor, + hid: usize, inter: usize, top_k: usize, +) -> Result<()> { + // n_warps = hid: one warp per acc[h] output element (phase 2). + // Phase 1 distributes top_k*inter up-proj rows across the same n_warps warps + // (each warp handles ≈4 rows = top_k*inter/hid ≈ 4.14 for NemotronH dims). + // Grid: ceil(hid/16) = 168 blocks. GB10: 48 SMs × 4 blocks/SM (at 512 threads) + // = 192 max cooperative blocks → 168 ≤ 192 ✓ + let block = 512u32; // 16 warps/block + let warps_per_block = block / 32; + let grid = ((hid as u32) + warps_per_block - 1) / warps_per_block; + let scalars: &[Vec] = &[ + (hid as i32).to_le_bytes().to_vec(), + (inter as i32).to_le_bytes().to_vec(), + (top_k as i32).to_le_bytes().to_vec(), + ]; + dev.dispatch_raw_cuda( + MOE_FUSED_SRC, + "moe_fused_ffn.cu", + "moe_fused_ffn", + &[ + (up_qs.buffer.as_ref(), 0), + (up_sc.buffer.as_ref(), 0), + (dn_qs.buffer.as_ref(), 0), + (dn_sc.buffer.as_ref(), 0), + (x.buffer.as_ref(), 0), + (idx.buffer.as_ref(), 0), + (wts.buffer.as_ref(), 0), + (acc.buffer.as_ref(), 0), + (scratch.buffer.as_ref(), 0), + ], + scalars, + [grid, 1, 1], + [block, 1, 1], + 0, + true, // cooperative = use cuLaunchCooperativeKernel for grid.sync() + ) +} + +/// Router-weighted sum of per-expert down outputs into `acc`: `acc[h] += Σ wts·downs[·,h]`. +/// Fully ON-DEVICE MoE router (NemotronH sigmoid + e_score_correction_bias, top-k by +/// biased score, weights from unbiased renormalized to sum-1, ×routed_scaling_factor). +/// Returns (idx[top_k] U32, wts[top_k] F32) WITHOUT a host round-trip — replaces the +/// per-layer dl(gate)+host-topk+up(idx) sync that drains the async pipeline 23×/token. +pub fn moe_router_device(dev: &dyn Device, gate_logits: &Tensor, bias: &Tensor, n_exp: usize, top_k: usize, scale: f32) -> Result<(Tensor, Tensor)> { + use metaltile_core::ir::KernelMode; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let f = |v: f32| Binding::Scalar(v.to_le_bytes().to_vec()); + let unbiased = Tensor::empty(dev, vec![n_exp], DType::F32)?; + let biased = Tensor::empty(dev, vec![n_exp], DType::F32)?; + let ks = cached_ir("ffai_moe_sigmoid_bias", DType::F32, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_moe_sigmoid_bias::kernel_ir(); k.mode = KernelMode::Grid3D; k }); + dev.dispatch(&ks, &[Binding::Buffer(gate_logits.buffer.clone()), Binding::Buffer(bias.buffer.clone()), Binding::Buffer(unbiased.buffer.clone()), Binding::Buffer(biased.buffer.clone()), u(n_exp as u32)], Grid { grid: [(n_exp as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + let idx = Tensor::empty(dev, vec![top_k], DType::U32)?; + let wts = Tensor::empty(dev, vec![top_k], DType::F32)?; + let kr = cached_ir("mt_dsv4_router_topk", DType::F32, || { let mut k = metaltile_std::ffai::dsv4_router_topk::mt_dsv4_router_topk::kernel_ir_for(DType::F32); k.mode = KernelMode::Reduction; k }); + dev.dispatch(&kr, &[Binding::Buffer(biased.buffer.clone()), Binding::Buffer(unbiased.buffer.clone()), Binding::Buffer(idx.buffer.clone()), Binding::Buffer(wts.buffer.clone()), u(n_exp as u32), u(top_k as u32)], Grid { grid: [1, 1, 1], block: [32, 1, 1] })?; + let kv = cached_ir("ffai_vscale", DType::F32, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_vscale::kernel_ir(); k.mode = KernelMode::Grid3D; k }); + dev.dispatch(&kv, &[Binding::Buffer(wts.buffer.clone()), f(scale), u(top_k as u32)], Grid { grid: [(top_k as u32).div_ceil(64), 1, 1], block: [64, 1, 1] })?; + Ok((idx, wts)) +} + +/// On-device MoE route + counting-sort. Replaces the per-E-layer HOST `triples` +/// round-trip (router logits → host → sigmoid+topk+sort → upload) that stalls the +/// GPU every MoE layer and blocks CUDA-graph capture of prefill. Given gate logits +/// `[s, n_exp]` (f32) + bias `[n_exp]`, produces — ALL on device: +/// sorted_tok `[mt]` u32 — token index per sorted slot (mt = s*top_k) +/// sorted_wt `[mt]` f32 — router weight per slot (sigmoid, normalized, ·scale) +/// offsets `[n_exp+1]` u32 — group boundaries (expert e's rows = [off[e],off[e+1])) +/// Tokens grouped contiguously by expert. Order within an expert is atomic-cursor +/// (not stable) — same near-tie nondeterminism as the existing GROUPED_GEMM path. +const MOE_BATCHED_ROUTER_SRC: &str = r#" +extern "C" __global__ void moe_batched_router( + const float* __restrict__ logits, const float* __restrict__ bias, + unsigned* __restrict__ idx_out, float* __restrict__ wt_out, + int s, int n_exp, int top_k, float scale) +{ + __shared__ float u[256]; + __shared__ float b[256]; + __shared__ int chosen[32]; + int ti = blockIdx.x; + int e = threadIdx.x; + if (ti < s) { + for (int j = e; j < n_exp; j += blockDim.x) { + float z = logits[(long)ti*n_exp + j]; + float sg = 1.f/(1.f+expf(-z)); // precise expf (not __expf) to match host topk exactly + u[j] = sg; b[j] = sg + bias[j]; + } + } + __syncthreads(); + if (ti < s && e == 0) { + for (int r = 0; r < top_k; r++) { + int bi = 0; float bv = -1e30f; + for (int j = 0; j < n_exp; j++) { if (b[j] > bv) { bv = b[j]; bi = j; } } + chosen[r] = bi; b[bi] = -1e30f; + } + float ws = 1e-20f; + for (int r = 0; r < top_k; r++) ws += u[chosen[r]]; + for (int r = 0; r < top_k; r++) { + idx_out[(long)ti*top_k + r] = (unsigned)chosen[r]; + wt_out[(long)ti*top_k + r] = u[chosen[r]] / ws * scale; + } + } +} +"#; +const MOE_HIST_SRC: &str = r#" +#include +extern "C" __global__ void moe_hist(const unsigned* __restrict__ idx, unsigned* counts, int mt) { + int i = blockIdx.x*blockDim.x + threadIdx.x; if (i >= mt) return; + atomicAdd(counts + idx[i], 1u); +} +"#; +const MOE_PREFIX_SRC: &str = r#" +extern "C" __global__ void moe_prefix(const unsigned* __restrict__ counts, + unsigned* __restrict__ offsets, unsigned* __restrict__ cursor, int n_exp) { + if (threadIdx.x == 0) { + unsigned acc = 0; offsets[0] = 0; + for (int j = 0; j < n_exp; j++) { cursor[j] = acc; acc += counts[j]; offsets[j+1] = acc; } + } +} +"#; +const MOE_SCATTER_SORT_SRC: &str = r#" +extern "C" __global__ void moe_scatter_sort( + const unsigned* __restrict__ idx, const float* __restrict__ wt, + const unsigned* __restrict__ offsets, unsigned* __restrict__ sorted_tok, float* __restrict__ sorted_wt, + int mt, int top_k, int n_exp) +{ + // STABLE counting-sort scatter, warp-per-expert: lanes sweep 32 consecutive + // pairs per step (coalesced); __ballot gives each match its stable rank + // (popc of lower lanes) on top of a running per-warp base. Deterministic, + // no atomics, same output order as the old 1-thread-per-expert scan but + // 32-way parallel per expert. O(n_exp*mt/32) per warp. + int e = (blockIdx.x*blockDim.x + threadIdx.x) >> 5; if (e >= n_exp) return; + int lane = threadIdx.x & 31; + unsigned base = offsets[e]; + for (int w = 0; w < mt; w += 32) { + int i = w + lane; + bool match = (i < mt) && (idx[i] == (unsigned)e); + unsigned mask = __ballot_sync(0xffffffffu, match); + if (match) { + unsigned rank = __popc(mask & ((1u << lane) - 1u)); + sorted_tok[base + rank] = (unsigned)(i / top_k); + sorted_wt[base + rank] = wt[i]; + } + base += __popc(mask); + } +} +"#; + +#[allow(clippy::type_complexity)] +pub fn moe_route_sort_device( + dev: &dyn Device, gate_logits: &Tensor, bias: &Tensor, n_exp: usize, top_k: usize, scale: f32, +) -> Result<(Tensor, Tensor, Tensor)> { + let s = gate_logits.elem_count() / n_exp; + let mt = s * top_k; + let idx = Tensor::empty(dev, vec![mt], DType::U32)?; + let wt = Tensor::empty(dev, vec![mt], DType::F32)?; + let counts = Tensor::new(dev.alloc_zeroed(n_exp * 4).map_err(|e| Error::Msg(format!("{e:?}")))?, vec![n_exp], DType::U32); + let offsets = Tensor::empty(dev, vec![n_exp + 1], DType::U32)?; + let cursor = Tensor::empty(dev, vec![n_exp], DType::U32)?; + let sorted_tok = Tensor::empty(dev, vec![mt], DType::U32)?; + let sorted_wt = Tensor::empty(dev, vec![mt], DType::F32)?; + let i = |x: i32| x.to_le_bytes().to_vec(); + let f = |x: f32| x.to_le_bytes().to_vec(); + let blk = if n_exp >= 256 { 256 } else { n_exp.max(32) } as u32; + // 1. batched router → idx/wt + dev.dispatch_raw_cuda(MOE_BATCHED_ROUTER_SRC, "moe_route_sort.cu", "moe_batched_router", + &[(gate_logits.buffer.as_ref(), gate_logits.offset), (bias.buffer.as_ref(), bias.offset), + (idx.buffer.as_ref(), 0), (wt.buffer.as_ref(), 0)], + &[i(s as i32), i(n_exp as i32), i(top_k as i32), f(scale)], + [s as u32, 1, 1], [blk, 1, 1], 0, false)?; + // 2. histogram + dev.dispatch_raw_cuda(MOE_HIST_SRC, "moe_hist.cu", "moe_hist", + &[(idx.buffer.as_ref(), 0), (counts.buffer.as_ref(), 0)], + &[i(mt as i32)], [(mt as u32).div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + // 3. exclusive prefix → offsets + cursor (cursor[e] = offsets[e]) + dev.dispatch_raw_cuda(MOE_PREFIX_SRC, "moe_prefix.cu", "moe_prefix", + &[(counts.buffer.as_ref(), 0), (offsets.buffer.as_ref(), 0), (cursor.buffer.as_ref(), 0)], + &[i(n_exp as i32)], [1, 1, 1], [32, 1, 1], 0, false)?; + // 4. STABLE scatter (one thread/expert, scans in index order) → sorted output + let _ = &cursor; // cursor no longer used (stable scatter reads offsets directly) + dev.dispatch_raw_cuda(MOE_SCATTER_SORT_SRC, "moe_scatter_sort.cu", "moe_scatter_sort", + &[(idx.buffer.as_ref(), 0), (wt.buffer.as_ref(), 0), (offsets.buffer.as_ref(), 0), + (sorted_tok.buffer.as_ref(), 0), (sorted_wt.buffer.as_ref(), 0)], + &[i(mt as i32), i(top_k as i32), i(n_exp as i32)], [((n_exp * 32) as u32).div_ceil(128), 1, 1], [128, 1, 1], 0, false)?; + Ok((sorted_tok, sorted_wt, offsets)) +} + +pub fn moe_weighted_sum(dev: &dyn Device, downs: &Tensor, wts: &Tensor, acc: &Tensor, hid: usize, top_k: usize) -> Result<()> { + let k = cached_ir("ffai_moe_weighted_sum", acc.dtype, || { let mut k = metaltile_std::ffai::gemv_q8::ffai_moe_weighted_sum::kernel_ir_for(acc.dtype); k.mode = metaltile_core::ir::KernelMode::Grid3D; k }); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch(&k, &[Binding::Buffer(downs.buffer.clone()), Binding::Buffer(wts.buffer.clone()), Binding::Buffer(acc.buffer.clone()), u(hid as u32), u(top_k as u32)], Grid { grid: [(hid as u32).div_ceil(256), 1, 1], block: [256, 1, 1] })?; + Ok(()) +} + +// ── Q4 (4-bit) family — half the weight DRAM of Q8 (the decode lever). ── +fn gemv_q4_dispatch(dev: &dyn Device, kernel: &str, qs: &Tensor, scales: &Tensor, x: &Tensor, acc: Option<&Tensor>, scale_buf: Option<&Tensor>, m_out: usize, k_in: usize, rows_per_group: usize) -> Result { + // 2-row tiling: 2 output rows per warp, shared activation read, 2 weight + // streams in flight (more memory-level-parallelism on the latency-bound Q4 read). + let vec = kernel == "plain" && std::env::var("MT_GEMV_VEC").is_ok(); + let two_row = !vec && kernel == "plain" && std::env::var("MT_GEMV_2ROW").is_ok(); + let name = if vec { "ffai_gemv_q4_vec" } else if two_row { "ffai_gemv_q4_coalesced_2row" } else { match kernel { "plain" => "ffai_gemv_q4_coalesced", "relu2" => "ffai_gemv_q4_coalesced_relu2", _ => "ffai_gemv_q4_coalesced_accum" } }; + let k = cached_ir(name, x.dtype, || { + let mut k = if vec { + metaltile_std::ffai::gemv_q8::ffai_gemv_q4_vec::kernel_ir_for(x.dtype) + } else if two_row { + metaltile_std::ffai::gemv_q8::ffai_gemv_q4_coalesced_2row::kernel_ir_for(x.dtype) + } else { match kernel { + "plain" => metaltile_std::ffai::gemv_q8::ffai_gemv_q4_coalesced::kernel_ir_for(x.dtype), + "relu2" => metaltile_std::ffai::gemv_q8::ffai_gemv_q4_coalesced_relu2::kernel_ir_for(x.dtype), + _ => metaltile_std::ffai::gemv_q8::ffai_gemv_q4_coalesced_accum::kernel_ir_for(x.dtype), + }}; + k.mode = metaltile_core::ir::KernelMode::Reduction; + k + }); + let out = acc.cloned().unwrap_or(Tensor::empty(dev, vec![m_out], x.dtype)?); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let mut b = vec![Binding::Buffer(qs.buffer.clone()), Binding::Buffer(scales.buffer.clone()), Binding::Buffer(x.buffer.clone()), Binding::Buffer(out.buffer.clone())]; + if let Some(sb) = scale_buf { b.push(Binding::Buffer(sb.buffer.clone())); } + b.extend([u(k_in as u32), u(m_out as u32), u(rows_per_group as u32)]); + // Plain gemv is multi-warp capable (rows_per_tg warps/TG to hide the + // ncu-measured global-load latency). MT_GEMV_RPT=1 → original single-warp. + let grid = if vec { + // single-warp/row, vectorized loads (no rpt binding) + Grid { grid: [m_out as u32, 1, 1], block: [32, 1, 1] } + } else if kernel == "plain" { + let rpt: u32 = std::env::var("MT_GEMV_RPT").ok().and_then(|v| v.parse().ok()).filter(|&r| r >= 1).unwrap_or(1); + b.push(u(rpt)); + let rows_per_block = if two_row { 2 * rpt } else { rpt }; + Grid { grid: [(m_out as u32).div_ceil(rows_per_block), 1, 1], block: [32 * rpt, 1, 1] } + } else { + // relu2 / accum: multi-warp capable (rows_per_tg warps/TG). The shared- + // expert matrices (3712×2688, 2688×3712) are small; rpt>1 gives no + // measured gain (these kernels are NOT latency-bound — the ~50% BW the + // sync-bracketed profiler showed is a measurement artifact, the kernel + // time is small). Default rpt=1 (bit-identical to the original single- + // warp form); MT_GEMV_SHARED_RPT overrides for experimentation. + let rpt: u32 = std::env::var("MT_GEMV_SHARED_RPT").ok().and_then(|v| v.parse().ok()).filter(|&r| r >= 1).unwrap_or(1); + b.push(u(rpt)); + Grid { grid: [(m_out as u32).div_ceil(rpt), 1, 1], block: [32 * rpt, 1, 1] } + }; + dev.dispatch(&k, &b, grid)?; + Ok(out) +} +/// Q4 matvec `out = Wq4·x`. (block 32, int4 [-7,7], f32 scale/block.) +pub fn gemv_q4(dev: &dyn Device, qs: &Tensor, scales: &Tensor, x: &Tensor, m_out: usize, k_in: usize, rpg: usize) -> Result { + gemv_q4_dispatch(dev, "plain", qs, scales, x, None, None, m_out, k_in, rpg) +} +/// Q4 matvec with fused ReLU² (MoE expert up). +pub fn gemv_q4_relu2(dev: &dyn Device, qs: &Tensor, scales: &Tensor, x: &Tensor, m_out: usize, k_in: usize, rpg: usize) -> Result { + gemv_q4_dispatch(dev, "relu2", qs, scales, x, None, None, m_out, k_in, rpg) +} +/// Q4 matvec, scale + accumulate into `acc` in place (MoE expert down). +#[allow(clippy::too_many_arguments)] +pub fn gemv_q4_accum(dev: &dyn Device, qs: &Tensor, scales: &Tensor, x: &Tensor, acc: &Tensor, scale_buf: &Tensor, m_out: usize, k_in: usize, rpg: usize) -> Result<()> { + gemv_q4_dispatch(dev, "accum", qs, scales, x, Some(acc), Some(scale_buf), m_out, k_in, rpg)?; + Ok(()) +} +/// Quantize a row-major `[m,k]` F32 weight to Q4 blocks (block 32, symmetric int4 +/// in [-7,7], f32 scale `amax/7`). Returns `(qs_u32, scales)`: qs `[m·k/32·4]` +/// (8 nibbles/u32, 4 u32/block), scales `[m·k/32]`. `k` must be a multiple of 32. +pub fn quantize_q4(w: &[f32], m: usize, k: usize) -> (Vec, Vec) { + assert_eq!(k % 32, 0, "quantize_q4: k must be a multiple of 32"); + let bpr = k / 32; + let mut qs = vec![0u32; m * bpr * 4]; + let mut scales = vec![0f32; m * bpr]; + for r in 0..m { + for b in 0..bpr { + let base = r * k + b * 32; + let amax = (0..32).fold(0f32, |a, i| a.max(w[base + i].abs())); + let d = amax / 7.0; + scales[r * bpr + b] = d; + let inv = if d > 0.0 { 1.0 / d } else { 0.0 }; + for word in 0..4 { + let mut packed = 0u32; + for i in 0..8 { + let q = (w[base + word * 8 + i] * inv).round().clamp(-7.0, 7.0) as i32; + packed |= ((q as u32) & 0xf) << (i * 4); + } + qs[r * bpr * 4 + b * 4 + word] = packed; + } + } + } + (qs, scales) +} + +/// Quantize a row-major `[m, k]` F32 weight to Q8_0 blocks (block 32, symmetric +/// int8, per-block f32 scale `amax/127`). Returns `(qs_u32_packed, scales_f32)` +/// laid out exactly as [`gemv_q8`] expects: qs `[m · k/32 · 8]` u32, scales +/// `[m · k/32]` f32. CPU, one-time at load. `k` must be a multiple of 32. +pub fn quantize_q8(w: &[f32], m: usize, k: usize) -> (Vec, Vec) { + assert_eq!(k % 32, 0, "quantize_q8: k must be a multiple of 32"); + let bpr = k / 32; + let mut qs = vec![0u32; m * bpr * 8]; + let mut scales = vec![0f32; m * bpr]; + for r in 0..m { + for b in 0..bpr { + let base = r * k + b * 32; + let amax = (0..32).fold(0f32, |a, i| a.max(w[base + i].abs())); + let d = amax / 127.0; + scales[r * bpr + b] = d; + let inv = if d > 0.0 { 1.0 / d } else { 0.0 }; + for w_i in 0..8 { + let mut packed = 0u32; + for i in 0..4 { + let q = (w[base + w_i * 4 + i] * inv).round().clamp(-127.0, 127.0) as i32; + packed |= ((q as u8) as u32) << (i * 8); + } + qs[r * bpr * 8 + b * 8 + w_i] = packed; + } + } + } + (qs, scales) +} + +/// e2m1 (FP4) codebook magnitudes (index 0..7); sign bit is bit 3. +const E2M1_LUT: [f32; 8] = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]; + +/// Encode a value `x` (already divided by its block scale) to a 4-bit e2m1 code +/// (sign<<3 | nearest-magnitude-index). Round-to-nearest over the 8-entry codebook. +#[inline] +fn enc_e2m1(v: f32) -> u8 { + let sign = if v < 0.0 { 8u8 } else { 0u8 }; + let a = v.abs(); + let mut best = 0usize; + let mut bd = f32::INFINITY; + for (i, &m) in E2M1_LUT.iter().enumerate() { + let d = (a - m).abs(); + if d < bd { bd = d; best = i; } + } + sign | best as u8 +} + +/// Encode a non-negative scale `s` to a UE4M3 byte (4 exp bits bias-7, 3 mantissa, +/// no sign — NVFP4's micro-scale type). Round-to-nearest over representable values. +#[inline] +fn enc_ue4m3(s: f32) -> u8 { + if s <= 0.0 { return 0; } + let (mut be, mut bm, mut bd) = (0i32, 0i32, f32::INFINITY); + for e in 0..16i32 { + for m in 0..8i32 { + let v = if e == 0 { (m as f32 / 8.0) * 0.007_812_5 } + else { (1.0 + m as f32 / 8.0) * (e as f32 - 7.0).exp2() }; + let d = (s - v).abs(); + if d < bd { bd = d; be = e; bm = m; } + } + } + ((be << 3) | bm) as u8 +} + +/// Decode a UE4M3 byte back to f32 (for reconstruction / host reference). +#[inline] +pub fn dec_ue4m3(b: u8) -> f32 { + let e = ((b >> 3) & 0xF) as i32; + let m = (b & 7) as i32; + if e == 0 { (m as f32 / 8.0) * 0.007_812_5 } else { (1.0 + m as f32 / 8.0) * (e as f32 - 7.0).exp2() } +} + +/// Quantize a row-major `[m, k]` F32 weight to **NVFP4** (NVIDIA FP4): E2M1 4-bit +/// codes + per-**block-16** UE4M3 micro-scale + one per-tensor FP32 global. This is +/// the format the native Blackwell `mma.sync.kind::mxf4nvf4` tensor-core path +/// consumes (validated bit-exact on GB10/sm_121a). Single quantization from the +/// original full-precision weights (no Q4→FP4 double-quant). `k % 16 == 0`. +/// +/// Returns `(codes, scales, global)`: +/// - `codes`: `[m · k/8]` u32, 8 e2m1 nibbles per u32 (nibble j of word w = element +/// `w*8 + j` of the row), row-major — same packing convention as [`quantize_q4`]. +/// - `scales`: `[m · k/16]` UE4M3 bytes (one per 16-element K-block, row-major). +/// - `global`: per-tensor f32; the true block scale is `dec_ue4m3(scale) * global`. +/// Chosen so each block's `amax/6` lands in UE4M3's representable range. +/// +/// Example: `let (codes, scales, g) = quantize_nvfp4(&w, n_rows, k_in);` +pub fn quantize_nvfp4(w: &[f32], m: usize, k: usize) -> (Vec, Vec, f32) { + assert_eq!(k % 16, 0, "quantize_nvfp4: k must be a multiple of 16"); + let nblk = k / 16; + // Per-tensor global: makes per-block (amax/6) fit UE4M3's ~448 max. + let gmax = w.iter().fold(0f32, |a, &x| a.max(x.abs())); + let global = if gmax > 0.0 { gmax / 6.0 / 448.0 } else { 1.0 }; + let mut codes = vec![0u32; m * (k / 8)]; + let mut scales = vec![0u8; m * nblk]; + for r in 0..m { + for b in 0..nblk { + let base = r * k + b * 16; + let amax = (0..16).fold(0f32, |a, i| a.max(w[base + i].abs())); + let sb = enc_ue4m3((amax / 6.0) / global); + scales[r * nblk + b] = sb; + let bs = dec_ue4m3(sb) * global; // reconstructed block scale + let inv = if bs > 0.0 { 1.0 / bs } else { 0.0 }; + for i in 0..16 { + let code = enc_e2m1(w[base + i] * inv) as u32; + let elem = b * 16 + i; // index within the row + let word = elem / 8; + let nib = elem % 8; + codes[r * (k / 8) + word] |= code << (nib * 4); + } + } + } + (codes, scales, global) +} + +/// Dequantize a [`quantize_nvfp4`] tensor back to row-major `[m, k]` f32 (for +/// host reference / FP4-sim round-trips). `codes` `[m·k/8]` u32, `scales` +/// `[m·k/16]` UE4M3 bytes, `global` f32. +pub fn dequantize_nvfp4(codes: &[u32], scales: &[u8], global: f32, m: usize, k: usize) -> Vec { + let nblk = k / 16; + let mut out = vec![0f32; m * k]; + for r in 0..m { + for b in 0..nblk { + let bs = dec_ue4m3(scales[r * nblk + b]) * global; + for i in 0..16 { + let elem = b * 16 + i; + let word = elem / 8; + let nib = elem % 8; + let code = (codes[r * (k / 8) + word] >> (nib * 4)) & 0xf; + let sign = if code & 8 != 0 { -1.0 } else { 1.0 }; + out[r * k + elem] = sign * E2M1_LUT[(code & 7) as usize] * bs; + } + } + } + out +} + +/// Round-trip a row-major `[m, k]` f32 buffer through NVFP4 quantize→dequantize — +/// i.e. inject exactly the E2M1+UE4M3 block-16 quantization noise the native FP4 +/// GEMM would see, without any kernel. Used to de-risk argmax before building the +/// real FP4 path (`NEMOTRON_FP4_SIM`). +pub fn nvfp4_roundtrip(w: &[f32], m: usize, k: usize) -> Vec { + let (codes, scales, global) = quantize_nvfp4(w, m, k); + dequantize_nvfp4(&codes, &scales, global, m, k) +} + +/// Prefill linear: `out[r, :] = weight · input[r, :]` for a block of rows in +/// one dispatch — `weight` is `[out_dim, in_dim]`, `input` is `[n_rows, in_dim]`, +/// result `[n_rows, out_dim]`. Dispatches the 32×32-tiled Reduction kernel +/// `ffai_gemm` (weight read once per tile, reused across the row block — the +/// many-token path that a `gemv`-per-row would re-stream). Requires +/// `in_dim % 16 == 0` (the K-tile contract); row/out-dim edges are handled +/// in-kernel. This is the matmul the prefill + VLM/ViT towers run on. +pub fn matmul(dev: &dyn Device, weight: &Tensor, input: &Tensor) -> Result { + if weight.shape.len() != 2 { + return Err(Error::Msg(format!("matmul: weight must be 2-D, got {:?}", weight.shape))); + } + let (out_dim, in_dim) = (weight.shape[0], weight.shape[1]); + let rows = input.elem_count() / in_dim; + if input.elem_count() != rows * in_dim { + return Err(Error::Msg(format!("matmul: input {:?} not a multiple of in_dim {in_dim}", input.shape))); + } + if in_dim % 16 != 0 { + return Err(Error::Msg(format!("matmul: in_dim {in_dim} must be a multiple of 16"))); + } + let k = lookup("ffai_gemm", weight.dtype)?; + let out = Tensor::empty(dev, vec![rows, out_dim], weight.dtype)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let grid = Grid { + grid: [(out_dim as u32).div_ceil(32), (rows as u32).div_ceil(32), 1], + block: [1024, 1, 1], + }; + dev.dispatch( + &k, + &[ + Binding::Buffer(weight.buffer.clone()), + Binding::Buffer(input.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(in_dim as u32), + u(out_dim as u32), + u(rows as u32), + ], + grid, + )?; + Ok(out) +} + +/// Tensor-core GEMM via cuBLAS (Path A escape hatch). Computes the row-major +/// `out[m,n] = x[m,k] · w[n,k]ᵀ` — the projection `out[r,o] = Σ_k w[o,k]·x[r,k]` +/// where `w` is the DENSE `[n,k]` weight (f16/bf16, NOT quantized) and `x` is +/// `[m,k]`. Runs on the GB10 tensor cores (f32 accumulate). The caller dequants +/// the Q4 weight to f16/bf16 once, then this hits real TFLOP/s (vs the +/// coop_tile software emulation at ~0.1% of peak). +pub fn gemm_cublas( + dev: &dyn Device, + x: &Tensor, + w: &Tensor, + m: usize, + n: usize, + k: usize, +) -> Result { + if x.dtype != w.dtype { + return Err(Error::Msg(format!("gemm_cublas: x/w dtype mismatch {:?} vs {:?}", x.dtype, w.dtype))); + } + let out = Tensor::empty(dev, vec![m, n], x.dtype)?; + dev.gemm_tc(x.buffer.as_ref(), w.buffer.as_ref(), out.buffer.as_ref(), m, n, k, x.dtype)?; + Ok(out) +} + +/// Same as [], but writes an **f32** output, fusing the post-GEMM +/// `cast_f16_f32` into the matmul. `x`/`w` are f16/bf16; cuBLAS already +/// accumulates in f32, so the only change is the D-layout type → no extra +/// kernel, no MFU loss. Lets the model keep the residual stream f32 (required +/// — f16/bf16 residual overflows/loses argmax) without a separate cast pass. +/// CUDA-only; other backends error (model gates this on `is_cuda`). +pub fn gemm_cublas_f32out( + dev: &dyn Device, + x: &Tensor, + w: &Tensor, + m: usize, + n: usize, + k: usize, +) -> Result { + if x.dtype != w.dtype { + return Err(Error::Msg(format!("gemm_cublas_f32out: x/w dtype mismatch {:?} vs {:?}", x.dtype, w.dtype))); + } + // A/B lever: FFAI_F32OUT_FALLBACK=1 reverts to the unfused path (f16-out GEMM + // + a separate `cast_f16_f32` kernel) so the fused vs unfused cast can be timed + // back-to-back under the same clock. Default = fused (no extra kernel). + if std::env::var("FFAI_F32OUT_FALLBACK").is_ok() { + let oh = gemm_cublas(dev, x, w, m, n, k)?; + return cast_f16_f32(dev, &oh); + } + let out = Tensor::empty(dev, vec![m, n], DType::F32)?; + dev.gemm_tc_out_f32(x.buffer.as_ref(), w.buffer.as_ref(), out.buffer.as_ref(), m, n, k, x.dtype)?; + Ok(out) +} + +/// Dense Q4 GEMM via cooperative-tensor MMA (`ffai_gemm_q4_mpp`). Weight +/// `[out_dim, k_in]` is the bench's Q4 layout (qs u32 4-words/block, scales +/// f16 amax/7); `x` is `[n_rows, k_in]` of dtype T; out is `[n_rows, out_dim]` +/// of T. The tensor-core projection GEMM for prefill (replaces the f32 scalar +/// `matmul` that sat at ~0.1% of peak). Reduction-mode, grid +/// `[ceil(out/64), ceil(rows/64), 1]`, block 128. `k_in % 32 == 0`. +#[allow(clippy::too_many_arguments)] +pub fn gemm_q4_mpp( + dev: &dyn Device, + x: &Tensor, + qs: &Tensor, + scales: &Tensor, + n_rows: usize, + out_dim: usize, + k_in: usize, +) -> Result { + use metaltile_core::ir::KernelMode; + if k_in % 32 != 0 { + return Err(Error::Msg(format!("gemm_q4_mpp: k_in {k_in} must be a multiple of 32"))); + } + let out = Tensor::empty(dev, vec![n_rows, out_dim], x.dtype)?; + let kern = cached_ir("ffai_gemm_q4_mpp", x.dtype, || { + let mut kk = metaltile_std::ffai::gemm_q4_mpp::ffai_gemm_q4_mpp::kernel_ir_for(x.dtype); + kk.mode = KernelMode::Reduction; + kk + }); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let grid = Grid { + grid: [(out_dim as u32).div_ceil(64), (n_rows as u32).div_ceil(64), 1], + block: [128, 1, 1], + }; + dev.dispatch( + &kern, + &[ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(qs.buffer.clone()), + Binding::Buffer(scales.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(n_rows as u32), + u(out_dim as u32), + u(k_in as u32), + ], + grid, + )?; + Ok(out) +} + +/// Routed-expert MoE Q4 grouped BGEMM via MMA (`ffai_moe_bgemm_q4_bm64`). The +/// token rows of `x` `[m_total, k_in]` MUST be pre-sorted by expert, with +/// `indices` `[m_total]` giving each row's expert id. Expert weights are the +/// contiguous Q4 pool `[n_experts*n_out, k_in]` (qs u32 + scales f16). Output +/// `[m_total, n_out]` (sorted order; host scatters back to token order). The +/// batched-over-S replacement for the per-token MoE gather loop. Reduction-mode, +/// grid `[n_out/64, ceil(m_total/64), 1]`, block 128. `n_out % 64 == 0`. +#[allow(clippy::too_many_arguments)] +pub fn moe_bgemm_q4_bm64( + dev: &dyn Device, + x: &Tensor, + qs: &Tensor, + scales: &Tensor, + indices: &Tensor, + m_total: usize, + n_out: usize, + k_in: usize, +) -> Result { + use metaltile_core::ir::KernelMode; + if n_out % 64 != 0 { + return Err(Error::Msg(format!("moe_bgemm_q4_bm64: n_out {n_out} must be a multiple of 64"))); + } + let out = Tensor::empty(dev, vec![m_total, n_out], x.dtype)?; + let kern = cached_ir("ffai_moe_bgemm_q4_bm64", x.dtype, || { + let mut kk = metaltile_std::ffai::moe_bgemm_q4_bm64::ffai_moe_bgemm_q4_bm64::kernel_ir_for(x.dtype); + kk.mode = KernelMode::Reduction; + kk + }); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let grid = Grid { + grid: [(n_out as u32) / 64, (m_total as u32).div_ceil(64), 1], + block: [128, 1, 1], + }; + dev.dispatch( + &kern, + &[ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(qs.buffer.clone()), + Binding::Buffer(scales.buffer.clone()), + Binding::Buffer(indices.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(m_total as u32), + u(n_out as u32), + u(k_in as u32), + ], + grid, + )?; + Ok(out) +} + +/// Q4 → dense `[m, k]` dequant. Expands a resident Q4 weight (the bench's +/// `quantize_q4` layout: `qs [m·(k/32)·4]` u32, signed nibbles; `scales +/// [m·(k/32)]` f32, amax/7) into a dense slab of `out_dtype` for the +/// compute-bound prefill GEMM (dequant-once → tensor-core `ffai_gemm`). +/// 1-D grid, one thread per output value. +pub fn dequant_q4( + dev: &dyn Device, + qs: &Tensor, + scales: &Tensor, + m: usize, + k: usize, + out_dtype: DType, +) -> Result { + dequant_q4_off(dev, qs, scales, m, k, out_dtype, 0) +} + +/// Like [`dequant_q4`] but dequants the `[m,k]` slab starting at block offset +/// `blk_off` (in 32-value Q4 blocks) inside the qs/scales pool — used to peel +/// one MoE expert's rows out of the contiguous `[n_exp*out, k]` Q4 pool without +/// a tensor view. `blk_off = expert * m * (k/32)`. +#[allow(clippy::too_many_arguments)] +pub fn dequant_q4_off( + dev: &dyn Device, + qs: &Tensor, + scales: &Tensor, + m: usize, + k: usize, + out_dtype: DType, + blk_off: usize, +) -> Result { + use metaltile_core::ir::KernelMode; + let n = m * k; + // Defensive bounds check. The kernel addresses sub-slabs purely via blk_off + // against the RAW bound buffers, reading up to qs[(blk_off + m*bpr - 1)*4 + 3] + // and scales[blk_off + m*bpr - 1]. If a caller binds a buffer that doesn't + // actually hold blk_off+slab blocks (an under-sized scratch / per-expert pool), + // that is an out-of-bounds READ — silent garbage on Metal, a deterministic + // MMU-fault (Xid 31 VIRT_READ) on CUDA. Fail loudly here, naming the shapes, + // so the mis-sized caller is obvious on every backend. + let bpr = k / 32; + let need_blocks = blk_off + m * bpr; + let have_qs_blocks = qs.buffer.len() / 16; // 4 u32 words/block * 4 B/word + let have_sc_blocks = scales.buffer.len() / 2; // f16 scale/block + if need_blocks > have_qs_blocks || need_blocks > have_sc_blocks { + return Err(Error::Msg(format!( + "dequant_q4_off OOB: m={m} k={k} blk_off={blk_off} needs {need_blocks} Q4 blocks \ + but bound buffers hold qs={have_qs_blocks} scales={have_sc_blocks} blocks" + ))); + } + let out = Tensor::empty(dev, vec![m, k], out_dtype)?; + let kern = cached_ir("ffai_dequant_q4", out_dtype, || { + let mut kk = metaltile_std::ffai::ffai_dequant_q4::ffai_dequant_q4::kernel_ir_for(out_dtype); + kk.mode = KernelMode::Grid3D; + kk + }); + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + // 1 thread per u32 word → 8 outputs. Grid is over words (n/8), not values: + // amortises the qs/scale global traffic 8×. n is always a multiple of 8 + // (k is a multiple of 32 → 4 words/block, 8 values/word). + let n_words = (n / 8) as u32; + let grid = Grid::d1(n_words.div_ceil(256), 256); + dev.dispatch( + &kern, + &[ + Binding::Buffer(qs.buffer.clone()), + Binding::Buffer(scales.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(k as u32), + u(n_words), + u(blk_off as u32), + ], + grid, + )?; + Ok(out) +} + +/// Multi-query (prefill) SDPA — attends `n_query` query rows against a shared +/// K/V cache in one dispatch. `causal=1` → query `r` attends `[0, base_kv+r+1)` +/// (causal within the block, prefix fully visible); `causal=0` → bidirectional +/// over `[0, base_kv+n_query)`. Q/out `[n_query, n_q_heads, head_dim]`, K/V +/// `[n_kv_heads, kv_stride, head_dim]`. head_dim hard-128. This is the prefill +/// flash-attn (`ffai_sdpa_multi`), Reduction-mode, grid `[n_q_heads*n_query]`, +/// block 1024. +#[allow(clippy::too_many_arguments)] +pub fn sdpa_multi( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_q_heads: u32, + base_kv: u32, + n_query: u32, + kv_stride: u32, + heads_per_group: u32, + causal: bool, + scale: f32, +) -> Result { + use metaltile_core::ir::KernelMode; + if head_dim != 128 { + return Err(Error::Msg(format!("sdpa_multi: head_dim must be 128, got {head_dim}"))); + } + let out = Tensor::empty(dev, vec![n_query as usize, n_q_heads as usize, head_dim], q.dtype)?; + let kern = cached_ir("ffai_sdpa_multi", q.dtype, || { + let mut kk = metaltile_std::ffai::sdpa_multi::ffai_sdpa_multi::kernel_ir_for(q.dtype); + kk.mode = KernelMode::Reduction; + kk + }); + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let f = |x: f32| Binding::Scalar(x.to_le_bytes().to_vec()); + let grid = Grid { grid: [n_q_heads * n_query, 1, 1], block: [1024, 1, 1] }; + dev.dispatch( + &kern, + &[ + Binding::Buffer(q.buffer.clone()), + Binding::Buffer(k.buffer.clone()), + Binding::Buffer(v.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(head_dim as u32), + u(n_q_heads), + u(base_kv), + u(n_query), + u(kv_stride), + u(heads_per_group), + u(u32::from(causal)), + f(scale), + ], + grid, + )?; + Ok(out) +} + +// ─── Tensor-core prefill SDPA (`sdpa_multi_tc`) ────────────────────────────── +// +// Drop-in replacement for [`sdpa_multi`] that runs the two big matmuls (QKᵀ and +// P·V) on the GB10 **tensor cores via cuBLAS** instead of the software-MMA +// `ffai_sdpa_multi` kernel (which sits at ~0.1% of peak and dominated the +// long-context prefill). It is a FlashAttention-style online-softmax loop tiled +// over KV blocks so the score matrix is never materialised in full: +// +// for each KV block: +// S_blk = Qh · Khᵀ (cublasGemmStridedBatchedEx, fp16→fp32→fp16) +// P_blk = exp(S_blk - m_blk) + causal mask (custom kernel; emits m_blk,l_blk) +// O_blk = P_blk · Vt (cublasGemmStridedBatchedEx) +// merge O_blk into running O with per-row rescale; update m_run, l_run +// O = O_run / l_run, transpose head-major → row-major, cast back to caller dtype +// +// GQA fan-out (16 query heads : 1 KV head) is handled by issuing the batched +// GEMM **once per KV group** with the K/V stride set to 0 (the 16 query heads in +// a group all read the same KV slab — no duplicated KV is materialised). +// +// Q is pre-scaled by `scale` in the transpose kernel, so the GEMM/softmax run on +// already-scaled logits (matches `ffai_sdpa_multi`, which folds `scale` into Q). + +/// Grouped MoE GEMM via mma.sync (register-O), ONE launch for ALL experts. +/// `out[t,n] = Σ_k A[t,k]·W[grp(t)][n,k]` over sorted tokens (each contiguous +/// token range = one expert). Reads the RESIDENT f16 expert weights directly via +/// a device pointer array — no f16 scratch (that's what sank cuBLAS-grouped) — +/// and runs every expert's output tiles concurrently to FILL the GPU (the skinny +/// m≈96 per-expert cuBLAS GEMMs hit only ~15 CTAs → 1.5% MFU). Same m16n8k16 +/// register-O technique + fragment packing as `sdpa_flash_mma`'s QKᵀ. 1 warp / +/// (16-token × 64-col) tile; grid (n_mtiles, ceil(N/64)). f16 in/out. +const MOE_GROUPED_GEMM_MMA_SRC: &str = r#" +#include +__device__ __forceinline__ unsigned pk2(__half a, __half b){ + return (unsigned)__half_as_ushort(a) | ((unsigned)__half_as_ushort(b) << 16); +} +#define CPA(SD, SP, V) asm volatile("cp.async.cg.shared.global [%0],[%1],16,%2;\n" \ + :: "r"(SD), "l"(SP), "r"((V)?16:0)) +// BM=64, BN=128: each block computes 128 N-cols so the loaded A tile is reused +// across 2x the weights → halves redundant A reads (the real bottleneck: A was +// re-read once per 64-wide N-tile, N/64=29 times/expert > weight traffic). +extern "C" __global__ void moe_grouped_gemm_mma( + const __half* __restrict__ A, + const __half* __restrict__ W_base, + const int* __restrict__ tok0_arr, + const int* __restrict__ eid_arr, + const int* __restrict__ gend_arr, + __half* __restrict__ out, + int N, int K) +{ + const int BM=64, BN=128, BK=64, NS=16, NKS=4, U=BK/8; + int bx = blockIdx.x, by = blockIdx.y; + int tok0 = tok0_arr[bx], gend = gend_arr[bx], n0 = by * BN; + const __half* W = W_base + (long)eid_arr[bx] * N * K; + int tid = threadIdx.x, warp = tid >> 5, lane = tid & 31, gid = lane >> 2, tid4 = lane & 3; + int wrow = warp * 16; + + __shared__ __half As[2][BM][BK]; + __shared__ __half Ws[2][BN][BK]; + float oc[NS][4]; + for (int s=0;s Result { + let n_active = expert_ids.len(); + let mt = g_starts.last().copied().unwrap_or(0); + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if n_active == 0 || mt == 0 { return Ok(out); } + let group_rows: Vec = (0..n_active).map(|g| (g_starts[g + 1] - g_starts[g]) as i32).collect(); + let eids: Vec = expert_ids.iter().map(|&e| e as i32).collect(); + dev.moe_grouped_cutlass(a.buffer.as_ref(), w_base.buffer.as_ref(), out.buffer.as_ref(), &group_rows, &eids, n, k)?; + Ok(out) +} + +/// Grouped MoE GEMM (mma.sync register-O) — `out[t,n] = Σ_k A[t,k]·W[eid][n,k]` +/// for sorted tokens. `a` is `[mt,K]` f16; `w_base` is the contiguous f16 expert +/// weight slab `[n_exp,N,K]`; `g_starts[g]..g_starts[g+1]` is group g's token +/// range; `expert_ids[g]` selects the weight slab. ONE launch, all experts' +/// tiles concurrent (fills the GPU), reading resident f16 weights directly (no +/// scratch). Returns `out[mt,N]` f16. K%16==0, N%8==0. +#[allow(clippy::too_many_arguments)] +pub fn moe_grouped_gemm_mma( + dev: &dyn Device, + a: &Tensor, + w_base: &Tensor, + g_starts: &[usize], + expert_ids: &[usize], + n: usize, + k: usize, +) -> Result { + let n_active = expert_ids.len(); + let mt = g_starts.last().copied().unwrap_or(0); + if k % 64 != 0 { return Err(Error::Msg(format!("moe_grouped_gemm_mma: K {k} %64!=0"))); } + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if n_active == 0 || mt == 0 { return Ok(out); } + // Build per-m-tile descriptors (BM=16 token rows each). + let (mut tok0, mut eid, mut gend) = (Vec::new(), Vec::new(), Vec::new()); + for g in 0..n_active { + let (s, e) = (g_starts[g], g_starts[g + 1]); + let mut t = s; + while t < e { tok0.push(t as i32); eid.push(expert_ids[g] as i32); gend.push(e as i32); t += 64; } + } + let n_mtiles = tok0.len(); + let up_i32 = |v: &[i32]| -> Result { + let bytes: Vec = v.iter().flat_map(|x| x.to_le_bytes()).collect(); + Ok(Tensor::new(dev.upload(&bytes).map_err(|e| Error::Msg(format!("{e:?}")))?, vec![v.len()], DType::U32)) + }; + let (t0d, eidd, ged) = (up_i32(&tok0)?, up_i32(&eid)?, up_i32(&gend)?); + let i = |x: i32| x.to_le_bytes().to_vec(); + let grid = [n_mtiles as u32, (n as u32).div_ceil(128), 1]; + dev.dispatch_raw_cuda( + MOE_GROUPED_GEMM_MMA_SRC, "moe_grouped_gemm_mma.cu", "moe_grouped_gemm_mma", + &[(a.buffer.as_ref(), a.offset), (w_base.buffer.as_ref(), w_base.offset), + (t0d.buffer.as_ref(), 0), (eidd.buffer.as_ref(), 0), (ged.buffer.as_ref(), 0), + (out.buffer.as_ref(), 0)], + &[i(n as i32), i(k as i32)], + grid, [128, 1, 1], 0, false)?; + Ok(out) +} + +// Q4-NATIVE grouped MoE GEMM (Marlin-style). Like moe_grouped_gemm_mma but reads +// the model's SIGNED Q4 weights directly (NO f16 slab): cp.async packs int4 into +// shared u32 staging + f16 scales, register-dequants per b-fragment in the mma +// m16n8k16 loop. dqn = sign_extend((nib^8)-8) * f16scale (matches quantize_q4). +// Qs [n_exp*N · K/32 · 4] u32, Sc [n_exp*N · K/32] f16 (= moe_up_all/down_all). +const MOE_Q4_GROUPED_MMA_SRC: &str = r#" +#include +__device__ __forceinline__ unsigned pk2(__half a, __half b){ + return (unsigned)__half_as_ushort(a) | ((unsigned)__half_as_ushort(b) << 16); +} +__device__ __forceinline__ __half dqn(unsigned w, int nib, __half sc){ + unsigned short t = (unsigned short)((((w>>(nib*4))&0xf) ^ 0x8) | 0x6400u); + return __hmul(__hsub(__ushort_as_half(t), __float2half(1032.f)), sc); +} +#define CPA(SD,SP) asm volatile("cp.async.cg.shared.global [%0],[%1],16,16;\n" :: "r"(SD), "l"(SP)) +// BN=128 (NS=16): each block does 128 N-cols → the loaded A tile is reused over +// 2x the weights, halving redundant A reads (the bottleneck). Weight/scale loads +// CLAMP rows at n0+r>=N to the expert's row 0 (no OOB; garbage masked on store). +extern "C" __global__ void moe_q4_grouped_mma( + const __half* __restrict__ A, + const unsigned* __restrict__ Qs, + const __half* __restrict__ Sc, + const int* __restrict__ tok0_arr, const int* __restrict__ eid_arr, const int* __restrict__ gend_arr, + __half* __restrict__ Out, + int N, int K) +{ + const int BM=64,BN=128,BK=64,NS=16,NKS=4,GS=32,WPR=BK/8,GPR=BK/GS; + int bx=blockIdx.x, by=blockIdx.y; + int tok0=tok0_arr[bx], gend=gend_arr[bx], eid=eid_arr[bx], n0=by*BN; + int tid=threadIdx.x,warp=tid>>5,lane=tid&31,gid=lane>>2,tid4=lane&3,wrow=warp*16; + __shared__ __half As[2][BM][BK]; + __shared__ unsigned Wqs[2][BN][WPR]; + __shared__ __half Scs[2][BN][GPR]; + float oc[NS][4]; for(int s=0;s> 3) & 1) * 8; + int acol = (lane >> 4) * 8; + unsigned aaddr = (unsigned)__cvta_generic_to_shared(&As[buf][wrow+arow][ko+acol]); + unsigned a0,a1,a2,a3; + asm volatile("ldmatrix.sync.aligned.m8n8.x4.shared.b16 {%0,%1,%2,%3}, [%4];" + : "=r"(a0),"=r"(a1),"=r"(a2),"=r"(a3) : "r"(aaddr)); + #pragma unroll + for(int s=0;s Result { + let n_active = expert_ids.len(); + let mt = g_starts.last().copied().unwrap_or(0); + if k % 64 != 0 { return Err(Error::Msg(format!("moe_q4_grouped_mma: K {k} %64!=0"))); } + if n % 64 != 0 { return Err(Error::Msg(format!("moe_q4_grouped_mma: N {n} %64!=0"))); } + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if n_active == 0 || mt == 0 { return Ok(out); } + // Per-m-tile descriptors (BM=64 token rows each, never crossing a group). + let (mut tok0, mut eid, mut gend) = (Vec::new(), Vec::new(), Vec::new()); + for g in 0..n_active { + let (s, e) = (g_starts[g], g_starts[g + 1]); + let mut t = s; + while t < e { tok0.push(t as i32); eid.push(expert_ids[g] as i32); gend.push(e as i32); t += 64; } + } + let n_mtiles = tok0.len(); + let up_i32 = |v: &[i32]| -> Result { + let bytes: Vec = v.iter().flat_map(|x| x.to_le_bytes()).collect(); + Ok(Tensor::new(dev.upload(&bytes).map_err(|e| Error::Msg(format!("{e:?}")))?, vec![v.len()], DType::U32)) + }; + let (t0d, eidd, ged) = (up_i32(&tok0)?, up_i32(&eid)?, up_i32(&gend)?); + let i = |x: i32| x.to_le_bytes().to_vec(); + let grid = [n_mtiles as u32, (n as u32).div_ceil(128), 1]; + dev.dispatch_raw_cuda( + MOE_Q4_GROUPED_MMA_SRC, "moe_q4_grouped_mma.cu", "moe_q4_grouped_mma", + &[(a.buffer.as_ref(), a.offset), (qs.buffer.as_ref(), qs.offset), (sc.buffer.as_ref(), sc.offset), + (t0d.buffer.as_ref(), 0), (eidd.buffer.as_ref(), 0), (ged.buffer.as_ref(), 0), + (out.buffer.as_ref(), 0)], + &[i(n as i32), i(k as i32)], + grid, [128, 1, 1], 0, false)?; + Ok(out) +} + +// Build fixed-size Q4 grouped-GEMM tile descriptors ON DEVICE from the routing +// `offsets[n_exp+1]` (no host sync) → graph-safe. One thread walks the experts, +// emitting one descriptor per BM=64-row tile; padding tiles get gend=0 (the GEMM +// early-exits on them). maxt = mt/BM + n_exp is a per-S constant → fixed launch. +const MOE_BUILD_TILES_SRC: &str = r#" +extern "C" __global__ void moe_build_tiles( + const unsigned* __restrict__ offsets, int* __restrict__ tok0, + int* __restrict__ eid, int* __restrict__ gend, int n_exp, int BM, int maxt) +{ + if (blockIdx.x || threadIdx.x) return; + int t = 0; + for (int e = 0; e < n_exp; e++) { + unsigned s = offsets[e], en = offsets[e+1]; + for (unsigned x = s; x < en; x += (unsigned)BM) { + if (t < maxt) { tok0[t]=(int)x; eid[t]=e; gend[t]=(int)en; t++; } + } + } + for (; t < maxt; t++) { tok0[t]=0; eid[t]=0; gend[t]=0; } +} +"#; + +/// Fully-on-device Q4 grouped MoE GEMM: builds tile descriptors on-device from +/// `offsets` (device `[n_exp+1]` u32) with a FIXED launch (grid=[maxt,N/64], +/// maxt = mt/64 + n_exp) → NO host sync, graph-capturable. Returns `out[mt,N]` f16. +#[allow(clippy::too_many_arguments)] +pub fn moe_q4_grouped_mma_dev( + dev: &dyn Device, + a: &Tensor, + qs: &Tensor, + sc: &Tensor, + offsets: &Tensor, + n_exp: usize, + mt: usize, + n: usize, + k: usize, +) -> Result { + if k % 64 != 0 { return Err(Error::Msg(format!("moe_q4_grouped_mma_dev: K {k} %64!=0"))); } + if n % 64 != 0 { return Err(Error::Msg(format!("moe_q4_grouped_mma_dev: N {n} %64!=0"))); } + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if mt == 0 { return Ok(out); } + let maxt = mt.div_ceil(64) + n_exp; + let t0d = Tensor::empty(dev, vec![maxt], DType::U32)?; + let eidd = Tensor::empty(dev, vec![maxt], DType::U32)?; + let ged = Tensor::empty(dev, vec![maxt], DType::U32)?; + let i = |x: i32| x.to_le_bytes().to_vec(); + dev.dispatch_raw_cuda( + MOE_BUILD_TILES_SRC, "moe_build_tiles.cu", "moe_build_tiles", + &[(offsets.buffer.as_ref(), offsets.offset), (t0d.buffer.as_ref(), 0), + (eidd.buffer.as_ref(), 0), (ged.buffer.as_ref(), 0)], + &[i(n_exp as i32), i(64), i(maxt as i32)], + [1, 1, 1], [1, 1, 1], 0, false)?; + let grid = [maxt as u32, (n as u32).div_ceil(128), 1]; + dev.dispatch_raw_cuda( + MOE_Q4_GROUPED_MMA_SRC, "moe_q4_grouped_mma.cu", "moe_q4_grouped_mma", + &[(a.buffer.as_ref(), a.offset), (qs.buffer.as_ref(), qs.offset), (sc.buffer.as_ref(), sc.offset), + (t0d.buffer.as_ref(), 0), (eidd.buffer.as_ref(), 0), (ged.buffer.as_ref(), 0), + (out.buffer.as_ref(), 0)], + &[i(n as i32), i(k as i32)], + grid, [128, 1, 1], 0, false)?; + Ok(out) +} + +/// Transpose+cast+scale Q `[n_query, n_q_heads, hd]` (caller dtype) into the +/// head-major fp16 `[n_q_heads, n_query, hd]` layout the batched GEMM wants, with +/// `scale` folded in. `IN_F32` selects f32 vs f16 input. +const SDPA_TC_QPREP_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_tc_qprep( + const void* __restrict__ q_in, // [Sq, nq, hd], dtype = f32 (in_f32=1) or f16 + __half* __restrict__ q_out, // [nq, Sq, hd] f16, scaled + int sq, int nq, int hd, float scale, int in_f32) +{ + long i = (long)blockIdx.x * blockDim.x + threadIdx.x; + long total = (long)sq * nq * hd; + if (i >= total) return; + int d = (int)(i % hd); + long t = i / hd; + int h = (int)(t % nq); + int r = (int)(t / nq); // query row + float val; + if (in_f32) val = ((const float*)q_in)[i]; + else val = __half2float(((const __half*)q_in)[i]); + long o = ((long)h * sq + r) * hd + d; + q_out[o] = __float2half(val * scale); +} +"#; + +/// Cast K `[nkv, kv_stride, hd]` → fp16 `[nkv, n_kv, hd]` (drops cache padding). +const SDPA_TC_KPREP_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_tc_kprep( + const void* __restrict__ k_in, // [nkv, kv_stride, hd] + __half* __restrict__ k_out, // [nkv, n_kv, hd] f16 + int nkv, int kv_stride, int n_kv, int hd, int in_f32) +{ + long i = (long)blockIdx.x * blockDim.x + threadIdx.x; + long total = (long)nkv * n_kv * hd; + if (i >= total) return; + int d = (int)(i % hd); + long t = i / hd; + int p = (int)(t % n_kv); // kv position + int kh = (int)(t / n_kv); // kv head + long si = ((long)kh * kv_stride + p) * hd + d; + float val = in_f32 ? ((const float*)k_in)[si] + : __half2float(((const __half*)k_in)[si]); + k_out[i] = __float2half(val); +} +"#; + +/// Cast+transpose ONE KV block of V `[nkv, kv_stride, hd]` → fp16 +/// `[nkv, hd, blk]` (V^Tᵀ per head, block-contiguous so the P·V GEMM's leading +/// dim equals `blk = k`). Reads source rows `[kb0, kb0+blk)`. +const SDPA_TC_VPREP_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_tc_vprep( + const void* __restrict__ v_in, // [nkv, kv_stride, hd] + __half* __restrict__ v_out, // [nkv, hd, blk] f16 + int nkv, int kv_stride, int hd, int kb0, int blk, int in_f32) +{ + long i = (long)blockIdx.x * blockDim.x + threadIdx.x; + long total = (long)nkv * blk * hd; + if (i >= total) return; + int d = (int)(i % hd); + long t = i / hd; + int j = (int)(t % blk); // position within block + int kh = (int)(t / blk); // kv head + long si = ((long)kh * kv_stride + (kb0 + j)) * hd + d; + float val = in_f32 ? ((const float*)v_in)[si] + : __half2float(((const __half*)v_in)[si]); + long oi = ((long)kh * hd + d) * blk + j; // [kh][d][j], leading dim = blk + v_out[oi] = __float2half(val); +} +"#; + +/// Online-softmax exp + causal mask for one KV block. Reads the (already +/// `scale`-folded) logits `S_blk[nq, Sq, blk]`, writes the unnormalised weights +/// `P_blk[nq, Sq, blk]` (fp16) plus the per-(head,row) block max `bm[nq,Sq]` and +/// block sum-exp `bl[nq,Sq]`. Causal: query row `r` (absolute pos `base_kv+r`) +/// attends absolute KV pos `kb0 + j` iff `kb0 + j <= base_kv + r`. +/// One thread block per (head,row); blockDim.x threads stride over `blk`. +const SDPA_TC_SOFTMAX_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_tc_softmax( + const __half* __restrict__ s_blk, // [nq, Sq, blk] logits (scaled) + __half* __restrict__ p_blk, // [nq, Sq, blk] weights out + float* __restrict__ bm, // [nq, Sq] block max + float* __restrict__ bl, // [nq, Sq] block sum-exp + int nq, int sq, int blk, int kb0, int base_kv) +{ + long row = (long)blockIdx.x; // global (head*Sq + r) + if (row >= (long)nq * sq) return; + int r = (int)(row % sq); + int max_kv = base_kv + r; // inclusive absolute KV position + const __half* s = s_blk + row * blk; + __half* p = p_blk + row * blk; + int tid = threadIdx.x; + int nt = blockDim.x; + // pass 1: row max over valid columns + float lm = -3.4e38f; + for (int j = tid; j < blk; j += nt) { + int abspos = kb0 + j; + float v = (abspos <= max_kv) ? __half2float(s[j]) : -3.4e38f; + if (v > lm) lm = v; + } + __shared__ float red[256]; + red[tid] = lm; __syncthreads(); + for (int off = nt >> 1; off > 0; off >>= 1) { + if (tid < off) { float o = red[tid+off]; if (o > red[tid]) red[tid] = o; } + __syncthreads(); + } + float m = red[0]; + __syncthreads(); + // pass 2: exp + sum + float ls = 0.0f; + for (int j = tid; j < blk; j += nt) { + int abspos = kb0 + j; + float w; + if (abspos <= max_kv && m > -3.0e38f) { w = __expf(__half2float(s[j]) - m); } + else { w = 0.0f; } + p[j] = __float2half(w); + ls += w; + } + red[tid] = ls; __syncthreads(); + for (int off = nt >> 1; off > 0; off >>= 1) { + if (tid < off) red[tid] += red[tid+off]; + __syncthreads(); + } + if (tid == 0) { + bm[row] = (m > -3.0e38f) ? m : -3.4e38f; + bl[row] = red[0]; + } +} +"#; + +/// Merge one block's partial output into the running FlashAttention state. +/// `o_blk[nq,Sq,hd]` = P_blk·V_blk (unnormalised). Updates running max `m_run`, +/// running sum `l_run`, and running (unnormalised) output `o_run` with the +/// standard online-softmax rescale. One block per (head,row); threads over hd. +/// Varlen (packed multi-sequence) softmax + causal mask. Identical to +/// `sdpa_tc_softmax`, but bounds each query row `r` BELOW by its packed +/// segment's start `seg_lo[r]` (absolute KV position) in addition to the +/// causal upper bound. A query thus attends only WITHIN its own packed +/// sequence: `seg_lo[r] <= abspos <= base_kv + r`. With `seg_lo[r] == 0` for +/// all rows this is bit-identical to the dense kernel. +const SDPA_TC_SOFTMAX_VARLEN_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_tc_softmax_varlen( + const __half* __restrict__ s_blk, // [nq, Sq, blk] logits (scaled) + __half* __restrict__ p_blk, // [nq, Sq, blk] weights out + float* __restrict__ bm, // [nq, Sq] block max + float* __restrict__ bl, // [nq, Sq] block sum-exp + const int* __restrict__ seg_lo, // [Sq] per-row segment start (abs KV pos) + int nq, int sq, int blk, int kb0, int base_kv) +{ + long row = (long)blockIdx.x; // global (head*Sq + r) + if (row >= (long)nq * sq) return; + int r = (int)(row % sq); + int max_kv = base_kv + r; // inclusive causal upper bound + int lo_kv = seg_lo[r]; // inclusive segment lower bound + const __half* s = s_blk + row * blk; + __half* p = p_blk + row * blk; + int tid = threadIdx.x; + int nt = blockDim.x; + // pass 1: row max over valid columns + float lm = -3.4e38f; + for (int j = tid; j < blk; j += nt) { + int abspos = kb0 + j; + float v = (abspos >= lo_kv && abspos <= max_kv) ? __half2float(s[j]) : -3.4e38f; + if (v > lm) lm = v; + } + __shared__ float red[256]; + red[tid] = lm; __syncthreads(); + for (int off = nt >> 1; off > 0; off >>= 1) { + if (tid < off) { float o = red[tid+off]; if (o > red[tid]) red[tid] = o; } + __syncthreads(); + } + float m = red[0]; + __syncthreads(); + // pass 2: exp + sum + float ls = 0.0f; + for (int j = tid; j < blk; j += nt) { + int abspos = kb0 + j; + float w; + if (abspos >= lo_kv && abspos <= max_kv && m > -3.0e38f) { w = __expf(__half2float(s[j]) - m); } + else { w = 0.0f; } + p[j] = __float2half(w); + ls += w; + } + red[tid] = ls; __syncthreads(); + for (int off = nt >> 1; off > 0; off >>= 1) { + if (tid < off) red[tid] += red[tid+off]; + __syncthreads(); + } + if (tid == 0) { + bm[row] = (m > -3.0e38f) ? m : -3.4e38f; + bl[row] = red[0]; + } +} +"#; + +const SDPA_TC_MERGE_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_tc_merge( + const __half* __restrict__ o_blk, // [nq, Sq, hd] this block's P·V + const float* __restrict__ bm, // [nq, Sq] block max + const float* __restrict__ bl, // [nq, Sq] block sum + float* __restrict__ o_run, // [nq, Sq, hd] running output (f32) + float* __restrict__ m_run, // [nq, Sq] + float* __restrict__ l_run, // [nq, Sq] + int nq, int sq, int hd) +{ + long row = (long)blockIdx.x; + if (row >= (long)nq * sq) return; + float m_old = m_run[row]; + float l_old = l_run[row]; + float m_b = bm[row]; + float l_b = bl[row]; + if (l_b <= 0.0f) return; // block fully masked → nothing to add + float m_new = (m_b > m_old) ? m_b : m_old; + float a = (m_old > -3.0e38f) ? __expf(m_old - m_new) : 0.0f; // rescale old + float b = __expf(m_b - m_new); // scale block + const __half* ob = o_blk + row * hd; + float* orun = o_run + row * hd; + for (int d = threadIdx.x; d < hd; d += blockDim.x) { + orun[d] = orun[d] * a + __half2float(ob[d]) * b; + } + if (threadIdx.x == 0) { + m_run[row] = m_new; + l_run[row] = l_old * a + l_b * b; + } +} +"#; + +/// Finalise: normalise the running output by `l_run` and write it back in the +/// caller's `[n_query, n_q_heads, hd]` row-major layout (transpose from the +/// head-major `[nq, Sq, hd]` accumulator). `OUT_F32` selects output dtype. +const SDPA_TC_FINALIZE_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_tc_finalize( + const float* __restrict__ o_run, // [nq, Sq, hd] + const float* __restrict__ l_run, // [nq, Sq] + void* __restrict__ out, // [Sq, nq, hd] caller dtype + int nq, int sq, int hd, int out_f32) +{ + long i = (long)blockIdx.x * blockDim.x + threadIdx.x; + long total = (long)nq * sq * hd; + if (i >= total) return; + int d = (int)(i % hd); + long t = i / hd; + int r = (int)(t % sq); + int h = (int)(t / sq); + long row = (long)h * sq + r; + float l = l_run[row]; + float val = (l > 0.0f) ? (o_run[i] / l) : 0.0f; + long oi = ((long)r * nq + h) * hd + d; // [r][h][d] row-major + if (out_f32) ((float*)out)[oi] = val; + else ((__half*)out)[oi] = __float2half(val); +} +"#; + +/// FUSED single-kernel causal FlashAttention (no HBM score/prob materialization, +/// no qprep/kprep/vprep passes). One warp per (head, query row): the warp holds +/// `Q[r]` in registers, streams K/V rows from HBM, and runs the online softmax + +/// `O` accumulation entirely in registers — scores never touch global memory. +/// Causal is exploited by looping `p` only to `base_kv + r` (the masked upper +/// triangle is never computed, ~2× fewer FLOPs than the materialized path). +/// hd must be a multiple of 32 (warp lanes own `hd/32` dims each). Q/K/V F32 or +/// F16; output matches `q.dtype`. This is v1 (scalar FMA, no tensor cores) — it +/// wins by removing the 6-dispatch HBM round-trip + the causal waste; the wmma +/// upgrade is the follow-up. NEMOTRON_FLASH_FUSED=1 in the model. +const SDPA_FLASH_FUSED_SRC: &str = r#" +#include +extern "C" __global__ void sdpa_flash_fused( + const void* __restrict__ q_in, // [Sq, nq, hd] + const void* __restrict__ k_in, // [nkv, kv_stride, hd] + const void* __restrict__ v_in, // [nkv, kv_stride, hd] + void* __restrict__ out, // [Sq, nq, hd] + int sq, int nq, int nkv, int hd, int kv_stride, + int base_kv, float scale, int in_f32, int out_f32) +{ + int warp = threadIdx.x >> 5; + int lane = threadIdx.x & 31; + int rpb = blockDim.x >> 5; // query rows per block (= warps) + int r = blockIdx.y * rpb + warp; // query row + int h = blockIdx.x; // q head + if (r >= sq) return; + int hpg = nq / nkv; + int kh = h / hpg; // kv head + int maxp = base_kv + r; // inclusive causal bound + int dpl = hd >> 5; // dims per lane (hd=128 -> 4) + + const float* qf = (const float*)q_in; const __half* qhp = (const __half*)q_in; + const float* kf = (const float*)k_in; const __half* khp = (const __half*)k_in; + const float* vf = (const float*)v_in; const __half* vhp = (const __half*)v_in; + + float qreg[8], oreg[8]; + long qbase = ((long)r * nq + h) * (long)hd; + #pragma unroll + for (int t = 0; t < dpl; t++) { + int d = lane + (t << 5); + float qv = in_f32 ? qf[qbase + d] : __half2float(qhp[qbase + d]); + qreg[t] = qv * scale; + oreg[t] = 0.0f; + } + float m = -3.4e38f, l = 0.0f; + + for (int p = 0; p <= maxp; p++) { + long kbase = ((long)kh * kv_stride + p) * (long)hd; + float part = 0.0f; + #pragma unroll + for (int t = 0; t < dpl; t++) { + int d = lane + (t << 5); + float kv = in_f32 ? kf[kbase + d] : __half2float(khp[kbase + d]); + part += qreg[t] * kv; + } + // full-warp reduce (sum) then broadcast the dot to every lane + #pragma unroll + for (int off = 16; off > 0; off >>= 1) + part += __shfl_down_sync(0xffffffffu, part, off); + float s = __shfl_sync(0xffffffffu, part, 0); + // online softmax (identical math on every lane → consistent m/l) + float m_new = fmaxf(m, s); + float corr = __expf(m - m_new); // first iter: exp(-inf)=0 + float pij = __expf(s - m_new); + l = l * corr + pij; + #pragma unroll + for (int t = 0; t < dpl; t++) { + int d = lane + (t << 5); + float vv = in_f32 ? vf[kbase + d] : __half2float(vhp[kbase + d]); + oreg[t] = oreg[t] * corr + pij * vv; + } + m = m_new; + } + + long obase = ((long)r * nq + h) * (long)hd; + float inv = (l > 0.0f) ? (1.0f / l) : 0.0f; + #pragma unroll + for (int t = 0; t < dpl; t++) { + int d = lane + (t << 5); + float ov = oreg[t] * inv; + if (out_f32) ((float*)out)[obase + d] = ov; + else ((__half*)out)[obase + d] = __float2half(ov); + } +} +"#; + +/// FUSED tensor-core (wmma) causal FlashAttention. v2 of [`SDPA_FLASH_FUSED_SRC`]: +/// 1 warp / 16-query-row tile, hd=128. QKᵀ and P·V run on tensor cores (wmma +/// 16×16×16 f16→f32); scores round-trip through SHARED (never HBM) so the online +/// softmax + causal mask are plain per-row shared reductions (no fragment-layout +/// reduction). K is transposed on load (`Kt[d][j]`) so QKᵀ = Q·Kt is a direct +/// A·B. O accumulator stays in shared f32, rescaled per-row each KV tile. Causal +/// tiles beyond the query block are skipped. f16 in/out; ~30KB static shared. +const SDPA_FLASH_WMMA_SRC: &str = r#" +#include +#include +using namespace nvcuda; +extern "C" __global__ void sdpa_flash_wmma( + const __half* __restrict__ q_in, // [Sq, nq, 128] (already scaled? no — scaled here) + const __half* __restrict__ k_in, // [nkv, kv_stride, 128] + const __half* __restrict__ v_in, // [nkv, kv_stride, 128] + __half* __restrict__ out, // [Sq, nq, 128] + int sq, int nq, int nkv, int kv_stride, int base_kv, float scale) +{ + const int BR=16, BC=16, NC=8; // 16 rows, 16 keys/tile, hd=128 -> 8 chunks + int h = blockIdx.x; + int R0 = blockIdx.y * BR; + int hpg = nq / nkv, kh = h / hpg; + int lane = threadIdx.x; // 0..31 (one warp) + + __shared__ __half Qs[BR][128]; + __shared__ __half Kt[128][BC]; // transposed K: [d][j] + __shared__ __half Vs[BC][128]; + __shared__ float Os[BR][128]; + __shared__ float Ss[BR][BC]; + __shared__ __half Ps[BR][BC]; + __shared__ float scratch[BR][128]; + __shared__ float m_sh[BR], l_sh[BR]; + + for (int e = lane; e < BR*128; e += 32) { + int i = e >> 7, d = e & 127, gr = R0 + i; + float qv = (gr < sq) ? __half2float(q_in[((long)gr*nq + h)*128 + d]) : 0.0f; + Qs[i][d] = __float2half(qv * scale); + Os[i][d] = 0.0f; + } + for (int i = lane; i < BR; i += 32) { m_sh[i] = -3.4e38f; l_sh[i] = 0.0f; } + __syncwarp(); + + int maxp_block = base_kv + R0 + BR - 1; + int last = base_kv + sq - 1; + if (maxp_block > last) maxp_block = last; + + wmma::fragment s_acc; + for (int kj0 = 0; kj0 <= maxp_block; kj0 += BC) { + for (int e = lane; e < BC*128; e += 32) { + int j = e >> 7, d = e & 127, p = kj0 + j; + int ok = (p <= last); + Kt[d][j] = ok ? k_in[((long)kh*kv_stride + p)*128 + d] : __float2half(0.0f); + Vs[j][d] = ok ? v_in[((long)kh*kv_stride + p)*128 + d] : __float2half(0.0f); + } + __syncwarp(); + + wmma::fill_fragment(s_acc, 0.0f); + for (int c = 0; c < NC; c++) { + wmma::fragment a; + wmma::fragment b; + wmma::load_matrix_sync(a, &Qs[0][c*16], 128); + wmma::load_matrix_sync(b, &Kt[c*16][0], BC); + wmma::mma_sync(s_acc, a, b, s_acc); + } + wmma::store_matrix_sync(&Ss[0][0], s_acc, BC, wmma::mem_row_major); + __syncwarp(); + + if (lane < BR) { + int i = lane, gr = R0 + i; + if (gr < sq) { + int maxp = base_kv + R0 + i; + float rmax = -3.4e38f; + for (int j = 0; j < BC; j++) { + float sv = (kj0 + j <= maxp) ? Ss[i][j] : -3.4e38f; + rmax = fmaxf(rmax, sv); + } + float m_old = m_sh[i]; + float m_new = fmaxf(m_old, rmax); + float corr = (m_old > -3.0e38f) ? __expf(m_old - m_new) : 0.0f; + float rsum = 0.0f; + for (int j = 0; j < BC; j++) { + float w = (kj0 + j <= maxp && m_new > -3.0e38f) ? __expf(Ss[i][j] - m_new) : 0.0f; + Ps[i][j] = __float2half(w); + rsum += w; + } + for (int d = 0; d < 128; d++) Os[i][d] *= corr; + m_sh[i] = m_new; + l_sh[i] = l_sh[i]*corr + rsum; + } else { + for (int j = 0; j < BC; j++) Ps[i][j] = __float2half(0.0f); + } + } + __syncwarp(); + + for (int dc = 0; dc < NC; dc++) { + wmma::fragment pa; + wmma::fragment vb; + wmma::fragment pv; + wmma::fill_fragment(pv, 0.0f); + wmma::load_matrix_sync(pa, &Ps[0][0], BC); + wmma::load_matrix_sync(vb, &Vs[0][dc*16], 128); + wmma::mma_sync(pv, pa, vb, pv); + wmma::store_matrix_sync(&scratch[0][dc*16], pv, 128, wmma::mem_row_major); + } + __syncwarp(); + for (int e = lane; e < BR*128; e += 32) { int i = e >> 7, d = e & 127; Os[i][d] += scratch[i][d]; } + __syncwarp(); + } + + if (lane < BR) { + int i = lane, gr = R0 + i; + if (gr < sq) { + float l = l_sh[i], inv = (l > 0.0f) ? 1.0f/l : 0.0f; + for (int d = 0; d < 128; d++) out[((long)gr*nq + h)*128 + d] = __float2half(Os[i][d]*inv); + } + } +} +"#; + +/// FlashAttention-2 with register-resident O (mma.sync m16n8k16). v3 of the fused +/// path: O lives in registers (acc fragments), not shared — freeing ~16KB so the +/// 1-warp/16-row block hits high occupancy (v2's 30KB shared capped it at ~11%). +/// QKᵀ + P·V via `mma.sync` (manual fragment packing, documented layout); the +/// causal mask + online softmax round-trip the small S/P tiles through shared +/// (cheap), and O is rescaled per-row IN registers each KV tile (lane t holds +/// rows {gid, gid+8} → multiply by corr[gid], corr[gid+8]). f16 in/out, hd=128, +/// ~3KB shared. NEMOTRON_FLASH_MMA=1. +const SDPA_FLASH_MMA_SRC: &str = r#" +#include +__device__ __forceinline__ unsigned pk(__half a, __half b){ + return (unsigned)__half_as_ushort(a) | ((unsigned)__half_as_ushort(b) << 16); +} +extern "C" __global__ void sdpa_flash_mma( + const void* __restrict__ q_in, const __half* __restrict__ k_in, + const __half* __restrict__ v_in, void* __restrict__ out, + int sq, int nq, int nkv, int kv_stride, int base_kv, float scale, int in_f32, int out_f32) +{ + const int BR=16, BC=16, HD=128, NC=8, ND=16; // NC=HD/16 (k-chunks), ND=HD/8 (O d-tiles) + int h = blockIdx.x, R0 = blockIdx.y * BR; + int hpg = nq / nkv, kh = h / hpg; + int lane = threadIdx.x, gid = lane >> 2, tid4 = lane & 3; + const float* qf=(const float*)q_in; const __half* qh=(const __half*)q_in; + // K/V are pre-cast to f16 by the wrapper (cheap, repeatedly-read in the KV loop). + + __shared__ __half Qs[BR][HD]; + __shared__ __half Ks[BC][HD]; + __shared__ __half Vs[BC][HD]; + __shared__ float Ss[BR][BC]; + __shared__ __half Ps[BR][BC]; + __shared__ float cv[BR], mv[BR], lv[BR]; + + for (int e = lane; e < BR*HD; e += 32) { int i=e>>7,d=e&127,gr=R0+i; long idx=((long)gr*nq+h)*HD+d; + float qv = (grlast) maxpb=last; + + for (int kj0=0; kj0<=maxpb; kj0+=BC) { + for (int e=lane;e>7,d=e&127,p=kj0+j,ok=(p<=last); long idx=((long)kh*kv_stride+p)*HD+d; + Ks[j][d]= ok? k_in[idx] : __float2half(0.f); + Vs[j][d]= ok? v_in[idx] : __float2half(0.f); } + __syncwarp(); + + // QKᵀ -> 2 S-tiles (n8: keys 0-7, 8-15), accumulate over 8 k-chunks + float sc[2][4] = {{0,0,0,0},{0,0,0,0}}; + for (int c=0;c shared (acc layout: c0,c1 row gid; c2,c3 row gid+8) + for (int s=0;s<2;s++){ + Ss[gid][s*8+2*tid4] = sc[s][0]; Ss[gid][s*8+2*tid4+1] = sc[s][1]; + Ss[gid+8][s*8+2*tid4] = sc[s][2]; Ss[gid+8][s*8+2*tid4+1] = sc[s][3]; + } + __syncwarp(); + + if (lane-3.0e38f)?__expf(mo-mn):0.f; float rs=0.f; + for (int j=0;j-3.0e38f)?__expf(Ss[i][j]-mn):0.f; Ps[i][j]=__float2half(w); rs+=w; } + cv[i]=cor; mv[i]=mn; lv[i]=lv[i]*cor+rs; + } else { cv[i]=1.f; for(int j=0;j0.f)?1.f/l0:0.f, inv8=(l8>0.f)?1.f/l8:0.f; + int r0=R0+gid, r8=R0+gid+8; + float* of=(float*)out; __half* oh=(__half*)out; + for (int t=0;t +extern "C" __global__ void sdpa_flash_mma_w4( + const void* __restrict__ q_in, const __half* __restrict__ k_in, + const __half* __restrict__ v_in, void* __restrict__ out, + int sq, int nq, int nkv, int kv_stride, int base_kv, float scale, int in_f32, int out_f32) +{ + const int BR=16, BC=16, HD=128, NC=8, ND=16, NW=4; // 4 warps/block, 64 rows + int h = blockIdx.x; + int wid = threadIdx.x >> 5; + int R0 = blockIdx.y * (BR*NW) + wid * BR; + int hpg = nq / nkv, kh = h / hpg; + int lane = threadIdx.x & 31, gid = lane >> 2, tid4 = lane & 3; + const float* qf=(const float*)q_in; const __half* qh=(const __half*)q_in; + // K/V are pre-cast to f16 by the wrapper (cheap, repeatedly-read in the KV loop). + + __shared__ __half Qs_a[4][BR][HD]; + __shared__ __half Ks[BC][HD]; + __shared__ __half Vs[BC][HD]; + __shared__ float Ss_a[4][BR][BC]; + __shared__ __half Ps_a[4][BR][BC]; + __shared__ float cv_a[4][BR], mv_a[4][BR], lv_a[4][BR]; + __half (*Qs)[HD] = Qs_a[wid]; + float (*Ss)[BC] = Ss_a[wid]; + __half (*Ps)[BC] = Ps_a[wid]; + float *cv = cv_a[wid], *mv = mv_a[wid], *lv = lv_a[wid]; + + for (int e = lane; e < BR*HD; e += 32) { int i=e>>7,d=e&127,gr=R0+i; long idx=((long)gr*nq+h)*HD+d; + float qv = (grlast) maxpb=last; // this warp's causal max + int blk_r_hi = blockIdx.y * (BR*NW) + (BR*NW) - 1; + int maxpb_blk = base_kv + blk_r_hi; if (maxpb_blk>last) maxpb_blk=last; // block max + + for (int kj0=0; kj0<=maxpb_blk; kj0+=BC) { + __syncthreads(); + for (int e=threadIdx.x;e>7,d=e&127,p=kj0+j,ok=(p<=last); long idx=((long)kh*kv_stride+p)*HD+d; + Ks[j][d]= ok? k_in[idx] : __float2half(0.f); + Vs[j][d]= ok? v_in[idx] : __float2half(0.f); } + __syncthreads(); + if (kj0 > maxpb) continue; // beyond this warp's causal window (barriers above are block-uniform) + __syncwarp(); + + // QKᵀ -> 2 S-tiles (n8: keys 0-7, 8-15), accumulate over 8 k-chunks + float sc[2][4] = {{0,0,0,0},{0,0,0,0}}; + for (int c=0;c shared (acc layout: c0,c1 row gid; c2,c3 row gid+8) + for (int s=0;s<2;s++){ + Ss[gid][s*8+2*tid4] = sc[s][0]; Ss[gid][s*8+2*tid4+1] = sc[s][1]; + Ss[gid+8][s*8+2*tid4] = sc[s][2]; Ss[gid+8][s*8+2*tid4+1] = sc[s][3]; + } + __syncwarp(); + + if (lane-3.0e38f)?__expf(mo-mn):0.f; float rs=0.f; + for (int j=0;j-3.0e38f)?__expf(Ss[i][j]-mn):0.f; Ps[i][j]=__float2half(w); rs+=w; } + cv[i]=cor; mv[i]=mn; lv[i]=lv[i]*cor+rs; + } else { cv[i]=1.f; for(int j=0;j0.f)?1.f/l0:0.f, inv8=(l8>0.f)?1.f/l8:0.f; + int r0=R0+gid, r8=R0+gid+8; + float* of=(float*)out; __half* oh=(__half*)out; + for (int t=0;t> 5; + int R0 = blockIdx.y * (BR*NW) + wid * BR; + int part = blockIdx.z; + int kv0 = part * kv_chunk; + int hpg = nq / nkv, kh = h / hpg; + int lane = threadIdx.x & 31, gid = lane >> 2, tid4 = lane & 3; + const float* qf=(const float*)q_in; const __half* qh=(const __half*)q_in; + + __shared__ __half Qs_a[4][BR][HD]; + __shared__ __half Ks2[2][BC][HD]; // cp.async double-buffered KV stages + __shared__ __half Vs2[2][BC][HD]; + __shared__ float Ss_a[4][BR][BC]; + __shared__ __half Ps_a[4][BR][BC]; + __shared__ float cv_a[4][BR], mv_a[4][BR], lv_a[4][BR]; + __half (*Qs)[HD] = Qs_a[wid]; + float (*Ss)[BC] = Ss_a[wid]; + __half (*Ps)[BC] = Ps_a[wid]; + float *cv = cv_a[wid], *mv = mv_a[wid], *lv = lv_a[wid]; + + for (int e = lane; e < BR*HD; e += 32) { int i=e>>7,d=e&127,gr=R0+i; long idx=((long)gr*nq+h)*HD+d; + float qv = (grlast) maxpb=last; + int blk_r_hi = blockIdx.y * (BR*NW) + (BR*NW) - 1; + int maxpb_blk = base_kv + blk_r_hi; if (maxpb_blk>last) maxpb_blk=last; + int kv_hi = kv0 + kv_chunk - 1; if (kv_hi > maxpb_blk) kv_hi = maxpb_blk; + + // cp.async 16B chunk staging: BC*HD f16 = 4KB per matrix = 256 chunks; + // 128 threads x 2 chunks each. Tail tiles (p > last) fall back to a tiny + // zero-fill after the wait (rare: only the last tile of the sequence). + #define KV_ISSUE(kj_,st_) do{ for (int c2=threadIdx.x; c2<2*BC*HD/8; c2+=NW*32){ int which=c2>=(BC*HD/8); int e8=(c2%(BC*HD/8))*8; int j=e8>>7, d=e8&127, p=(kj_)+j; const __half* src = which? &v_in[((long)kh*kv_stride+p)*HD+d] : &k_in[((long)kh*kv_stride+p)*HD+d]; __half* dst = which? &Vs2[st_][j][d] : &Ks2[st_][j][d]; if (p<=last) { unsigned sa=(unsigned)__cvta_generic_to_shared(dst); asm volatile("cp.async.cg.shared.global [%0], [%1], 16;"::"r"(sa),"l"(src)); } } asm volatile("cp.async.commit_group;"); }while(0) + KV_ISSUE(kv0,0); + for (int kj0=kv0; kj0<=kv_hi; kj0+=BC) { + int st=((kj0-kv0)/BC)&1; + if (kj0+BC<=kv_hi) { + KV_ISSUE(kj0+BC,st^1); + asm volatile("cp.async.wait_group 1;"); + } else { + asm volatile("cp.async.wait_group 0;"); + } + __syncthreads(); + // zero-fill the (rare) beyond-last lanes of this stage + if (kj0+BC-1 > last) { + for (int e=threadIdx.x;e>7,d=e&127; + if (kj0+j>last){ Ks2[st][j][d]=__float2half(0.f); Vs2[st][j][d]=__float2half(0.f); } } + __syncthreads(); + } + __half (*Ks)[HD] = Ks2[st]; + __half (*Vs)[HD] = Vs2[st]; + if (kj0 > maxpb) continue; + __syncwarp(); + + float sc[2][4] = {{0,0,0,0},{0,0,0,0}}; + for (int c=0;c-3.0e38f)?__expf(mo-mn):0.f; float rs=0.f; + for (int j=0;j-3.0e38f)?__expf(Ss[i][j]-mn):0.f; Ps[i][j]=__float2half(w); rs+=w; } + cv[i]=cor; mv[i]=mn; lv[i]=lv[i]*cor+rs; + } else { cv[i]=1.f; for(int j=0;j0.f)) return (unsigned char)s; + if(a>=448.f) return (unsigned char)(s|0x7Eu); + int e; float fr=frexpf(a,&e); int E=e+6; + unsigned c; + if(E<1){ int m=(int)rintf(a*512.f); if(m>7)m=7; c=(unsigned)m; } + else { int m=(int)rintf((2.f*fr-1.f)*8.f); if(m>7){m=0;E+=1;} if(E>15){E=15;m=6;} c=(unsigned)((E<<3)|m); } + return (unsigned char)(s|c); +} +// One pass: f16 KV cache -> e4m3 (scaled). n = total elements. +extern "C" __global__ void kv_to_e4m3( + const __half* __restrict__ k_in, const __half* __restrict__ v_in, + unsigned char* __restrict__ k8, unsigned char* __restrict__ v8, + const float* __restrict__ scales, long n) +{ + long i=(long)blockIdx.x*blockDim.x+threadIdx.x; + long stride=(long)gridDim.x*blockDim.x; + float ki=scales[1], vi=scales[2]; + for(;i> 5; + int R0 = blockIdx.y * (BR*NW) + wid * BR; + int part = blockIdx.z; + int kv0 = part * kv_chunk; + int hpg = nq / nkv, kh = h / hpg; + int lane = threadIdx.x & 31, gid = lane >> 2, tid4 = lane & 3; + const float* qf=(const float*)q_in; const __half* qh=(const __half*)q_in; + float qi=scales[0], ki=scales[1], vi=scales[2]; + float qk_de = 1.f/(qi*ki); + float v_de = 1.f/vi; + + __shared__ unsigned char Qs_a[4][BR][HD]; + __shared__ unsigned char Ks[BC][HD]; + __shared__ unsigned char Vs[HD][BC]; // [d][j] for PV B frags + __shared__ float Ss_a[4][BR][BC]; + __shared__ unsigned char Ps_a[4][BR][BC]; + __shared__ float cv_a[4][BR], mv_a[4][BR], lv_a[4][BR]; + unsigned char (*Qs)[HD] = Qs_a[wid]; + float (*Ss)[BC] = Ss_a[wid]; + unsigned char (*Ps)[BC] = Ps_a[wid]; + float *cv = cv_a[wid], *mv = mv_a[wid], *lv = lv_a[wid]; + + for (int e = lane; e < BR*HD; e += 32) { int i=e>>7,d=e&127,gr=R0+i; long idx=((long)gr*nq+h)*HD+d; + float qv = (grlast) maxpb=last; + int blk_r_hi = blockIdx.y * (BR*NW) + (BR*NW) - 1; + int maxpb_blk = base_kv + blk_r_hi; if (maxpb_blk>last) maxpb_blk=last; + int kv_hi = kv0 + kv_chunk - 1; if (kv_hi > maxpb_blk) kv_hi = maxpb_blk; + + #define PK4(p0,p1,p2,p3) ((unsigned)(p0)|((unsigned)(p1)<<8)|((unsigned)(p2)<<16)|((unsigned)(p3)<<24)) + for (int kj0=kv0; kj0<=kv_hi; kj0+=BC) { + __syncthreads(); + for (int e=threadIdx.x;e>7,d=e&127,p=kj0+j,ok=(p<=last); long idx=((long)kh*kv_stride+p)*HD+d; + unsigned char kb = ok? k8[idx] : 0; + unsigned char vb = ok? v8[idx] : 0; + Ks[j][d]=kb; Vs[d][j]=vb; } + __syncthreads(); + if (kj0 > maxpb) continue; + __syncwarp(); + + // QK: 4 n8 key tiles (BC=32), 4 k32 chunks over HD + float sc[4][4] = {{0,0,0,0},{0,0,0,0},{0,0,0,0},{0,0,0,0}}; + for (int c=0;c-3.0e38f)?__expf(mo-mn):0.f; float rs=0.f; + for (int j=0;j-3.0e38f)?__expf(Ss[i][j]-mn):0.f; + Ps[i][j]=enc_e4m3a(w*448.f); rs+=w; } + cv[i]=cor; mv[i]=mn; lv[i]=lv[i]*cor+rs; + } else { cv[i]=1.f; for(int j=0;j=n) return; + long rh = e / HD; // r*nq + h + float mstar=-3.4e38f; + for (int p=0;p-3.0e38f)?__expf(m-mstar):0.f; + lsum += l*w; + osum += o_part[(long)p*sq*nq*HD+e]*w; + } + float r = (lsum>0.f)? osum/lsum : 0.f; + if (out_f32) ((float*)out)[e]=r; else ((__half*)out)[e]=__float2half(r); +} + +"#; + +/// FlashAttention-2, register-resident O via mma.sync (see [`SDPA_FLASH_MMA_SRC`]). +/// Same contract as [`sdpa_multi_tc`]; casts Q/K/V → f16, runs the single kernel, +/// returns `q.dtype`. head_dim must be 128. CUDA-only. +#[allow(clippy::too_many_arguments)] +pub fn sdpa_flash_mma( + dev: &dyn Device, + q: &Tensor, k: &Tensor, v: &Tensor, + head_dim: usize, n_q_heads: u32, base_kv: u32, n_query: u32, + kv_stride: u32, heads_per_group: u32, causal: bool, scale: f32, +) -> Result { + let hd = head_dim; + let nq = n_q_heads as usize; + let sq = n_query as usize; + let hpg = heads_per_group as usize; + let nkv = nq / hpg; + let base = base_kv as usize; + if hd != 128 { return Err(Error::Msg(format!("sdpa_flash_mma: head_dim {hd} != 128"))); } + if !causal { return Err(Error::Msg("sdpa_flash_mma: only causal wired".into())); } + let n_kv = base + sq; + if n_kv > kv_stride as usize { + return Err(Error::Msg(format!("sdpa_flash_mma: n_kv {n_kv} exceeds kv_stride {kv_stride}"))); + } + // Pre-cast K/V → f16 (cheap: [nkv,cap,hd]≈2M elems; they're re-read every KV + // tile so f16 halves that HBM traffic). Q is read once → pass directly (no big + // [Sq,nq,hd] cast). O written in native dtype (no output cast). + let in_f32 = match q.dtype { DType::F32 => 1i32, DType::F16 => 0, + other => return Err(Error::Msg(format!("sdpa_flash_mma: unsupported dtype {other:?}"))) }; + let out_f32 = if q.dtype == DType::F32 { 1i32 } else { 0 }; + let kf16 = match k.dtype { DType::F16 => k.clone(), _ => cast_f32_f16(dev, k)? }; + let vf16 = match v.dtype { DType::F16 => v.clone(), _ => cast_f32_f16(dev, v)? }; + let out = Tensor::empty(dev, vec![sq, nq, hd], q.dtype)?; + let i = |x: i32| x.to_le_bytes().to_vec(); + let f = |x: f32| x.to_le_bytes().to_vec(); + // Split-KV at depth (NEMOTRON_FLASH_SPLITKV_OFF=1 disables): each block's + // serial KV walk dominates deep-context prefill; partition the KV range + // across grid.z, emit unnormalized running-softmax partials, merge. The + // partial-softmax merge is the standard exact decomposition. + let kv_chunk_target = 8192usize; + let n_part = n_kv.div_ceil(kv_chunk_target).min(8); + // FP8 attention (NEMOTRON_FLASH_FP8=1): e4m3 QK and PV mma at 2x the f16 + // tensor-core rate — attention at depth is mma-compute-bound. Static + // conservative scales (16x: |x|<28 covers the post-softmax-scale Q and the + // K/V ranges with e4m3's 448 ceiling). Quant-regime lever. + if std::env::var("NEMOTRON_FLASH_FP8").is_ok() { + let np = n_part.max(1); + let kv_chunk = n_kv.div_ceil(np).div_ceil(32) * 32; + let scl: [f32; 3] = [16.0, 16.0, 16.0]; + let mut sb = Vec::with_capacity(12); + for v in scl { sb.extend_from_slice(&v.to_le_bytes()); } + let scales = Tensor::new(dev.upload(&sb)?, vec![3], DType::F32); + // pre-quantize the K/V cache to e4m3 ONCE per call (one elementwise + // pass; also halves the per-tile KV re-read traffic). + let kv_elems = nkv * kv_stride as usize * hd; + let k8 = Tensor::new(dev.alloc(kv_elems)?, vec![kv_elems], DType::U32); + let v8 = Tensor::new(dev.alloc(kv_elems)?, vec![kv_elems], DType::U32); + let l = |x: i64| x.to_le_bytes().to_vec(); + dev.dispatch_raw_cuda( + SDPA_FLASH_MMA_SRC, "sdpa_flash_mma.cu", "kv_to_e4m3", + &[(kf16.buffer.as_ref(), kf16.offset), (vf16.buffer.as_ref(), vf16.offset), + (k8.buffer.as_ref(), 0), (v8.buffer.as_ref(), 0), (scales.buffer.as_ref(), 0)], + &[l(kv_elems as i64)], + [((kv_elems as u32).div_ceil(256 * 4)).max(1), 1, 1], [256, 1, 1], 0, false)?; + let o_part = Tensor::new(dev.alloc(np * sq * nq * hd * 4)?, vec![np * sq * nq * hd], DType::F32); + let ml_part = Tensor::new(dev.alloc(np * sq * nq * 2 * 4)?, vec![np * sq * nq * 2], DType::F32); + let grid = [nq as u32, (sq as u32).div_ceil(64), np as u32]; + dev.dispatch_raw_cuda( + SDPA_FLASH_MMA_SRC, "sdpa_flash_mma.cu", "sdpa_flash_mma_w4s_fp8", + &[(q.buffer.as_ref(), q.offset), (k8.buffer.as_ref(), 0), + (v8.buffer.as_ref(), 0), (o_part.buffer.as_ref(), 0), (ml_part.buffer.as_ref(), 0), + (scales.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(nkv as i32), i(kv_stride as i32), i(base as i32), f(scale), i(in_f32), i(kv_chunk as i32)], + grid, [128, 1, 1], 0, false)?; + let n_el = (sq * nq * hd) as u32; + dev.dispatch_raw_cuda( + SDPA_FLASH_MMA_SRC, "sdpa_flash_mma.cu", "sdpa_flash_merge", + &[(o_part.buffer.as_ref(), 0), (ml_part.buffer.as_ref(), 0), (out.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(np as i32), i(out_f32)], + [n_el.div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + return Ok(out); + } + if n_part > 1 && std::env::var("NEMOTRON_FLASH_SPLITKV_OFF").is_err() { + let kv_chunk = n_kv.div_ceil(n_part).div_ceil(16) * 16; // BC-aligned + let o_part = Tensor::new(dev.alloc(n_part * sq * nq * hd * 4)?, vec![n_part * sq * nq * hd], DType::F32); + let ml_part = Tensor::new(dev.alloc(n_part * sq * nq * 2 * 4)?, vec![n_part * sq * nq * 2], DType::F32); + let grid = [nq as u32, (sq as u32).div_ceil(64), n_part as u32]; + dev.dispatch_raw_cuda( + SDPA_FLASH_MMA_SRC, "sdpa_flash_mma.cu", "sdpa_flash_mma_w4s", + &[(q.buffer.as_ref(), q.offset), (kf16.buffer.as_ref(), kf16.offset), + (vf16.buffer.as_ref(), vf16.offset), (o_part.buffer.as_ref(), 0), (ml_part.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(nkv as i32), i(kv_stride as i32), i(base as i32), f(scale), i(in_f32), i(kv_chunk as i32)], + grid, [128, 1, 1], 0, false)?; + let n_el = (sq * nq * hd) as u32; + dev.dispatch_raw_cuda( + SDPA_FLASH_MMA_SRC, "sdpa_flash_mma.cu", "sdpa_flash_merge", + &[(o_part.buffer.as_ref(), 0), (ml_part.buffer.as_ref(), 0), (out.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(n_part as i32), i(out_f32)], + [n_el.div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + return Ok(out); + } + // 4-warp blocks: 64 query rows/block sharing the K/V smem stage — + // 2.7x the 1-warp kernel at S=2048, bit-identical output. + let grid = [nq as u32, (sq as u32).div_ceil(64), 1]; + dev.dispatch_raw_cuda( + SDPA_FLASH_MMA_SRC, "sdpa_flash_mma.cu", "sdpa_flash_mma_w4", + &[(q.buffer.as_ref(), q.offset), (kf16.buffer.as_ref(), kf16.offset), + (vf16.buffer.as_ref(), vf16.offset), (out.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(nkv as i32), i(kv_stride as i32), i(base as i32), f(scale), i(in_f32), i(out_f32)], + grid, [128, 1, 1], 0, false)?; + Ok(out) +} + +/// Fused tensor-core causal FlashAttention (see [`SDPA_FLASH_WMMA_SRC`]). Same +/// numerics/contract as [`sdpa_multi_tc`]; casts Q/K/V → f16, runs the single +/// wmma kernel, returns `q.dtype`. head_dim must be 128. CUDA-only. +#[allow(clippy::too_many_arguments)] +pub fn sdpa_flash_wmma( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_q_heads: u32, + base_kv: u32, + n_query: u32, + kv_stride: u32, + heads_per_group: u32, + causal: bool, + scale: f32, +) -> Result { + let hd = head_dim; + let nq = n_q_heads as usize; + let sq = n_query as usize; + let hpg = heads_per_group as usize; + let nkv = nq / hpg; + let base = base_kv as usize; + if hd != 128 { return Err(Error::Msg(format!("sdpa_flash_wmma: head_dim {hd} != 128"))); } + if !causal { return Err(Error::Msg("sdpa_flash_wmma: only causal wired".into())); } + let n_kv = base + sq; + if n_kv > kv_stride as usize { + return Err(Error::Msg(format!("sdpa_flash_wmma: n_kv {n_kv} exceeds kv_stride {kv_stride}"))); + } + // tensor cores need f16 inputs; cast if the caller passed f32 (the KV cache). + let to_f16 = |t: &Tensor| -> Result { + match t.dtype { DType::F16 => Ok(t.clone()), DType::F32 => cast_f32_f16(dev, t), + other => Err(Error::Msg(format!("sdpa_flash_wmma: unsupported dtype {other:?}"))) } + }; + let qh = to_f16(q)?; + let kh = to_f16(k)?; + let vh = to_f16(v)?; + let out_h = Tensor::empty(dev, vec![sq, nq, hd], DType::F16)?; + + let i = |x: i32| x.to_le_bytes().to_vec(); + let f = |x: f32| x.to_le_bytes().to_vec(); + let grid = [nq as u32, (sq as u32).div_ceil(16), 1]; + dev.dispatch_raw_cuda( + SDPA_FLASH_WMMA_SRC, "sdpa_flash_wmma.cu", "sdpa_flash_wmma", + &[(qh.buffer.as_ref(), qh.offset), (kh.buffer.as_ref(), kh.offset), + (vh.buffer.as_ref(), vh.offset), (out_h.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(nkv as i32), i(kv_stride as i32), + i(base as i32), f(scale)], + grid, [32, 1, 1], 0, false)?; + match q.dtype { DType::F16 => Ok(out_h), _ => cast_f16_f32(dev, &out_h) } +} + +/// Fused causal FlashAttention (see [`SDPA_FLASH_FUSED_SRC`]). Same signature + +/// numerics as [`sdpa_multi_tc`] (its correctness oracle): Q pre-scaled by +/// `scale`, GQA via `heads_per_group`, causal online softmax, output `[Sq,nq,hd]` +/// in `q.dtype`. CUDA-only. `head_dim % 32 == 0` required. +#[allow(clippy::too_many_arguments)] +pub fn sdpa_flash_fused( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_q_heads: u32, + base_kv: u32, + n_query: u32, + kv_stride: u32, + heads_per_group: u32, + causal: bool, + scale: f32, +) -> Result { + let hd = head_dim; + let nq = n_q_heads as usize; + let sq = n_query as usize; + let hpg = heads_per_group as usize; + let nkv = nq / hpg; + let base = base_kv as usize; + if hd % 32 != 0 { + return Err(Error::Msg(format!("sdpa_flash_fused: head_dim {hd} not a multiple of 32"))); + } + if !causal { + return Err(Error::Msg("sdpa_flash_fused: only causal wired".into())); + } + let n_kv = base + sq; + if n_kv > kv_stride as usize { + return Err(Error::Msg(format!("sdpa_flash_fused: n_kv {n_kv} exceeds kv_stride {kv_stride}"))); + } + let in_f32 = match q.dtype { DType::F32 => 1i32, DType::F16 => 0, + other => return Err(Error::Msg(format!("sdpa_flash_fused: unsupported dtype {other:?}"))) }; + let out_f32 = if q.dtype == DType::F32 { 1i32 } else { 0 }; + let out = Tensor::empty(dev, vec![sq, nq, hd], q.dtype)?; + + let i = |x: i32| x.to_le_bytes().to_vec(); + let f = |x: f32| x.to_le_bytes().to_vec(); + let rpb: u32 = 4; // 4 warps/block, one query row each + let block = [rpb * 32, 1, 1]; + let grid = [nq as u32, (sq as u32).div_ceil(rpb), 1]; + dev.dispatch_raw_cuda( + SDPA_FLASH_FUSED_SRC, "sdpa_flash_fused.cu", "sdpa_flash_fused", + &[(q.buffer.as_ref(), q.offset), (k.buffer.as_ref(), k.offset), + (v.buffer.as_ref(), v.offset), (out.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(nkv as i32), i(hd as i32), i(kv_stride as i32), + i(base as i32), f(scale), i(in_f32), i(out_f32)], + grid, block, 0, false)?; + Ok(out) +} + +/// Tensor-core prefill SDPA — drop-in faster replacement for [`sdpa_multi`]. +/// Same signature/semantics (causal/full, GQA, head_dim=128, Q pre-scaled by +/// `scale`), but runs QKᵀ and P·V on the cuBLAS tensor cores with a +/// FlashAttention online-softmax loop tiled over KV. Q/K/V may be F32 or F16; +/// output matches `q.dtype`. CUDA-only (falls back via the GEMM/dispatch FFI). +#[allow(clippy::too_many_arguments)] +pub fn sdpa_multi_tc( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_q_heads: u32, + base_kv: u32, + n_query: u32, + kv_stride: u32, + heads_per_group: u32, + causal: bool, + scale: f32, +) -> Result { + let hd = head_dim; + let nq = n_q_heads as usize; + let sq = n_query as usize; + let hpg = heads_per_group as usize; + let nkv = nq / hpg; + let base = base_kv as usize; + // Total KV positions the deepest query row attends. + let n_kv = if causal { base + sq } else { base + sq }; + if n_kv > kv_stride as usize { + return Err(Error::Msg(format!( + "sdpa_multi_tc: n_kv {n_kv} exceeds kv_stride {kv_stride}"))); + } + let in_f32 = match q.dtype { DType::F32 => 1i32, DType::F16 => 0, + other => return Err(Error::Msg(format!("sdpa_multi_tc: unsupported dtype {other:?}"))) }; + + // ── prep buffers (all f16) ────────────────────────────────────────────── + let qh = Tensor::empty(dev, vec![nq, sq, hd], DType::F16)?; // [nq,Sq,hd] scaled + let kh = Tensor::empty(dev, vec![nkv, n_kv, hd], DType::F16)?; // [nkv,n_kv,hd] + + let i = |x: i32| x.to_le_bytes().to_vec(); + let f = |x: f32| x.to_le_bytes().to_vec(); + let blk256 = |n: usize| -> [u32; 3] { [((n as u32).div_ceil(256)).max(1), 1, 1] }; + + dev.dispatch_raw_cuda(SDPA_TC_QPREP_SRC, "sdpa_tc_qprep.cu", "sdpa_tc_qprep", + &[(q.buffer.as_ref(), q.offset), (qh.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(hd as i32), f(scale), i(in_f32)], + blk256(nq*sq*hd), [256,1,1], 0, false)?; + dev.dispatch_raw_cuda(SDPA_TC_KPREP_SRC, "sdpa_tc_kprep.cu", "sdpa_tc_kprep", + &[(k.buffer.as_ref(), k.offset), (kh.buffer.as_ref(), 0)], + &[i(nkv as i32), i(kv_stride as i32), i(n_kv as i32), i(hd as i32), i(in_f32)], + blk256(nkv*n_kv*hd), [256,1,1], 0, false)?; + + // ── running FlashAttention state (init via upload: o=0, l=0, m=-inf) ───── + let o_run = Tensor::new(dev.alloc_zeroed(nq*sq*hd*4)?, vec![nq, sq, hd], DType::F32); + let l_run = Tensor::new(dev.alloc_zeroed(nq*sq*4)?, vec![nq, sq], DType::F32); + let neg: Vec = (0..nq*sq).flat_map(|_| (-3.4e38f32).to_le_bytes()).collect(); + let m_run = Tensor::new(dev.upload(&neg)?, vec![nq, sq], DType::F32); + + // KV block size: bound scores buffer ≈ nq*Sq*BK*2 bytes. 2048 keeps it ≤ ~512MB + // at Sq≤4096; smaller Sq could go larger but 2048 is a safe universal default. + let bk = 2048usize.min(n_kv); + let scores = Tensor::empty(dev, vec![nq, sq, bk], DType::F16)?; + let p_blk = Tensor::empty(dev, vec![nq, sq, bk], DType::F16)?; + let bm = Tensor::empty(dev, vec![nq, sq], DType::F32)?; + let bl = Tensor::empty(dev, vec![nq, sq], DType::F32)?; + let o_blk = Tensor::empty(dev, vec![nq, sq, hd], DType::F16)?; + let vt = Tensor::empty(dev, vec![nkv, hd, bk], DType::F16)?; // per-block V^T + + let el = 2i64; // f16 bytes + let mut kb0 = 0usize; + while kb0 < n_kv { + let blk = bk.min(n_kv - kb0); + + // ── QKᵀ: per KV group (16 q-heads share one KV head) ──────────────── + // C[Sq, blk] = Qh[Sq,hd] · Kh_blk[blk,hd]ᵀ ; batch over the hpg q-heads. + for g in 0..nkv { + let q_off = g * hpg * sq * hd * 2; // bytes into qh + let k_off = (g * n_kv + kb0) * hd * 2; // bytes into kh (this block) + let s_off = g * hpg * sq * blk * 2; // bytes into scores + dev.gemm_strided_batched_off( + qh.buffer.as_ref(), q_off, (sq*hd) as i64 * el, // X stride = one q-head + kh.buffer.as_ref(), k_off, 0, // W stride = 0 (broadcast KV) + scores.buffer.as_ref(), s_off, (sq*blk) as i64 * el, // out stride + sq, blk, hd, hpg, DType::F16)?; + } + + // ── softmax + causal mask for this block ──────────────────────────── + // scores/p_blk are PACKED at pitch `blk` (head stride sq*blk, row pitch + // blk) — only the first nq*sq*blk f16 of the bk-sized allocation are used. + // This keeps the GEMM's lda == k == blk valid for the final partial block. + dev.dispatch_raw_cuda(SDPA_TC_SOFTMAX_SRC, "sdpa_tc_softmax.cu", "sdpa_tc_softmax", + &[(scores.buffer.as_ref(), 0), (p_blk.buffer.as_ref(), 0), + (bm.buffer.as_ref(), 0), (bl.buffer.as_ref(), 0)], + &[i(nq as i32), i(sq as i32), i(blk as i32), i(kb0 as i32), i(base as i32)], + [(nq*sq) as u32, 1, 1], [256,1,1], 0, false)?; + + // ── transpose this block of V → vt[nkv, hd, blk] (lda = blk = k) ──── + dev.dispatch_raw_cuda(SDPA_TC_VPREP_SRC, "sdpa_tc_vprep.cu", "sdpa_tc_vprep", + &[(v.buffer.as_ref(), v.offset), (vt.buffer.as_ref(), 0)], + &[i(nkv as i32), i(kv_stride as i32), i(hd as i32), i(kb0 as i32), i(blk as i32), i(in_f32)], + blk256(nkv*blk*hd), [256,1,1], 0, false)?; + + // ── P·V: O_blk[Sq,hd] = P[Sq,blk] · Vt[hd,blk]ᵀ ; batch over q-heads ─ + for g in 0..nkv { + let p_off = g * hpg * sq * blk * 2; // bytes into p_blk (pitch blk) + let v_off = g * hd * blk * 2; // bytes into vt (this group's slab) + let o_off = g * hpg * sq * hd * 2; // bytes into o_blk + dev.gemm_strided_batched_off( + p_blk.buffer.as_ref(), p_off, (sq*blk) as i64 * el, + vt.buffer.as_ref(), v_off, 0, // Vt stride = 0 (broadcast) + o_blk.buffer.as_ref(), o_off, (sq*hd) as i64 * el, + sq, hd, blk, hpg, DType::F16)?; + } + + // ── merge into running state ──────────────────────────────────────── + dev.dispatch_raw_cuda(SDPA_TC_MERGE_SRC, "sdpa_tc_merge.cu", "sdpa_tc_merge", + &[(o_blk.buffer.as_ref(), 0), (bm.buffer.as_ref(), 0), (bl.buffer.as_ref(), 0), + (o_run.buffer.as_ref(), 0), (m_run.buffer.as_ref(), 0), (l_run.buffer.as_ref(), 0)], + &[i(nq as i32), i(sq as i32), i(hd as i32)], + [(nq*sq) as u32, 1, 1], [128,1,1], 0, false)?; + + kb0 += blk; + } + + // ── finalise: normalise + transpose head-major → row-major ────────────── + let out = Tensor::empty(dev, vec![sq, nq, hd], q.dtype)?; + let out_f32 = if q.dtype == DType::F32 { 1i32 } else { 0 }; + dev.dispatch_raw_cuda(SDPA_TC_FINALIZE_SRC, "sdpa_tc_finalize.cu", "sdpa_tc_finalize", + &[(o_run.buffer.as_ref(), 0), (l_run.buffer.as_ref(), 0), (out.buffer.as_ref(), 0)], + &[i(nq as i32), i(sq as i32), i(hd as i32), i(out_f32)], + blk256(nq*sq*hd), [256,1,1], 0, false)?; + Ok(out) +} + +/// Varlen (packed multi-sequence) tensor-core FlashAttention. Identical to +/// [`sdpa_multi_tc`] except each query attends only within its own packed +/// segment: the softmax masks KV positions below `seg_lo[r]` (the absolute +/// start of query row `r`'s sequence). `seg_lo` is an `[n_query]` i32 device +/// buffer. Used by the `NEMOTRON_PACKED` batched-prefill path so N sequences +/// share one set of GEMMs while attention stays block-diagonal (correct). +/// +/// NOTE: this computes the full `[Sq, n_kv]` QKᵀ and masks off-segment to +/// −inf — bit-correct, but still O((ΣL)²) QKᵀ. The segment-skip optimisation +/// (only run KV blocks intersecting a query's segment → O(ΣLᵢ²)) is the +/// follow-up that turns correctness into the throughput win. +pub fn sdpa_multi_tc_varlen( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + seg_lo: &Tensor, // [n_query] i32: abs KV start of each query's segment + seg_len: u32, // packed segment length; if == KV-block size, enables + // the per-block query-range SKIP (O((NL)^2)→O(NL^2)). + // 0 (or != block size) → full-range (correct, no skip). + head_dim: usize, + n_q_heads: u32, + base_kv: u32, + n_query: u32, + kv_stride: u32, + heads_per_group: u32, + causal: bool, + scale: f32, +) -> Result { + let hd = head_dim; + let nq = n_q_heads as usize; + let sq = n_query as usize; + let hpg = heads_per_group as usize; + let nkv = nq / hpg; + let base = base_kv as usize; + let n_kv = if causal { base + sq } else { base + sq }; + if n_kv > kv_stride as usize { + return Err(Error::Msg(format!( + "sdpa_multi_tc_varlen: n_kv {n_kv} exceeds kv_stride {kv_stride}"))); + } + if seg_lo.elem_count() != sq { + return Err(Error::Msg(format!( + "sdpa_multi_tc_varlen: seg_lo len {} != n_query {sq}", seg_lo.elem_count()))); + } + let in_f32 = match q.dtype { DType::F32 => 1i32, DType::F16 => 0, + other => return Err(Error::Msg(format!("sdpa_multi_tc_varlen: unsupported dtype {other:?}"))) }; + + let qh = Tensor::empty(dev, vec![nq, sq, hd], DType::F16)?; + let kh = Tensor::empty(dev, vec![nkv, n_kv, hd], DType::F16)?; + + let i = |x: i32| x.to_le_bytes().to_vec(); + let f = |x: f32| x.to_le_bytes().to_vec(); + let blk256 = |n: usize| -> [u32; 3] { [((n as u32).div_ceil(256)).max(1), 1, 1] }; + + dev.dispatch_raw_cuda(SDPA_TC_QPREP_SRC, "sdpa_tc_qprep.cu", "sdpa_tc_qprep", + &[(q.buffer.as_ref(), q.offset), (qh.buffer.as_ref(), 0)], + &[i(sq as i32), i(nq as i32), i(hd as i32), f(scale), i(in_f32)], + blk256(nq*sq*hd), [256,1,1], 0, false)?; + dev.dispatch_raw_cuda(SDPA_TC_KPREP_SRC, "sdpa_tc_kprep.cu", "sdpa_tc_kprep", + &[(k.buffer.as_ref(), k.offset), (kh.buffer.as_ref(), 0)], + &[i(nkv as i32), i(kv_stride as i32), i(n_kv as i32), i(hd as i32), i(in_f32)], + blk256(nkv*n_kv*hd), [256,1,1], 0, false)?; + + let o_run = Tensor::new(dev.alloc_zeroed(nq*sq*hd*4)?, vec![nq, sq, hd], DType::F32); + let l_run = Tensor::new(dev.alloc_zeroed(nq*sq*4)?, vec![nq, sq], DType::F32); + let neg: Vec = (0..nq*sq).flat_map(|_| (-3.4e38f32).to_le_bytes()).collect(); + let m_run = Tensor::new(dev.upload(&neg)?, vec![nq, sq], DType::F32); + + let bk = 2048usize.min(n_kv); + let scores = Tensor::empty(dev, vec![nq, sq, bk], DType::F16)?; + let p_blk = Tensor::empty(dev, vec![nq, sq, bk], DType::F16)?; + let bm = Tensor::empty(dev, vec![nq, sq], DType::F32)?; + let bl = Tensor::empty(dev, vec![nq, sq], DType::F32)?; + let o_blk = Tensor::empty(dev, vec![nq, sq, hd], DType::F16)?; + let vt = Tensor::empty(dev, vec![nkv, hd, bk], DType::F16)?; + + let el = 2i64; + let mut kb0 = 0usize; + while kb0 < n_kv { + let blk = bk.min(n_kv - kb0); + // Segment-skip: when each KV-block is exactly one packed segment + // (seg_len == bk), only that segment's query rows [kb0, kb0+blk) attend + // to this block — every other row masks to p=0. Restrict the expensive + // QKᵀ/PV tensor-core GEMMs to that range (O((NL)²)→O(NL²)); the cheap + // full-range softmax/merge correctly no-op the off-segment rows. + let (q_lo, q_cnt) = if seg_len != 0 && seg_len as usize == bk { (kb0, blk) } else { (0, sq) }; + for g in 0..nkv { + let q_off = g * hpg * sq * hd * 2 + q_lo * hd * 2; + let k_off = (g * n_kv + kb0) * hd * 2; + let s_off = g * hpg * sq * blk * 2 + q_lo * blk * 2; + dev.gemm_strided_batched_off( + qh.buffer.as_ref(), q_off, (sq*hd) as i64 * el, + kh.buffer.as_ref(), k_off, 0, + scores.buffer.as_ref(), s_off, (sq*blk) as i64 * el, + q_cnt, blk, hd, hpg, DType::F16)?; + } + // varlen softmax: causal upper (base+r) AND segment lower (seg_lo[r]). + dev.dispatch_raw_cuda(SDPA_TC_SOFTMAX_VARLEN_SRC, "sdpa_tc_softmax_varlen.cu", "sdpa_tc_softmax_varlen", + &[(scores.buffer.as_ref(), 0), (p_blk.buffer.as_ref(), 0), + (bm.buffer.as_ref(), 0), (bl.buffer.as_ref(), 0), + (seg_lo.buffer.as_ref(), seg_lo.offset)], + &[i(nq as i32), i(sq as i32), i(blk as i32), i(kb0 as i32), i(base as i32)], + [(nq*sq) as u32, 1, 1], [256,1,1], 0, false)?; + dev.dispatch_raw_cuda(SDPA_TC_VPREP_SRC, "sdpa_tc_vprep.cu", "sdpa_tc_vprep", + &[(v.buffer.as_ref(), v.offset), (vt.buffer.as_ref(), 0)], + &[i(nkv as i32), i(kv_stride as i32), i(hd as i32), i(kb0 as i32), i(blk as i32), i(in_f32)], + blk256(nkv*blk*hd), [256,1,1], 0, false)?; + for g in 0..nkv { + let p_off = g * hpg * sq * blk * 2 + q_lo * blk * 2; + let v_off = g * hd * blk * 2; + let o_off = g * hpg * sq * hd * 2 + q_lo * hd * 2; + dev.gemm_strided_batched_off( + p_blk.buffer.as_ref(), p_off, (sq*blk) as i64 * el, + vt.buffer.as_ref(), v_off, 0, + o_blk.buffer.as_ref(), o_off, (sq*hd) as i64 * el, + q_cnt, hd, blk, hpg, DType::F16)?; + } + dev.dispatch_raw_cuda(SDPA_TC_MERGE_SRC, "sdpa_tc_merge.cu", "sdpa_tc_merge", + &[(o_blk.buffer.as_ref(), 0), (bm.buffer.as_ref(), 0), (bl.buffer.as_ref(), 0), + (o_run.buffer.as_ref(), 0), (m_run.buffer.as_ref(), 0), (l_run.buffer.as_ref(), 0)], + &[i(nq as i32), i(sq as i32), i(hd as i32)], + [(nq*sq) as u32, 1, 1], [128,1,1], 0, false)?; + kb0 += blk; + } + let out = Tensor::empty(dev, vec![sq, nq, hd], q.dtype)?; + let out_f32 = if q.dtype == DType::F32 { 1i32 } else { 0 }; + dev.dispatch_raw_cuda(SDPA_TC_FINALIZE_SRC, "sdpa_tc_finalize.cu", "sdpa_tc_finalize", + &[(o_run.buffer.as_ref(), 0), (l_run.buffer.as_ref(), 0), (out.buffer.as_ref(), 0)], + &[i(nq as i32), i(sq as i32), i(hd as i32), i(out_f32)], + blk256(nq*sq*hd), [256,1,1], 0, false)?; + Ok(out) +} + +/// Raw CUDA source for the **chunked parallel SSD prefill scan** for the +/// NemotronH-Nano-30B Mamba2 cell (Dh=64, Ds=128, H=64, G=8, n_per_t=4). +/// +/// The existing `ssm_step_record_d64_128_64_8` runs a serial loop over T +/// positions per (h, dh) cell — the serial chain of length T is the bottleneck. +/// This kernel replaces it with a **two-pass parallel segment scan**: +/// +/// **Pass 1 (parallel)**: `N_SEG` warp-segments each process `T/N_SEG` positions +/// independently (assuming initial state = 0), computing a "partial state" +/// contribution and the segment's decay product `alpha_seg`. +/// +/// **Pass 2 (sequential, warp 0 only)**: Stitch the true initial state for each +/// segment by walking the T/N_SEG chain: `s_in[k+1] = alpha_seg[k] * s_in[k] + partial[k]`. +/// +/// **Pass 3 (parallel)**: Each segment re-computes its positions with the true +/// `s_in[seg]` and emits the correct `y[t]` values. +/// +/// **Complexity**: 2T work (passes 1+3) + T/N_SEG serial (pass 2). At N_SEG=32, +/// depth drops from T=2048 to T/32=64. Speedup ≈ 32× for pure serial bottleneck. +/// +/// **Memory**: Shared: `N_SEG × n_per_t × WARP` = 32×4×32 = 4096 f32 per block ≈ 16KB. +/// Block size: `[WARP × N_SEG, 1, 1]`; Grid3D: `[1, Dh, H]` (same as sequential). +/// +/// **Dimensions fixed for NemotronH**: Dh=64, Ds=128, H=64, G=8, n_per_t=4. +/// The Ds/warp = 128/32 = 4 state values per lane (= n_per_t). G/H ratio = 8/64 = 1/8. +const SSM_CHUNKED_SCAN_SRC: &str = r#" +#include +// Note: math.h / cmath not needed for NVRTC device code. +// expf, __shfl_xor_sync etc. are CUDA intrinsics, available without includes. + +// NemotronH-Nano Mamba2 cell constants (hardcoded for performance). +#define DH 64u // head_dim +#define DS 128u // state_dim +#define NH 64u // n_heads +#define NG 8u // n_groups (G = H/ratio, ratio = NH/NG = 8) +#define NPT 4u // n_per_t = DS / WARP (128/32 = 4 state slots per lane) +#define WARP 32u + +// N_SEG segments per (h, dh) cell. Must divide T cleanly. +// 32 segments → at T=2048 each segment handles 64 steps; T=512 → 16 steps. +// Shared mem = N_SEG * NPT * WARP * 4 = 32*4*32*4 = 16KB + alpha (128B) + s_in (512B) per block. +// Block size = WARP * N_SEG = 32*32 = 1024 (CUDA max threads/block limit). +#define N_SEG 32u + +// Total threads per block: WARP * N_SEG = 32 * 32 = 1024. +// Grid: [1, DH, NH] = [1, 64, 64] = 4096 blocks (same as sequential kernel). + +extern "C" __global__ __launch_bounds__(WARP * N_SEG) +void ssm_chunked_prefill( + const float* __restrict__ x, // [T, NH, DH] + const float* __restrict__ a_log, // [NH] + const float* __restrict__ b_mat, // [T, NG, DS] + const float* __restrict__ c_mat, // [T, NG, DS] + const float* __restrict__ d_skip, // [NH] + const float* __restrict__ dt, // [T, NH] + const float* __restrict__ state_in, // [NH, DH, DS] + float* __restrict__ y, // [T, NH, DH] + float* __restrict__ state_out,// [NH, DH, DS] + unsigned int t_total) +{ + // Each block owns one (d_idx, h_idx) cell. + const unsigned int d_idx = blockIdx.y; // ∈ [0, DH) + const unsigned int h_idx = blockIdx.z; // ∈ [0, NH) + const unsigned int g_idx = h_idx / (NH / NG); // group index ∈ [0, NG) + const unsigned int lane = threadIdx.x & (WARP - 1u); // ∈ [0, 32) + const unsigned int seg_id = threadIdx.x / WARP; // ∈ [0, N_SEG) + + // Shared memory: partial state contribution for each segment × state slots. + // Layout: [N_SEG, NPT] where NPT lanes × each 1 float = 128 floats/segment. + __shared__ float sh_partial[N_SEG * NPT * WARP]; // seg → [NPT, WARP] colmajor + __shared__ float sh_alpha[N_SEG]; // seg decay product + __shared__ float sh_s_in[NPT * WARP]; // true initial state for each seg (broadcast) + + // Chunk parameters. + const unsigned int T = t_total; + const unsigned int seg_len = (T + N_SEG - 1u) / N_SEG; // positions per segment (~T/N_SEG) + const unsigned int t_start = seg_id * seg_len; + const unsigned int t_end = (t_start + seg_len < T) ? (t_start + seg_len) : T; + + // A = -exp(A_log[h]) (constant for this cell). + const float a_neg = -expf(a_log[h_idx]); + + // State-slice offsets for this (h, d_idx) cell. + const unsigned int state_base = (h_idx * DH + d_idx) * DS; + + // ── Pass 1: Compute partial state and alpha for this segment ───────────── + // Assume state_in = 0. Walk t_start..t_end updating local state in registers. + // Each lane owns NPT=4 consecutive Ds slots: lane*NPT .. lane*NPT+3. + float local_s[NPT] = {0.f, 0.f, 0.f, 0.f}; // partial state (assuming s_in=0) + float seg_alpha = 1.f; // product of dA over [t_start, t_end) + + for (unsigned int t = t_start; t < t_end; ++t) { + const unsigned int bt_h = t * NH + h_idx; + const unsigned int bt_g = t * NG + g_idx; + const float dt_v = dt[bt_h]; + const float d_a = expf(a_neg * dt_v); + const float x_v = x[bt_h * DH + d_idx]; + + seg_alpha *= d_a; + + // Update local state and accumulate y later (will redo in pass 3). + for (unsigned int i = 0u; i < NPT; ++i) { + const unsigned int s_idx = lane * NPT + i; + const float b_v = b_mat[bt_g * DS + s_idx]; + local_s[i] = d_a * local_s[i] + dt_v * b_v * x_v; + } + } + + // Write partial state to shared memory: sh_partial[seg_id, lane, i]. + for (unsigned int i = 0u; i < NPT; ++i) { + sh_partial[(seg_id * NPT + i) * WARP + lane] = local_s[i]; + } + if (lane == 0u) sh_alpha[seg_id] = seg_alpha; + + __syncthreads(); + + // ── Pass 2: Sequential stitch (only seg 0 of each block = warp 0) ───── + // Walk segments 0..N_SEG sequentially to compute true s_in for each segment. + // Seg 0's s_in is the model's real state_in (loaded from global memory). + if (seg_id == 0u) { + // Load true initial state into sh_s_in (seg 0's start = state_in). + for (unsigned int i = 0u; i < NPT; ++i) { + sh_s_in[i * WARP + lane] = state_in[state_base + (lane * NPT + i)]; + } + // Seg 0: its partial result already assumed s_in=0; add the real s_in×alpha. + // (We store the TRUE s_in for seg k, then update to seg k+1's s_in.) + for (unsigned int seg = 0u; seg < N_SEG; ++seg) { + const float alpha_s = sh_alpha[seg]; + // True s_in for next seg = alpha_s * true_s_in[seg] + partial[seg]. + for (unsigned int i = 0u; i < NPT; ++i) { + float s_in_val = sh_s_in[i * WARP + lane]; + float partial = sh_partial[(seg * NPT + i) * WARP + lane]; + // The corrected partial for this seg: partial + alpha_s * s_in_val + // We store the NEXT seg's s_in (= alpha_s * s_in_val + partial). + sh_s_in[i * WARP + lane] = alpha_s * s_in_val + partial; + // Overwrite partial with corrected carry for pass 3 to read. + sh_partial[(seg * NPT + i) * WARP + lane] = s_in_val; + } + } + // sh_s_in now holds the true final state after all T positions. + } + + __syncthreads(); + + // ── Pass 3: Recompute with true s_in, emit y ──────────────────────── + // sh_partial[seg_id * NPT + i, lane] now holds the TRUE s_in for this seg. + for (unsigned int i = 0u; i < NPT; ++i) { + local_s[i] = sh_partial[(seg_id * NPT + i) * WARP + lane]; + } + + for (unsigned int t = t_start; t < t_end; ++t) { + const unsigned int bt_h = t * NH + h_idx; + const unsigned int bt_g = t * NG + g_idx; + const float dt_v = dt[bt_h]; + const float d_a = expf(a_neg * dt_v); + const float x_v = x[bt_h * DH + d_idx]; + + float y_acc = 0.f; + for (unsigned int i = 0u; i < NPT; ++i) { + const unsigned int s_idx = lane * NPT + i; + const float b_v = b_mat[bt_g * DS + s_idx]; + const float c_v = c_mat[bt_g * DS + s_idx]; + local_s[i] = d_a * local_s[i] + dt_v * b_v * x_v; + y_acc += c_v * local_s[i]; + } + // Warp-reduce y_acc across all 32 lanes. + for (unsigned int mask = WARP >> 1u; mask > 0u; mask >>= 1u) + y_acc += __shfl_xor_sync(0xffffffffu, y_acc, mask); + + if (lane == 0u) { + const float d_v = d_skip[h_idx]; + y[bt_h * DH + d_idx] = y_acc + x_v * d_v; + } + } + + // ── Write state_out ─────────────────────────────────────────────────── + // Only warp 0 (seg_id=0, lane=all) holds the FINAL state in sh_s_in. + if (seg_id == 0u) { + for (unsigned int i = 0u; i < NPT; ++i) { + state_out[state_base + (lane * NPT + i)] = sh_s_in[i * WARP + lane]; + } + } +} +"#; + +/// Mamba2 SSD **chunked parallel prefill scan** for the NemotronH cell +/// (Dh=64, Ds=128, H=64, G=8). Replaces the sequential-in-T `ssm_prefill_scan` +/// with a two-pass parallel segment scan that drops the serial depth from T to +/// T/N_SEG=T/64. Gated by `NEMOTRON_CHUNKED_SCAN=1`; correctness gate is the +/// same `NEMOTRON_PREFILL_CHECK` comparison against sequential. +/// +/// Grid3D: `[1, Dh=64, H=64]`, block `[WARP × N_SEG]` = `[32 × 32 = 1024]`. +/// Shared mem per block: ~17KB (partial states + alpha + s_in scratch). +/// +/// Returns `(state_out, y)` — same signature as `ssm_prefill_scan`. +#[allow(clippy::too_many_arguments)] +pub fn ssm_prefill_scan_chunked( + dev: &dyn Device, + x: &Tensor, + a_log: &Tensor, + b_mat: &Tensor, + c_mat: &Tensor, + d_skip: &Tensor, + dt: &Tensor, + state_in: &Tensor, + t_total: u32, + dh: u32, + ds: u32, + n_heads: u32, + n_groups: u32, +) -> Result<(Tensor, Tensor)> { + if (dh, ds, n_heads, n_groups) != (64, 128, 64, 8) { + return Err(Error::Msg(format!( + "ssm_prefill_scan_chunked: only Nemotron cell (64,128,64,8) wired, got ({dh},{ds},{n_heads},{n_groups})" + ))); + } + let t = t_total as usize; + let (nh, dhu, _dsu) = (n_heads as usize, dh as usize, ds as usize); + let y = Tensor::empty(dev, vec![t * nh * dhu], x.dtype)?; + let state_out = Tensor::empty(dev, state_in.shape.clone(), x.dtype)?; + // N_SEG must divide t_total (or kernel pads). 64 divides 512,2048,8192. + // For t_total not divisible by 64, pass as-is — the kernel uses a guard. + let t_bytes = t_total.to_le_bytes().to_vec(); + // Grid: [1, Dh, H]. Block: [WARP * N_SEG] = [32 * 64] = [2048]. + let grid = [1u32, dh, n_heads]; + let block = [32u32 * 32, 1, 1]; // WARP * N_SEG = 32*32 = 1024 (CUDA max threads/block) + // Shared mem: N_SEG * NPT * WARP * 4 + N_SEG * 4 + NPT * WARP * 4 + // = 32*4*32*4 + 32*4 + 4*32*4 = 16384 + 128 + 512 = 17024 bytes + let shared_bytes: u32 = 32 * 4 * 32 * 4 + 32 * 4 + 4 * 32 * 4; + // Cast inputs to f32 for the raw CUDA kernel (which reads f32). + // The existing sequential kernel also accumulates in f32. + // NOTE: if x/state are f16/bf16 we need casts. For now, only f32 is wired. + if x.dtype != DType::F32 { + return Err(Error::Msg("ssm_prefill_scan_chunked: only F32 input wired (cast first)".into())); + } + dev.dispatch_raw_cuda( + SSM_CHUNKED_SCAN_SRC, + "ssm_chunked_prefill.cu", + "ssm_chunked_prefill", + &[ + (x.buffer.as_ref(), 0), + (a_log.buffer.as_ref(), 0), + (b_mat.buffer.as_ref(), 0), + (c_mat.buffer.as_ref(), 0), + (d_skip.buffer.as_ref(), 0), + (dt.buffer.as_ref(), 0), + (state_in.buffer.as_ref(), 0), + (y.buffer.as_ref(), 0), + (state_out.buffer.as_ref(), 0), + ], + &[t_bytes], + grid, + block, + shared_bytes, + false, + )?; + Ok((state_out, y)) +} + +/// Mamba2 SSD **batched-prefill scan** — runs the sequential SSD forward over +/// all `t_total` prompt tokens in ONE dispatch, emitting every per-token +/// `y[t]` plus the final recurrent `state_out` (for decode continuity). +/// Dispatches `ssm_step_record_d64_128_64_8` (Nemotron cell: Dh=64, Ds=128, +/// H=64, G=8). The `(da_log, dbx_log)` tapes are written but ignored here +/// (they exist for the speculative-rollback replay path). Layouts: +/// `x [t·H·Dh]`, `a_log/d [H]`, `dt [t·H]`, `b/c [t·G·Ds]`, +/// `state_in/out [H·Dh·Ds]`, `y [t·H·Dh]`. Grid3D `[1, Dh, H]`, tg `[32,1,1]`. +#[allow(clippy::too_many_arguments)] +pub fn ssm_prefill_scan( + dev: &dyn Device, + x: &Tensor, + a_log: &Tensor, + b_mat: &Tensor, + c_mat: &Tensor, + d_skip: &Tensor, + dt: &Tensor, + state_in: &Tensor, + t_total: u32, + dh: u32, + ds: u32, + n_heads: u32, + n_groups: u32, +) -> Result<(Tensor, Tensor)> { + use metaltile_core::ir::KernelMode; + if (dh, ds, n_heads, n_groups) != (64, 128, 64, 8) { + return Err(Error::Msg(format!( + "ssm_prefill_scan: only Nemotron cell (64,128,64,8) wired, got ({dh},{ds},{n_heads},{n_groups})" + ))); + } + let t = t_total as usize; + let (nh, dhu, dsu) = (n_heads as usize, dh as usize, ds as usize); + let y = Tensor::empty(dev, vec![t * nh * dhu], x.dtype)?; + let state_out = Tensor::empty(dev, state_in.shape.clone(), x.dtype)?; + // Tape outputs — written by the kernel, unused by prefill. + let da_log = Tensor::empty(dev, vec![t * nh * dsu], x.dtype)?; + let dbx_log = Tensor::empty(dev, vec![t * nh * dhu * dsu], x.dtype)?; + // mask buffer: has_mask=0 → kernel ignores contents, but the binding must exist. + let mask = Tensor::new(dev.alloc_zeroed(t * 4).unwrap(), vec![t], DType::U32); + let kern = cached_ir("ssm_step_record_d64_128_64_8", x.dtype, || { + let mut kk = metaltile_std::ffai::ssm_replay::ssm_step_record_d64_128_64_8::kernel_ir_for(x.dtype); + kk.mode = KernelMode::Grid3D; + kk + }); + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let grid = Grid { grid: [1, dh, n_heads], block: [32, 1, 1] }; + dev.dispatch( + &kern, + &[ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(a_log.buffer.clone()), + Binding::Buffer(b_mat.buffer.clone()), + Binding::Buffer(c_mat.buffer.clone()), + Binding::Buffer(d_skip.buffer.clone()), + Binding::Buffer(dt.buffer.clone()), + Binding::Buffer(state_in.buffer.clone()), + Binding::Buffer(mask.buffer.clone()), + Binding::Buffer(y.buffer.clone()), + Binding::Buffer(state_out.buffer.clone()), + Binding::Buffer(da_log.buffer.clone()), + Binding::Buffer(dbx_log.buffer.clone()), + u(t_total), + u(0), // has_mask + ], + grid, + )?; + Ok((state_out, y)) +} + +/// DeepSeek-V4 MLA decode attention: d512 SDPA with a per-head learnable +/// **attention sink** (a virtual logit that extends the softmax denominator). +/// `q` is `[n_q_heads, 512]`; `k`/`v` are the latent KV cache +/// `[n_kv_heads, kv_stride, 512]`; `sink_logit` is `[n_q_heads]` f32. +/// Dispatches `ffai_sdpa_decode_d512_sink` (tg 512). +#[allow(clippy::too_many_arguments)] +pub fn sdpa_decode_sink( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + sink_logit: &Tensor, + n_kv: u32, + kv_stride: u32, + heads_per_group: u32, + scale: f32, +) -> Result { + const HD: usize = 512; + let n_q_heads = q.elem_count() / HD; + let out = Tensor::empty(dev, vec![n_q_heads, HD], q.dtype)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let kern = lookup("ffai_sdpa_decode_d512_sink", q.dtype)?; + let bindings = vec![ + Binding::Buffer(q.buffer.clone()), + Binding::Buffer(k.buffer.clone()), + Binding::Buffer(v.buffer.clone()), + Binding::Buffer(sink_logit.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(HD as u32), + u(n_kv), + u(kv_stride), + u(heads_per_group), + Binding::Scalar(scale.to_le_bytes().to_vec()), + ]; + let grid = Grid { grid: [n_q_heads as u32, 1, 1], block: [512, 1, 1] }; + dev.dispatch(&kern, &bindings, grid)?; + Ok(out) +} + +/// Decode-time scaled-dot-product attention for a single query token. +/// +/// Layout (matching the kernel): `q` is `[n_q_heads, head_dim]`; `k`/`v` are +/// `[n_kv_heads, kv_stride, head_dim]` (kv cache, `kv_stride` = capacity, +/// `n_kv` = filled length); `kv_head = q_head / heads_per_group` (GQA). No +/// attention sink / sliding window (dense causal). Picks the head-dim +/// specialized kernel variant. Output is `[n_q_heads, head_dim]`. +#[allow(clippy::too_many_arguments)] +pub fn sdpa_decode( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_kv: u32, + kv_stride: u32, + heads_per_group: u32, + scale: f32, +) -> Result { + let n_q_heads = q.elem_count() / head_dim; + let out = Tensor::empty(dev, vec![n_q_heads, head_dim], q.dtype)?; + + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let f = |x: f32| Binding::Scalar(x.to_le_bytes().to_vec()); + + // Per-variant trailing constexprs (everything after head_dim, n_kv, + // kv_stride, heads_per_group). Dense path → sink/window disabled. + let (name, block, trailing): (&str, u32, Vec) = match head_dim { + 64 => ("ffai_sdpa_decode_d64", 1024, vec![u(0), f(0.0), f(scale)]), // has_sink, sink_logit, scale + 96 => ("ffai_sdpa_decode_d96", 1024, vec![f(scale)]), + 128 => ("ffai_sdpa_decode", 1024, vec![u(0), u(0), u(0), f(0.0), f(scale)]), // sink_end, window_start, has_sink, sink_logit, scale + 256 => ("ffai_sdpa_decode_d256", 1024, vec![u(0), f(0.0), f(scale)]), + 512 => ("ffai_sdpa_decode_d512", 512, vec![f(scale)]), + _ => return Err(Error::Msg(format!("sdpa_decode: unsupported head_dim {head_dim}"))), + }; + + let kern = lookup(name, q.dtype)?; + let mut bindings = vec![ + Binding::Buffer(q.buffer.clone()), + Binding::Buffer(k.buffer.clone()), + Binding::Buffer(v.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(head_dim as u32), + u(n_kv), + u(kv_stride), + u(heads_per_group), + ]; + bindings.extend(trailing); + let grid = Grid { grid: [n_q_heads as u32, 1, 1], block: [block, 1, 1] }; + dev.dispatch(&kern, &bindings, grid)?; + Ok(out) +} + +/// GQA-aware split-K flash-decode SDPA (MLX `sdpa_vector_2pass` port, head_dim +/// 128). Pass 1: grid `(n_kv_heads, blocks)`, block `(32, gqa_factor)` — one +/// simdgroup per Q-head of the group, so the `gqa_factor` heads SHARE each K/V +/// load (the single-pass kernel re-reads the shared KV head `gqa_factor`× — at +/// 32K that's the dominant cost). Each block-row strides a `1/blocks` slice of +/// the KV positions, emitting per-block (max, sum, partial_o). Pass 2: grid +/// `(n_q_heads)`, block 1024 — online-softmax merge of the `blocks` partials. +/// `blocks` MUST be a multiple of 32 (pass-2 reducer constraint). Kernel math +/// is registry-validated (`test_ffai_sdpa_decode_2pass_combined`, f32/f16/bf16). +#[allow(clippy::too_many_arguments)] +pub fn sdpa_decode_2pass( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_kv: u32, + kv_stride: u32, + gqa_factor: u32, + scale: f32, + blocks: u32, +) -> Result { + use metaltile_core::ir::KernelMode; + let n_q_heads = q.elem_count() / head_dim; + let n_kv_heads = n_q_heads / gqa_factor as usize; + let nb = blocks as usize; + let partial_o = Tensor::empty(dev, vec![n_q_heads * nb * head_dim], q.dtype)?; + let partial_m = Tensor::empty(dev, vec![n_q_heads * nb], DType::F32)?; + let partial_l = Tensor::empty(dev, vec![n_q_heads * nb], DType::F32)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let f = |x: f32| Binding::Scalar(x.to_le_bytes().to_vec()); + + let k1 = cached_ir("sdpa_decode_2pass_pass1", q.dtype, || { + let mut k = metaltile_std::ffai::sdpa_decode_2pass::sdpa_decode_2pass_pass1::kernel_ir_for(q.dtype); + k.mode = KernelMode::Reduction; + k + }); + dev.dispatch(&k1, &[ + Binding::Buffer(q.buffer.clone()), Binding::Buffer(k.buffer.clone()), Binding::Buffer(v.buffer.clone()), + Binding::Buffer(partial_o.buffer.clone()), Binding::Buffer(partial_m.buffer.clone()), Binding::Buffer(partial_l.buffer.clone()), + u(head_dim as u32), u(n_kv), u(kv_stride), u(gqa_factor), u(blocks), f(scale), + ], Grid { grid: [n_kv_heads as u32, blocks, 1], block: [32 * gqa_factor, 1, 1] })?; + + let out = Tensor::empty(dev, vec![n_q_heads, head_dim], q.dtype)?; + let k2 = cached_ir("sdpa_decode_2pass_pass2", q.dtype, || { + let mut k = metaltile_std::ffai::sdpa_decode_2pass::sdpa_decode_2pass_pass2::kernel_ir_for(q.dtype); + k.mode = KernelMode::Reduction; + k + }); + dev.dispatch(&k2, &[ + Binding::Buffer(partial_o.buffer.clone()), Binding::Buffer(partial_m.buffer.clone()), Binding::Buffer(partial_l.buffer.clone()), + Binding::Buffer(out.buffer.clone()), u(head_dim as u32), u(blocks), + ], Grid { grid: [n_q_heads as u32, 1, 1], block: [1024, 1, 1] })?; + Ok(out) +} + +/// Like `sdpa_decode_2pass` but uses the TILED pass-1 variant: assigns each +/// block a contiguous chunk of KV positions for L2-cache-friendly sequential +/// access (vs the strided pattern which thrashes L2). Activated by NEMOTRON_TILED=1. +#[allow(clippy::too_many_arguments)] +pub fn sdpa_decode_2pass_tiled( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_kv: u32, + kv_stride: u32, + gqa_factor: u32, + scale: f32, + blocks: u32, +) -> Result { + use metaltile_core::ir::KernelMode; + let n_q_heads = q.elem_count() / head_dim; + let n_kv_heads = n_q_heads / gqa_factor as usize; + let nb = blocks as usize; + let partial_o = Tensor::empty(dev, vec![n_q_heads * nb * head_dim], q.dtype)?; + let partial_m = Tensor::empty(dev, vec![n_q_heads * nb], DType::F32)?; + let partial_l = Tensor::empty(dev, vec![n_q_heads * nb], DType::F32)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let f = |x: f32| Binding::Scalar(x.to_le_bytes().to_vec()); + + let k1 = cached_ir("sdpa_decode_2pass_pass1_tiled", q.dtype, || { + let mut k = metaltile_std::ffai::sdpa_decode_2pass::sdpa_decode_2pass_pass1_tiled::kernel_ir_for(q.dtype); + k.mode = KernelMode::Reduction; + k + }); + dev.dispatch(&k1, &[ + Binding::Buffer(q.buffer.clone()), Binding::Buffer(k.buffer.clone()), Binding::Buffer(v.buffer.clone()), + Binding::Buffer(partial_o.buffer.clone()), Binding::Buffer(partial_m.buffer.clone()), Binding::Buffer(partial_l.buffer.clone()), + u(head_dim as u32), u(n_kv), u(kv_stride), u(gqa_factor), u(blocks), f(scale), + ], Grid { grid: [n_kv_heads as u32, blocks, 1], block: [32 * gqa_factor, 1, 1] })?; + + let out = Tensor::empty(dev, vec![n_q_heads, head_dim], q.dtype)?; + let k2 = cached_ir("sdpa_decode_2pass_pass2", q.dtype, || { + let mut k = metaltile_std::ffai::sdpa_decode_2pass::sdpa_decode_2pass_pass2::kernel_ir_for(q.dtype); + k.mode = KernelMode::Reduction; + k + }); + dev.dispatch(&k2, &[ + Binding::Buffer(partial_o.buffer.clone()), Binding::Buffer(partial_m.buffer.clone()), Binding::Buffer(partial_l.buffer.clone()), + Binding::Buffer(out.buffer.clone()), u(head_dim as u32), u(blocks), + ], Grid { grid: [n_q_heads as u32, 1, 1], block: [1024, 1, 1] })?; + Ok(out) +} + +/// Like `sdpa_decode_2pass` but uses the BC=4 pass-1 variant: processes 4 KV +/// positions per loop iteration to expose memory-level parallelism and hide +/// load latency. Same pass-2 reduction. Same correctness guarantees. +/// Activated by NEMOTRON_BC4=1 in the bench harness. +#[allow(clippy::too_many_arguments)] +pub fn sdpa_decode_2pass_bc4( + dev: &dyn Device, + q: &Tensor, + k: &Tensor, + v: &Tensor, + head_dim: usize, + n_kv: u32, + kv_stride: u32, + gqa_factor: u32, + scale: f32, + blocks: u32, +) -> Result { + use metaltile_core::ir::KernelMode; + let n_q_heads = q.elem_count() / head_dim; + let n_kv_heads = n_q_heads / gqa_factor as usize; + let nb = blocks as usize; + let partial_o = Tensor::empty(dev, vec![n_q_heads * nb * head_dim], q.dtype)?; + let partial_m = Tensor::empty(dev, vec![n_q_heads * nb], DType::F32)?; + let partial_l = Tensor::empty(dev, vec![n_q_heads * nb], DType::F32)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let f = |x: f32| Binding::Scalar(x.to_le_bytes().to_vec()); + + // BC=4 pass 1: 4 positions per loop iter for MLP / load-latency hiding. + let k1 = cached_ir("sdpa_decode_2pass_pass1_bc4", q.dtype, || { + let mut k = metaltile_std::ffai::sdpa_decode_2pass::sdpa_decode_2pass_pass1_bc4::kernel_ir_for(q.dtype); + k.mode = KernelMode::Reduction; + k + }); + dev.dispatch(&k1, &[ + Binding::Buffer(q.buffer.clone()), Binding::Buffer(k.buffer.clone()), Binding::Buffer(v.buffer.clone()), + Binding::Buffer(partial_o.buffer.clone()), Binding::Buffer(partial_m.buffer.clone()), Binding::Buffer(partial_l.buffer.clone()), + u(head_dim as u32), u(n_kv), u(kv_stride), u(gqa_factor), u(blocks), f(scale), + ], Grid { grid: [n_kv_heads as u32, blocks, 1], block: [32 * gqa_factor, 1, 1] })?; + + // Pass 2 unchanged: same partial buffer layout, same reduction. + let out = Tensor::empty(dev, vec![n_q_heads, head_dim], q.dtype)?; + let k2 = cached_ir("sdpa_decode_2pass_pass2", q.dtype, || { + let mut k = metaltile_std::ffai::sdpa_decode_2pass::sdpa_decode_2pass_pass2::kernel_ir_for(q.dtype); + k.mode = KernelMode::Reduction; + k + }); + dev.dispatch(&k2, &[ + Binding::Buffer(partial_o.buffer.clone()), Binding::Buffer(partial_m.buffer.clone()), Binding::Buffer(partial_l.buffer.clone()), + Binding::Buffer(out.buffer.clone()), u(head_dim as u32), u(blocks), + ], Grid { grid: [n_q_heads as u32, 1, 1], block: [1024, 1, 1] })?; + Ok(out) +} + +/// Run a registered elementwise kernel (Grid3D, one thread per element) with +/// the given ordered bindings, producing a fresh `out`-shaped output. +fn elementwise_kernel( + dev: &dyn Device, + name: &str, + dtype: DType, + out_shape: Vec, + mut bindings: Vec, +) -> Result { + let k = lookup(name, dtype)?; + let out = Tensor::empty(dev, out_shape, dtype)?; + bindings.push(Binding::Buffer(out.buffer.clone())); + let n = out.elem_count() as u32; + let grid = Grid::d1(n.div_ceil(256), 256); + // output binding goes in its signature position — callers pass inputs, + // we append the single output last (matches a,…,out param order). + dev.dispatch(&k, &bindings, grid)?; + Ok(out) +} + +/// SiLU activation `out = x * sigmoid(x)`, elementwise. Dispatches `mt_silu`. +pub fn silu(dev: &dyn Device, x: &Tensor) -> Result { + elementwise_kernel(dev, "mt_silu", x.dtype, x.shape.clone(), vec![Binding::Buffer(x.buffer.clone())]) +} + +/// ReLU² activation `out = max(x,0)²`, elementwise — NemotronH MoE expert act. +/// Built inline (Relu → square) so the expert `up → relu² → down` chain stays +/// ON-DEVICE (no host round-trip between the two Q8 GEMVs). Dispatches `mt_relu2`. +pub fn relu2(dev: &dyn Device, x: &Tensor) -> Result { + let mut k = Kernel::new("mt_relu2"); + for (pname, is_out) in [("a", false), ("c", true)] { + k.params.push(Param { name: pname.into(), dtype: x.dtype, shape: Shape::scalar(), is_output: is_out, kind: ParamKind::Tensor }); + } + k.body.push_op(Op::ProgramId { axis: 0 }, ValueId::new(0)); + k.body.push_op(Op::Load { src: "a".into(), indices: vec![IndexExpr::Value(ValueId::new(0))], mask: None, other: None }, ValueId::new(1)); + k.body.push_op(Op::Activation { kind: ActKind::Relu, value: ValueId::new(1) }, ValueId::new(2)); + k.body.push_op(Op::BinOp { op: BinOpKind::Mul, lhs: ValueId::new(2), rhs: ValueId::new(2) }, ValueId::new(3)); + k.body.push_op_no_result(Op::Store { dst: "c".into(), indices: vec![IndexExpr::Value(ValueId::new(0))], value: ValueId::new(3), mask: None }); + k.mode = metaltile_core::ir::KernelMode::Elementwise; + let out = Tensor::empty(dev, x.shape.clone(), x.dtype)?; + let n = out.elem_count() as u32; + let grid = Grid::d1(n.div_ceil(256), 256); + dev.dispatch(&k, &[Binding::Buffer(x.buffer.clone()), Binding::Buffer(out.buffer.clone())], grid)?; + Ok(out) +} + +/// Raw CUDA source for the **MoE scatter-add** kernel. +/// +/// After the batched down-GEMM produces `dn_out [mt, hid]` (sorted by expert), +/// this kernel scatters each row back to the corresponding token slot in the +/// output accumulator, weighted by the router weight: +/// +/// `acc[token_indices[r], h] += dn_out[r, h] * weights[r] * unscale` +/// +/// Uses `atomicAdd(float*, float)` (available since CUDA 2.0/GPGPU) so multiple +/// experts for the same token add correctly. Block: `[BLOCK_H, 1]`; Grid: +/// `[mt, ceil(hid/BLOCK_H)]`. For NemotronH hid=2688, BLOCK_H=128: 21 col-blocks. +const MOE_SCATTER_ADD_SRC: &str = r#" +#include +#define BLOCK_H 128 + +extern "C" __global__ void moe_scatter_add_f32( + const float* __restrict__ dn, // [mt, hid] sorted-row expert outputs + const unsigned* __restrict__ tidx, // [mt] token index per row + const float* __restrict__ wts, // [mt] router weights + float* acc, // [s, hid] output accumulator (pre-zeroed) + int mt, int hid, float unscale) +{ + int r = (int)blockIdx.x; // sorted row ∈ [0, mt) + int h = (int)(blockIdx.y * BLOCK_H + threadIdx.x); // hidden dim + if (r < mt && h < hid) { + float w = wts[r] * unscale; + atomicAdd(&acc[(int)tidx[r] * hid + h], dn[r * hid + h] * w); + } +} +"#; + +/// Device-side MoE scatter-add: write `dn_out[r, h] * weights[r] * unscale` +/// into `acc[token_indices[r], h]` for each row `r` in the sorted batch. +/// `acc` must be pre-zeroed (use `Tensor::zeros`). Returns nothing (in-place). +/// +/// Replaces the host scatter loop in the prefill MoE path, enabling fully +/// on-device processing when combined with device gather + batched bm64 GEMMs. +pub fn moe_scatter_add( + dev: &dyn Device, + dn: &Tensor, // [mt, hid] f32 down-GEMM output (sorted) + tidx: &Tensor, // [mt] u32 token indices + wts: &Tensor, // [mt] f32 router weights + acc: &Tensor, // [s, hid] f32 accumulator + mt: usize, + hid: usize, + unscale: f32, +) -> Result<()> { + let mt_i = (mt as i32).to_le_bytes().to_vec(); + let hid_i = (hid as i32).to_le_bytes().to_vec(); + let uscale_f = unscale.to_le_bytes().to_vec(); + const BLOCK_H: u32 = 128; + let grid = [mt as u32, (hid as u32).div_ceil(BLOCK_H), 1]; + let block = [BLOCK_H, 1, 1]; + dev.dispatch_raw_cuda( + MOE_SCATTER_ADD_SRC, + "moe_scatter_add.cu", + "moe_scatter_add_f32", + &[ + (dn.buffer.as_ref(), 0), + (tidx.buffer.as_ref(), 0), + (wts.buffer.as_ref(), 0), + (acc.buffer.as_ref(), 0), + ], + &[mt_i, hid_i, uscale_f], + grid, + block, + 0, + false, + ) +} + +/// Raw CUDA source for the **DETERMINISTIC MoE scatter-add** kernel. +/// +/// The atomicAdd variant (`moe_scatter_add_f32`) is run-to-run NONdeterministic: +/// floating-point `atomicAdd` completes in hardware-arbitrated (nondeterministic) +/// order, so when several routed experts for the same token accumulate into the +/// same `acc[token,h]`, the summation order — and thus the low bits of the result +/// — varies between identical runs. That jitter propagates through 52 layers and +/// can flip the final argmax at deep context. +/// +/// This variant is bit-exact reproducible: one block per `(token, h-block)`, and +/// each thread sums *its* token's contributing rows in a FIXED order, read from a +/// pre-built CSR-style index (`tok_starts[s+1]`, `tok_rows[mt]`). No atomics. +/// `acc[t,h] = Σ_{r ∈ rows(t)} dn[r,h] * wts[r] * unscale` (rows in row order) +/// Grid: `[s, ceil(hid/BLOCK_H)]`; block `[BLOCK_H,1]`. +const MOE_SCATTER_ADD_DET_SRC: &str = r#" +#include +#define BLOCK_H 128 + +extern "C" __global__ void moe_scatter_add_det_f32( + const float* __restrict__ dn, // [mt, hid] sorted-row expert outputs + const int* __restrict__ tok_starts, // [s+1] CSR row offsets per token + const int* __restrict__ tok_rows, // [mt] row ids grouped by token (fixed order) + const float* __restrict__ wts, // [mt] router weights + float* acc, // [s, hid] output accumulator (pre-zeroed) + int s, int hid, float unscale) +{ + int t = (int)blockIdx.x; // token ∈ [0, s) + int h = (int)(blockIdx.y * BLOCK_H + threadIdx.x); // hidden dim + if (t < s && h < hid) { + float sum = 0.f; + int r0 = tok_starts[t]; + int r1 = tok_starts[t + 1]; + // Fixed-order reduction over this token's rows → deterministic. + for (int j = r0; j < r1; ++j) { + int r = tok_rows[j]; + sum += dn[r * hid + h] * (wts[r] * unscale); + } + acc[t * hid + h] = sum; + } +} + +// f16-input variant: reads the down-GEMM output directly as __half (no separate +// cast_f16_f32 + [mt,hid] f32 materialization). Same deterministic CSR order. +extern "C" __global__ void moe_scatter_add_det_f16( + const __half* __restrict__ dn, + const int* __restrict__ tok_starts, + const int* __restrict__ tok_rows, + const float* __restrict__ wts, + float* acc, + int s, int hid, float unscale) +{ + int t = (int)blockIdx.x; + int h = (int)(blockIdx.y * BLOCK_H + threadIdx.x); + if (t < s && h < hid) { + float sum = 0.f; + int r0 = tok_starts[t]; + int r1 = tok_starts[t + 1]; + for (int j = r0; j < r1; ++j) { + int r = tok_rows[j]; + sum += __half2float(dn[r * hid + h]) * (wts[r] * unscale); + } + acc[t * hid + h] = sum; + } +} +"#; + +/// Deterministic MoE scatter-add (drop-in for [`moe_scatter_add`] on the prefill +/// path). `tidx_host` is the per-row token index (callers already build it on the +/// host before uploading). Builds a CSR index grouping rows by token in ascending +/// row order, uploads it, and runs the atomic-free deterministic kernel. +/// +/// `acc` is WRITTEN (not accumulated) — every output token is fully summed here, +/// so it need not be pre-zeroed, but tokens with no routed rows are set to 0. +pub fn moe_scatter_add_det( + dev: &dyn Device, + dn: &Tensor, // [mt, hid] f32 down-GEMM output (sorted) + tidx_host: &[u32], // [mt] token index per row (host) + wts: &Tensor, // [mt] f32 router weights + acc: &Tensor, // [s, hid] f32 accumulator + s: usize, + mt: usize, + hid: usize, + unscale: f32, + in_f16: bool, // true → `dn` is f16 (read directly, no cast_f16_f32) +) -> Result<()> { + // Build CSR (tok_starts[s+1], tok_rows[mt]) grouping rows by token. Rows are + // appended in ascending row order → fixed, deterministic accumulation order. + let mut counts = vec![0i32; s + 1]; + for &t in tidx_host { counts[t as usize + 1] += 1; } + for i in 0..s { counts[i + 1] += counts[i]; } // prefix sum → tok_starts + let tok_starts = counts; // [s+1] + let mut cursor: Vec = tok_starts[..s].to_vec(); + let mut tok_rows = vec![0i32; mt]; + for (r, &t) in tidx_host.iter().enumerate() { + let ti = t as usize; + tok_rows[cursor[ti] as usize] = r as i32; + cursor[ti] += 1; + } + let ts_bytes: Vec = tok_starts.iter().flat_map(|v| v.to_le_bytes()).collect(); + let tr_bytes: Vec = tok_rows.iter().flat_map(|v| v.to_le_bytes()).collect(); + let ts_dev = dev.upload(&ts_bytes).map_err(|e| Error::Msg(format!("moe_scatter_add_det: upload tok_starts: {e:?}")))?; + let tr_dev = dev.upload(&tr_bytes).map_err(|e| Error::Msg(format!("moe_scatter_add_det: upload tok_rows: {e:?}")))?; + + let s_i = (s as i32).to_le_bytes().to_vec(); + let hid_i = (hid as i32).to_le_bytes().to_vec(); + let uscale_f = unscale.to_le_bytes().to_vec(); + const BLOCK_H: u32 = 128; + let grid = [s as u32, (hid as u32).div_ceil(BLOCK_H), 1]; + let block = [BLOCK_H, 1, 1]; + dev.dispatch_raw_cuda( + MOE_SCATTER_ADD_DET_SRC, + "moe_scatter_add_det.cu", + if in_f16 { "moe_scatter_add_det_f16" } else { "moe_scatter_add_det_f32" }, + &[ + (dn.buffer.as_ref(), 0), + (ts_dev.as_ref(), 0), + (tr_dev.as_ref(), 0), + (wts.buffer.as_ref(), 0), + (acc.buffer.as_ref(), 0), + ], + &[s_i, hid_i, uscale_f], + grid, + block, + 0, + false, + ) +} + +// Stable token-CSR scan: one thread per output token scans ALL mt sorted rows in +// ASCENDING row order, appending matching rows into tok_rows[toff[t]..]. No atomics +// → deterministic row order per token (== the host CSR moe_scatter_add_det builds). +const MOE_TOKEN_CSR_SRC: &str = r#" +extern "C" __global__ void moe_token_csr( + const unsigned* __restrict__ sorted_tok, const unsigned* __restrict__ toff, + unsigned* __restrict__ tok_rows, int mt, int s) +{ + int t = blockIdx.x*blockDim.x + threadIdx.x; if (t >= s) return; + unsigned pos = toff[t]; + for (int r = 0; r < mt; r++) { if (sorted_tok[r] == (unsigned)t) { tok_rows[pos] = (unsigned)r; pos++; } } +} +"#; + +/// DETERMINISTIC fully-on-device scatter-add (no host sync): builds the per-token +/// CSR (toff, tok_rows) ON DEVICE from `sorted_tok` (hist→prefix→stable scan) then +/// runs the deterministic moe_scatter_add_det kernel. Same result as the host +/// `moe_scatter_add_det` but host-sync-free → graph-capturable. +#[allow(clippy::too_many_arguments)] +pub fn moe_scatter_add_det_dev( + dev: &dyn Device, + dn: &Tensor, + sorted_tok: &Tensor, + wts: &Tensor, + acc: &Tensor, + s: usize, + mt: usize, + hid: usize, + unscale: f32, + in_f16: bool, +) -> Result<()> { + let i = |x: i32| x.to_le_bytes().to_vec(); + let counts = Tensor::new(dev.alloc_zeroed(s * 4).map_err(|e| Error::Msg(format!("{e:?}")))?, vec![s], DType::U32); + dev.dispatch_raw_cuda(MOE_HIST_SRC, "moe_hist.cu", "moe_hist", + &[(sorted_tok.buffer.as_ref(), sorted_tok.offset), (counts.buffer.as_ref(), 0)], + &[i(mt as i32)], [(mt as u32).div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + let toff = Tensor::empty(dev, vec![s + 1], DType::U32)?; + let cursor = Tensor::empty(dev, vec![s], DType::U32)?; + dev.dispatch_raw_cuda(MOE_PREFIX_SRC, "moe_prefix.cu", "moe_prefix", + &[(counts.buffer.as_ref(), 0), (toff.buffer.as_ref(), 0), (cursor.buffer.as_ref(), 0)], + &[i(s as i32)], [1, 1, 1], [32, 1, 1], 0, false)?; + let tok_rows = Tensor::empty(dev, vec![mt], DType::U32)?; + dev.dispatch_raw_cuda(MOE_TOKEN_CSR_SRC, "moe_token_csr.cu", "moe_token_csr", + &[(sorted_tok.buffer.as_ref(), sorted_tok.offset), (toff.buffer.as_ref(), 0), (tok_rows.buffer.as_ref(), 0)], + &[i(mt as i32), i(s as i32)], [(s as u32).div_ceil(128), 1, 1], [128, 1, 1], 0, false)?; + let s_i = (s as i32).to_le_bytes().to_vec(); + let hid_i = (hid as i32).to_le_bytes().to_vec(); + let uscale_f = unscale.to_le_bytes().to_vec(); + dev.dispatch_raw_cuda( + MOE_SCATTER_ADD_DET_SRC, "moe_scatter_add_det.cu", + if in_f16 { "moe_scatter_add_det_f16" } else { "moe_scatter_add_det_f32" }, + &[(dn.buffer.as_ref(), 0), (toff.buffer.as_ref(), 0), (tok_rows.buffer.as_ref(), 0), + (wts.buffer.as_ref(), 0), (acc.buffer.as_ref(), 0)], + &[s_i, hid_i, uscale_f], + [s as u32, (hid as u32).div_ceil(128), 1], [128, 1, 1], 0, false)?; + Ok(()) +} + +/// Expand per-expert CSR offsets `[n_exp+1]` into a per-row expert-id vector +/// `[mt]` on device: `idx[r] = e` for every `r` in `[off[e], off[e+1])`. +/// Graph-safe replacement for the host `triples.map(|(e,_,_)| e)` build -- lets +/// the on-device Marlin W4A16 MoE path get per-row expert ids without a host dl. +const MOE_EXPERT_IDS_SRC: &str = r#" +extern "C" __global__ void moe_expert_ids_from_offsets( + const unsigned int* __restrict__ off, // [n_exp+1] + unsigned int* __restrict__ idx, // [mt] + int n_exp) +{ + int e = blockIdx.x; + if (e >= n_exp) return; + unsigned int lo = off[e]; + unsigned int hi = off[e + 1]; + for (unsigned int r = lo + threadIdx.x; r < hi; r += blockDim.x) + idx[r] = (unsigned int)e; +} +"#; + +/// Build a per-row expert-id tensor `[mt]` (u32) from CSR offsets `[n_exp+1]`. +/// One block per expert fills its contiguous row range. Fully device-side. +pub fn moe_expert_ids_from_offsets(dev: &dyn Device, off: &Tensor, n_exp: usize, mt: usize) -> Result { + let idx = Tensor::empty(dev, vec![mt], DType::U32)?; + dev.dispatch_raw_cuda(MOE_EXPERT_IDS_SRC, "moe_expert_ids.cu", "moe_expert_ids_from_offsets", + &[(off.buffer.as_ref(), off.offset), (idx.buffer.as_ref(), 0)], + &[(n_exp as i32).to_le_bytes().to_vec()], + [n_exp as u32, 1, 1], [256, 1, 1], 0, false)?; + Ok(idx) +} + +/// Raw CUDA source for `relu2_scale_f16`: `out[i] = max(0, (float)in[i])^2 * scale` +/// where `in` and `out` are __half (f16). Fuses the cast-to-f32, relu, square, +/// scale, and cast-back-to-f16 that the MoE prefill loop previously did by +/// downloading to host, computing on CPU, and re-uploading. One kernel replaces +/// the full host-round-trip between the up and down GEMMs. +const RELU2_SCALE_F16_SRC: &str = r#" +#include +extern "C" __global__ void relu2_scale_f16( + const __half* __restrict__ inp, + __half* __restrict__ out, + int n, float scale) +{ + int i = (int)(blockIdx.x * blockDim.x + threadIdx.x); + if (i < n) { + float v = __half2float(inp[i]); + if (v < 0.f) v = 0.f; + float lim = sqrtf(65504.0f / scale); + v = fminf(v, lim); // clamp to f16-square-safe range (Inf/NaN -> lim, no overflow) + out[i] = __float2half(v * v * scale); + } +} +"#; + +/// Fused relu² + scalar scale for f16 tensors, all on device. +/// Computes `out[i] = max(0, x[i])^2 * scale` (x and out are f16). +/// Used to replace the host round-trip between the MoE expert up and down GEMMs: +/// old: dl(a_f16) → relu2+scale on host → ul(a2_f16) [forces GPU sync] +/// new: relu2_scale_f16(a_f16, scale) [stays on device] +pub fn relu2_scale_f16(dev: &dyn Device, x: &Tensor, scale: f32) -> Result { + let n = x.elem_count(); + let out = Tensor::empty(dev, x.shape.clone(), DType::F16)?; + let scale_bytes = scale.to_le_bytes().to_vec(); + let n_bytes = (n as i32).to_le_bytes().to_vec(); + let block = 256u32; + let grid = (n as u32).div_ceil(block); + dev.dispatch_raw_cuda( + RELU2_SCALE_F16_SRC, + "relu2_scale_f16.cu", + "relu2_scale_f16", + &[(x.buffer.as_ref(), 0), (out.buffer.as_ref(), 0)], + &[n_bytes, scale_bytes], + [grid, 1, 1], + [block, 1, 1], + 0, + false, + )?; + Ok(out) +} + +/// Build an Elementwise `out[i] = act(a[i])` kernel for `dtype`. (`mt_gelu` +/// is bench-only in the metaltile corpus — no registered correctness test — so +/// we hand-build the IR the same way `binop_kernel` does for add/mul.) +fn unary_act_kernel(name: &str, dtype: DType, kind: ActKind) -> Kernel { + let mut k = Kernel::new(name); + for (pname, is_out) in [("a", false), ("c", true)] { + k.params.push(Param { + name: pname.into(), + dtype, + shape: Shape::scalar(), + is_output: is_out, + kind: ParamKind::Tensor, + }); + } + k.body.push_op(Op::ProgramId { axis: 0 }, ValueId::new(0)); + k.body.push_op( + Op::Load { src: "a".into(), indices: vec![IndexExpr::Value(ValueId::new(0))], mask: None, other: None }, + ValueId::new(1), + ); + k.body.push_op(Op::Activation { kind, value: ValueId::new(1) }, ValueId::new(2)); + k.body.push_op_no_result(Op::Store { + dst: "c".into(), + indices: vec![IndexExpr::Value(ValueId::new(0))], + value: ValueId::new(2), + mask: None, + }); + k +} + +/// Fused multiply-add into an accumulator, elementwise IN-PLACE: +/// `acc[i] += x[i] · s[i]`. Lets the MoE expert sum stay ON-DEVICE — each +/// expert's `down` output is folded into `acc` on the GPU (one final download +/// per layer instead of one per expert). `s` is the per-expert weight broadcast +/// to `[len]`. Dispatches `mt_fma_inplace`. +pub fn fma_inplace(dev: &dyn Device, acc: &Tensor, x: &Tensor, s: &Tensor) -> Result<()> { + let mut k = Kernel::new("mt_fma_inplace"); + for (pname, is_out) in [("acc", true), ("x", false), ("s", false)] { + k.params.push(Param { name: pname.into(), dtype: acc.dtype, shape: Shape::scalar(), is_output: is_out, kind: ParamKind::Tensor }); + } + let idx = || vec![IndexExpr::Value(ValueId::new(0))]; + k.body.push_op(Op::ProgramId { axis: 0 }, ValueId::new(0)); + k.body.push_op(Op::Load { src: "acc".into(), indices: idx(), mask: None, other: None }, ValueId::new(1)); + k.body.push_op(Op::Load { src: "x".into(), indices: idx(), mask: None, other: None }, ValueId::new(2)); + k.body.push_op(Op::Load { src: "s".into(), indices: idx(), mask: None, other: None }, ValueId::new(3)); + k.body.push_op(Op::BinOp { op: BinOpKind::Mul, lhs: ValueId::new(2), rhs: ValueId::new(3) }, ValueId::new(4)); + k.body.push_op(Op::BinOp { op: BinOpKind::Add, lhs: ValueId::new(1), rhs: ValueId::new(4) }, ValueId::new(5)); + k.body.push_op_no_result(Op::Store { dst: "acc".into(), indices: idx(), value: ValueId::new(5), mask: None }); + k.mode = metaltile_core::ir::KernelMode::Elementwise; + let n = acc.elem_count() as u32; + let grid = Grid::d1(n.div_ceil(256), 256); + dev.dispatch(&k, &[Binding::Buffer(acc.buffer.clone()), Binding::Buffer(x.buffer.clone()), Binding::Buffer(s.buffer.clone())], grid)?; + Ok(()) +} + +/// GELU activation (PyTorch tanh approximation, = `gelu_pytorch_tanh`), +/// elementwise. Hand-built `Activation(Gelu)` kernel. Used by ViT / SigLIP / +/// CLIP towers and the GELU-MLP LLM families. +pub fn gelu(dev: &dyn Device, x: &Tensor) -> Result { + let out = Tensor::empty(dev, x.shape.clone(), x.dtype)?; + let k = unary_act_kernel("mt_gelu", x.dtype, ActKind::Gelu); + let n = x.elem_count() as u32; + let grid = Grid::d1(n.div_ceil(256), 256); + dev.dispatch( + &k, + &[Binding::Buffer(x.buffer.clone()), Binding::Buffer(out.buffer.clone())], + grid, + )?; + Ok(out) +} + +/// LayerNorm `out = (x - mean) / sqrt(var + eps) * w + b`, normalized over the +/// last dim. Mean-subtracting + bias (unlike RMSNorm). Dispatches the +/// Reduction-mode `mt_layer_norm` (one threadgroup per row, block = n/4 — needs +/// the row width `n` divisible by `4·lsize`; ViT/SigLIP widths are). Used by +/// every transformer with LayerNorm (vision towers, BERT-style, GPT-2, …). +pub fn layer_norm(dev: &dyn Device, x: &Tensor, weight: &Tensor, bias: &Tensor, eps: f32) -> Result { + let n = *x.shape.last().ok_or_else(|| Error::Msg("layer_norm: scalar input".into()))?; + let rows = x.elem_count() / n; + let out = Tensor::empty(dev, x.shape.clone(), x.dtype)?; + let eps_buf = scalar_buf(dev, eps.to_bits())?; + let k = lookup("mt_layer_norm", x.dtype)?; + let grid = Grid { grid: [rows as u32, 1, 1], block: [(n / 4) as u32, 1, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(weight.buffer.clone()), + Binding::Buffer(bias.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Buffer(eps_buf), + Binding::Scalar((n as u32).to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out) +} + +/// Fused SwiGLU `out = silu(gate) * up`, elementwise. Dispatches `mt_swiglu`. +pub fn swiglu(dev: &dyn Device, gate: &Tensor, up: &Tensor) -> Result { + if gate.shape != up.shape { + return Err(Error::Msg("swiglu: gate/up shape mismatch".into())); + } + elementwise_kernel( + dev, + "mt_swiglu", + gate.dtype, + gate.shape.clone(), + vec![Binding::Buffer(gate.buffer.clone()), Binding::Buffer(up.buffer.clone())], + ) +} + +/// Embedding gather: `out[t, :] = table[indices[t], :]`. `table` is +/// `[vocab, dim]`, `indices` is `[n_tokens]` (u32). Dispatches `ffai_gather`. +pub fn gather(dev: &dyn Device, table: &Tensor, indices: &Tensor) -> Result { + if table.shape.len() != 2 { + return Err(Error::Msg(format!("gather: table must be 2-D, got {:?}", table.shape))); + } + let dim = table.shape[1]; + let n_tokens = indices.elem_count(); + let k = lookup("ffai_gather", table.dtype)?; + let out = Tensor::empty(dev, vec![n_tokens, dim], table.dtype)?; + let n = (n_tokens * dim) as u32; + let grid = Grid::d1(n.div_ceil(256), 256); + dev.dispatch( + &k, + &[ + Binding::Buffer(table.buffer.clone()), + Binding::Buffer(indices.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Scalar((dim as u32).to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out) +} + +/// DeepSeek-V4 partial RoPE: rotate only the rope tail of each head +/// (`[n_nope .. head_dim]`, adjacent-pair / GPT-J convention) in place; the +/// nope dims pass through. `qk` is `[n_heads, head_dim]`. Full-attention +/// layers: YaRN disabled (freq_scale=1, ext_factor=0). Dispatches +/// `ffai_dsv4_partial_rope` (grid `[n_heads, half_rot, 1]`). Mutates and +/// returns a view of `qk`'s buffer (matches the Swift in-place RoPE). +#[allow(clippy::too_many_arguments)] +pub fn dsv4_partial_rope( + dev: &dyn Device, + qk: &Tensor, + n_heads: u32, + head_dim: u32, + n_nope: u32, + half_rot: u32, + position: u32, + theta_base: f32, + inverse: bool, +) -> Result { + let k = lookup("ffai_dsv4_partial_rope", qk.dtype)?; + let u = |x: u32| Binding::Scalar(x.to_le_bytes().to_vec()); + let f = |x: f32| Binding::Scalar(x.to_le_bytes().to_vec()); + // Bind qk as both input and output → in-place (nope dims preserved). + let bindings = vec![ + Binding::Buffer(qk.buffer.clone()), + Binding::Buffer(qk.buffer.clone()), + u(head_dim), + u(n_nope), + u(half_rot), + u(position), + f(theta_base), + u(if inverse { 1 } else { 0 }), + f(1.0), // freq_scale (YaRN off) + f(0.0), // ext_factor + f(0.0), // corr_low + f(1.0), // corr_high + ]; + // TIER-1 launch-geometry fix: move half_rot from grid.y into block.y + // (product unchanged → byte-identical gid_y = blockIdx.y*blockDim.y + + // threadIdx.y under Grid3D codegen). + let grid = Grid { grid: [n_heads, 1, 1], block: [1, half_rot, 1] }; + dev.dispatch(&k, &bindings, grid)?; + Ok(Tensor::new(qk.buffer.clone(), qk.shape.clone(), qk.dtype)) +} + +/// Softmax over the last dim, row-wise. Dispatches `mt_softmax`. Row width +/// `n` must be a multiple of 1024 (the kernel's 4-elems/thread loop). +pub fn softmax(dev: &dyn Device, x: &Tensor) -> Result { + let n = *x.shape.last().ok_or_else(|| Error::Msg("softmax: scalar input".into()))?; + if n % 1024 != 0 { + return Err(Error::Msg(format!("softmax: row width {n} must be a multiple of 1024"))); + } + let rows = x.elem_count() / n; + let k = lookup("mt_softmax", x.dtype)?; + let out = Tensor::empty(dev, x.shape.clone(), x.dtype)?; + let grid = Grid { grid: [rows as u32, 1, 1], block: [256, 1, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(x.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Scalar((n as u32).to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out) +} + +/// Llama-style rotary position embedding applied to a `[n_heads, head_dim]` +/// Q or K tensor at sequence `position`. Dispatches `ffai_rope_llama`. Pass +/// the freq-band knobs disabled (`scale_factor=1`, `low=1`, `high=1`, +/// `orig_max=1e9`) for vanilla RoPE. +#[allow(clippy::too_many_arguments)] +pub fn rope_llama( + dev: &dyn Device, + qk: &Tensor, + position: u32, + theta_base: f32, + scale_factor: f32, + low_freq_factor: f32, + high_freq_factor: f32, + original_max_position: f32, +) -> Result { + let head_dim = *qk.shape.last().ok_or_else(|| Error::Msg("rope: scalar input".into()))?; + let n_heads = qk.elem_count() / head_dim; + let half = head_dim / 2; + let k = lookup("ffai_rope_llama", qk.dtype)?; + let out = Tensor::empty(dev, qk.shape.clone(), qk.dtype)?; + // TIER-1 launch-geometry fix: move `half` from grid.y into block.y + // (product unchanged → byte-identical gid_y under Grid3D codegen). + let grid = Grid { grid: [n_heads as u32, 1, 1], block: [1, half as u32, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(qk.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Scalar((head_dim as u32).to_le_bytes().to_vec()), + Binding::Scalar((half as u32).to_le_bytes().to_vec()), + Binding::Scalar(position.to_le_bytes().to_vec()), + Binding::Scalar(theta_base.to_le_bytes().to_vec()), + Binding::Scalar(scale_factor.to_le_bytes().to_vec()), + Binding::Scalar(low_freq_factor.to_le_bytes().to_vec()), + Binding::Scalar(high_freq_factor.to_le_bytes().to_vec()), + Binding::Scalar(original_max_position.to_le_bytes().to_vec()), + ], + grid, + )?; + Ok(out) +} + +/// Batched Llama-style RoPE: apply position-dependent rotation to ALL T rows in +/// ONE dispatch. Replaces the T-loop of per-token `rope_llama` calls in the +/// prefill attention path, eliminating the dl(q_all)/dl(k_all)/dl(v_all) + S×dl +/// overhead for the attention layers. +/// +/// `qk`: [T, n_heads, head_dim] float +/// `positions`: [T] u32 positions (one per token row) +/// returns: [T, n_heads, head_dim] rotated (same dtype as qk) +pub fn rope_llama_many( + dev: &dyn Device, + qk: &Tensor, + positions: &Tensor, + n_heads: usize, + head_dim: usize, + theta_base: f32, + scale_factor: f32, + low_freq_factor: f32, + high_freq_factor: f32, + original_max_position: f32, +) -> Result { + use metaltile_core::ir::KernelMode; + let t = qk.elem_count() / (n_heads * head_dim); + let half = head_dim / 2; + let k = cached_ir("ffai_rope_llama_many", qk.dtype, || { + let mut kk = metaltile_std::ffai::rope_llama_many::ffai_rope_llama_many::kernel_ir_for(qk.dtype); + kk.mode = KernelMode::Grid3D; + kk + }); + let out = Tensor::empty(dev, qk.shape.clone(), qk.dtype)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let f = |v: f32| Binding::Scalar(v.to_le_bytes().to_vec()); + let row_stride = (n_heads * head_dim) as u32; + // TIER-1 launch-geometry fix: move `half` from grid.z into block.z + // (product unchanged → byte-identical gid_z under Grid3D codegen). + let grid = Grid { grid: [t as u32, n_heads as u32, 1], block: [1, 1, half as u32] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(qk.buffer.clone()), + Binding::Buffer(positions.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(head_dim as u32), + u(half as u32), + u(row_stride), + f(theta_base), + f(scale_factor), + f(low_freq_factor), + f(high_freq_factor), + f(original_max_position), + ], + grid, + )?; + Ok(out) +} + +/// Batched KV-cache append: write T tokens' K (or V) rows into the per-head +/// cache in ONE dispatch. Replaces the T-loop of per-token `kv_append` calls. +/// +/// `src`: [T, n_kv_heads, head_dim] +/// `positions`: [T] u32 positions +/// `cache`: [n_kv_heads, max_seq, head_dim] (pre-allocated) +pub fn kv_append_many( + dev: &dyn Device, + src: &Tensor, + positions: &Tensor, + cache: &Tensor, + n_kv_heads: usize, + head_dim: usize, + max_seq: usize, +) -> Result<()> { + use metaltile_core::ir::KernelMode; + let t = src.elem_count() / (n_kv_heads * head_dim); + let k = cached_ir("kv_cache_update_many", src.dtype, || { + let mut kk = metaltile_std::ffai::kv_cache_update_many::kv_cache_update_many::kernel_ir_for(src.dtype); + kk.mode = KernelMode::Grid3D; + kk + }); + let total = t * n_kv_heads * head_dim; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch( + &k, + &[ + Binding::Buffer(src.buffer.clone()), + Binding::Buffer(positions.buffer.clone()), + Binding::Buffer(cache.buffer.clone()), + u(head_dim as u32), + u(max_seq as u32), + u((n_kv_heads * head_dim) as u32), + ], + // PERF: pack threads/block (was 1). Global linear element id; total = + // t*n_kv_heads*head_dim. Pick largest of {256,64,1} dividing total exactly. + { + let b = if total % 256 == 0 { 256u32 } else if total % 64 == 0 { 64u32 } else { 1u32 }; + Grid { grid: [(total as u32) / b, 1, 1], block: [b, 1, 1] } + }, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{dsv4_mhc_sinkhorn_split, permute_q4_from_marlin, permute_q4_to_marlin, quantize_q4, quantize_nvfp4, dec_ue4m3, E2M1_LUT}; + + /// NVFP4 quantize → dequantize round-trip reconstruction error must be on par + /// with Q4 (the existing argmax-correct path). Validated standalone at ~9.5% on + /// GB10; here we just gate that the Rust quantizer reconstructs the same way and + /// stays comfortably under Q4's error ceiling for a representative weight. + #[test] + fn nvfp4_reconstruction_on_par_with_q4() { + let (m, k) = (64usize, 2688usize); // k % 16 == 0 and % 32 == 0 + // deterministic pseudo-gaussian weights + let w: Vec = (0..m * k).map(|i| { + let x = ((i * 2654435761usize) & 0xffff) as f32 / 65535.0 - 0.5; + let y = (((i * 40503usize) >> 3) & 0xffff) as f32 / 65535.0 - 0.5; + (x + y) * 0.08 // roughly normal-ish, small magnitude + }).collect(); + + // NVFP4 reconstruct + let (codes, scales, global) = quantize_nvfp4(&w, m, k); + let nblk = k / 16; + let mut sse_nv = 0f64; let mut ss = 0f64; + for r in 0..m { + for b in 0..nblk { + let bs = dec_ue4m3(scales[r * nblk + b]) * global; + for i in 0..16 { + let elem = b * 16 + i; + let word = elem / 8; let nib = elem % 8; + let code = (codes[r * (k / 8) + word] >> (nib * 4)) & 0xf; + let sign = if code & 8 != 0 { -1.0 } else { 1.0 }; + let rec = sign * E2M1_LUT[(code & 7) as usize] * bs; + let orig = w[r * k + elem]; + let d = (rec - orig) as f64; sse_nv += d * d; ss += (orig as f64) * (orig as f64); + } + } + } + // Q4 reconstruct (block-32 amax/7) for the same weight + let (qs, qsc) = quantize_q4(&w, m, k); + let bpr = k / 32; let mut sse_q4 = 0f64; + for r in 0..m { + for b in 0..bpr { + let d = qsc[r * bpr + b]; + for word in 0..4 { + for i in 0..8 { + let packed = qs[r * bpr * 4 + b * 4 + word]; + let mut q = ((packed >> (i * 4)) & 0xf) as i32; + if q >= 8 { q -= 16; } // sign-extend nibble + let rec = q as f32 * d; + let orig = w[r * k + b * 32 + word * 8 + i]; + let e = (rec - orig) as f64; sse_q4 += e * e; + } + } + } + } + let rmse_nv = (sse_nv / ss).sqrt(); + let rmse_q4 = (sse_q4 / ss).sqrt(); + println!("nvfp4 rel_rmse={:.4} q4 rel_rmse={:.4}", rmse_nv, rmse_q4); + // NVFP4 must be within 1.5× of Q4 (it was actually slightly better in C++). + assert!(rmse_nv < rmse_q4 * 1.5, "NVFP4 recon {rmse_nv:.4} >> Q4 {rmse_q4:.4}"); + } + + /// Verify `permute_q4_to_marlin` + `permute_q4_from_marlin` is a lossless round-trip. + #[test] + fn marlin_permute_roundtrip() { + let (n_exp, n_out, k_in) = (4, 128, 64); // n_out and k_in are multiples of 64/32 + let bpr = k_in / 32; + let total_rows = n_exp * n_out; + // Use a deterministic weight pattern to expose nibble-mapping bugs. + let wv: Vec = (0..total_rows * k_in) + .map(|i| (i as f32 * 0.019 - 0.5).sin() * 7.0) + .collect(); + let (qs_std, _scales) = quantize_q4(&wv, total_rows, k_in); + + // Round-trip: std → Marlin → std + let qs_mar = permute_q4_to_marlin(&qs_std, n_exp, n_out, k_in); + let qs_back = permute_q4_from_marlin(&qs_mar, n_exp, n_out, k_in); + + assert_eq!(qs_std.len(), qs_mar.len(), "Marlin layout must have same u32 count"); + assert_eq!(qs_std, qs_back, "round-trip must be lossless"); + + // Also verify individual nibble values are preserved at a few spot checks. + // Standard layout: qs_std[exp*n_out*bpr*4 + row*bpr*4 + blk*4 + word] + // nibble at k=5: blk=0, word=0, nib_in_word=5 + for e in 0..n_exp { + for row in 0..n_out { + for k in [0usize, 7, 15, 16, 31] { + let blk = k / 32; + let word = (k % 32) / 8; + let nib_in = (k % 32) % 8; + let src_word = qs_std[e * n_out * bpr * 4 + row * bpr * 4 + blk * 4 + word]; + let nib_std = (src_word >> (nib_in * 4)) & 0xf; + let dst_word = qs_back[e * n_out * bpr * 4 + row * bpr * 4 + blk * 4 + word]; + let nib_back = (dst_word >> (nib_in * 4)) & 0xf; + assert_eq!(nib_std, nib_back, + "nibble mismatch at e={e} row={row} k={k}: std={nib_std} back={nib_back}"); + } + } + } + } + + #[test] + fn sinkhorn_comb_is_doubly_stochastic() { + // mixes/base arbitrary; after enough Sinkhorn iters the 4×4 comb + // matrix must be (near) doubly-stochastic: every row and column ≈ 1. + let mixes: Vec = (0..24).map(|i| (i as f32 - 12.0) * 0.2).collect(); + let base: Vec = (0..24).map(|i| (i as f32) * 0.01 - 0.1).collect(); + let (pre, post, comb) = dsv4_mhc_sinkhorn_split(&mixes, [1.0, 1.0, 1.0], &base, 1e-6, 20); + assert_eq!((pre.len(), post.len(), comb.len()), (4, 4, 16)); + for i in 0..4 { + let row: f32 = (0..4).map(|j| comb[i * 4 + j]).sum(); + let col: f32 = (0..4).map(|j| comb[j * 4 + i]).sum(); + assert!((row - 1.0).abs() < 1e-2, "row {i} sum {row}"); + assert!((col - 1.0).abs() < 1e-2, "col {i} sum {col}"); + } + // pre is sigmoid+eps ∈ (0,1+eps); post is 2·sigmoid ∈ (0,2). + assert!(pre.iter().all(|&x| x > 0.0 && x < 1.01)); + assert!(post.iter().all(|&x| x > 0.0 && x < 2.0)); + } +} + +// ── Fused Mamba projection split (Part-B: 3 strided_col_copy → 1 dispatch) ─ +// +// Splits proj [s, in_proj_out] into z [s, di], xbc [s, conv_dim], dt_raw [s, m_nh] +// in ONE device dispatch. Replaces the 3 sequential `strided_col_copy` launches +// that were 20-37% of prefill time at S=512-2048 (138 dispatches/forward). +pub fn mamba_split_proj( + dev: &dyn Device, + proj: &Tensor, + s: usize, + in_proj_out: usize, + di: usize, + conv_dim: usize, + m_nh: usize, +) -> Result<(Tensor, Tensor, Tensor)> { + let kernel = cached_ir("mamba_split_proj", DType::F32, || { + use metaltile_core::ir::KernelMode; + let mut k = metaltile_std::ffai::ssm::mamba_split_proj::kernel_ir_for(); + k.mode = KernelMode::Grid3D; + k + }); + let z_out = Tensor::empty(dev, vec![s * di], DType::F32)?; + let xbc_out = Tensor::empty(dev, vec![s * conv_dim], DType::F32)?; + let dt_out = Tensor::empty(dev, vec![s * m_nh], DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch( + &kernel, + &[ + Binding::Buffer(proj.buffer.clone()), + Binding::Buffer(z_out.buffer.clone()), + Binding::Buffer(xbc_out.buffer.clone()), + Binding::Buffer(dt_out.buffer.clone()), + u(in_proj_out as u32), + u(di as u32), + u(conv_dim as u32), + u(m_nh as u32), + ], + // PERF: pack threads/block (was block:[1,1,1] = 1 thread/block, 1/32 of a + // warp — this split was ~its full cost in launch overhead, not memory). The + // kernel uses a global linear element id (gid_x), so block size is transparent + // as long as the grid covers exactly s*in_proj_out threads. in_proj_out (10304 + // = 64*161) is a multiple of 64 → s*in_proj_out always divisible by 64. + { + let nt = (s * in_proj_out) as u32; + let b = if nt % 64 == 0 { 64u32 } else if nt % 32 == 0 { 32u32 } else { 1u32 }; + Grid { grid: [nt / b, 1, 1], block: [b, 1, 1] } + }, + )?; + Ok((z_out, xbc_out, dt_out)) +} + +// ── Fused Mamba conv-output split (Part-B: 3 strided_col_copy → 1 dispatch) ─ +// +// Splits yc_silu [s, conv_dim] into x [s, di], b [s, ng*ds], c [s, ng*ds] +// in ONE device dispatch. +pub fn mamba_split_conv( + dev: &dyn Device, + yc_silu: &Tensor, + s: usize, + conv_dim: usize, + di: usize, + ng_ds: usize, +) -> Result<(Tensor, Tensor, Tensor)> { + let kernel = cached_ir("mamba_split_conv", DType::F32, || { + use metaltile_core::ir::KernelMode; + let mut k = metaltile_std::ffai::ssm::mamba_split_conv::kernel_ir_for(); + k.mode = KernelMode::Grid3D; + k + }); + let x_out = Tensor::empty(dev, vec![s * di], DType::F32)?; + let b_out = Tensor::empty(dev, vec![s * ng_ds], DType::F32)?; + let c_out = Tensor::empty(dev, vec![s * ng_ds], DType::F32)?; + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch( + &kernel, + &[ + Binding::Buffer(yc_silu.buffer.clone()), + Binding::Buffer(x_out.buffer.clone()), + Binding::Buffer(b_out.buffer.clone()), + Binding::Buffer(c_out.buffer.clone()), + u(conv_dim as u32), + u(di as u32), + u(ng_ds as u32), + ], + // PERF: 256 threads/block (was 1). Global linear element id; conv_dim (6144) + // is a multiple of 256 → s*conv_dim divisible by 256, exact grid, no OOB. + { let n = (s * conv_dim) as u32; let b = 256u32; Grid { grid: [n / b, 1, 1], block: [b, 1, 1] } }, + )?; + Ok((x_out, b_out, c_out)) +} + +// ── Batched gated group RMSNorm (Mamba2, prefill) ───────────────────────── +// +// Processes S tokens in one dispatch. Each thread-group handles one (token, +// norm-group) pair. Replaces the host dl+compute+upload loop in the +// CONV_DEVICE prefill path (TODO comment at line ~1543 of modeltests/lib.rs). +pub fn gated_group_rmsnorm_batched( + dev: &dyn Device, + y: &Tensor, + z: &Tensor, + w: &Tensor, + eps: f32, + s: usize, + di: usize, + gs: usize, +) -> Result { + let ng = di / gs; + let kernel = cached_ir("gated_group_rmsnorm_batched", DType::F32, || { + use metaltile_core::ir::KernelMode; + let mut k = metaltile_std::ffai::ssm::gated_group_rmsnorm_batched::kernel_ir_for(); + k.mode = KernelMode::Reduction; + k + }); + let out = Tensor::empty(dev, vec![s * di], DType::F32)?; + let eps_buf = Tensor::new(scalar_buf(dev, eps.to_bits())?, vec![1], DType::F32); + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + dev.dispatch( + &kernel, + &[ + Binding::Buffer(y.buffer.clone()), + Binding::Buffer(z.buffer.clone()), + Binding::Buffer(w.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + Binding::Buffer(eps_buf.buffer.clone()), + u(gs as u32), + u(ng as u32), + ], + Grid { grid: [(s * ng) as u32, 1, 1], block: [(gs / 4) as u32, 1, 1] }, + )?; + Ok(out) +} + +const BATCHED_DEQUANT_Q4_SRC: &str = r#" +#include +extern "C" __global__ void moe_batched_dequant_q4( + const unsigned int* __restrict__ qs, + const __half* __restrict__ sc, + const unsigned int* __restrict__ eid, + __half* __restrict__ out, + int n_active, int n_out, int k_in, int bpr_in +) { + int i = blockIdx.x * blockDim.x + threadIdx.x; + if (i >= n_active * n_out * k_in) return; + int slot = i / (n_out * k_in); + int local = i - slot * n_out * k_in; + int r = local / k_in; + int c = local - r * k_in; + int expert = (int)eid[slot]; + int b = c / 32; + int j = c % 32; + int w_idx = j / 8; + int nib_idx= j % 8; + int global_blk = expert * n_out * bpr_in + r * bpr_in + b; + unsigned int word = qs[global_blk * 4 + w_idx]; + unsigned int nib = (word >> (nib_idx * 4)) & 0xFu; + int q_signed = (nib >= 8u) ? (int)nib - 16 : (int)nib; + float scale = __half2float(sc[global_blk]); + __half val = __float2half((float)q_signed * scale); + out[i] = val; +} +"#; + +/// Fused grouped-GEMM MoE expert pass (CUDA 13+, `NEMOTRON_GROUPED_GEMM=1`). +/// +/// ONE batched NVRTC dequant kernel (`BATCHED_DEQUANT_Q4_SRC`) dequants ALL active +/// experts' weights into a contiguous per-slot scratch buffer in a SINGLE dispatch +/// (thread `i` → slot `i/(n_out*k_in)` → correct slot offset; reads `eid[slot]` for +/// the global expert id). Then per-expert `gemm_tc_off` reads from each slot. The +/// per-slot writes are what the plain `dispatch`+`ffai_dequant_q4` path could NOT do +/// (that kernel always writes `out[0..n_elem]`, so every expert clobbered slot 0). +/// +/// Device `relu2_scale_f16` eliminates the host relu² round-trip. No host syncs +/// between experts; everything stays on device. +/// +/// Uses pre-allocated scratch (`up_scratch`/`dn_scratch`) to avoid per-layer +/// `cuMemAlloc`. Slot `i` of the UP scratch is `i*inter*hid*2` bytes; DN is +/// `i*hid*inter*2`. Scratch must hold at least `n_active` slots. +/// +/// Returns `[mt, hid]` f16 output (relu² applied, 1/256 scaled). Caller scatter-adds. +const MOE_W4A16_SRC: &str = r#" +#include +#include +using namespace nvcuda; + +extern "C" __global__ void moe_w4a16( + const unsigned int* __restrict__ qs, // Q4 weights: n_exp*n_out*(k_in/32)*4 + const __half* __restrict__ scales, // scales: n_exp*n_out*(k_in/32) + const __half* __restrict__ x, // [m_total, k_in] f16, sorted by expert + const unsigned int* __restrict__ indices, // [m_total] expert ids + __half* __restrict__ out, // [m_total, n_out] f16 + int m_total, int n_out, int k_in) +{ + // ── Tile / warp coordinates ──────────────────────────────────────────── + const int n_tile_base = blockIdx.x * 64; + const int m_tile_base = blockIdx.y * 64; + const int warp_id = threadIdx.x >> 5; + // 2×2 warp layout: warp_id 0→(m=0,n=0), 1→(m=0,n=32), 2→(m=32,n=0), 3→(m=32,n=32) + (void)(threadIdx.x & 31); // lane — unused in this kernel (warp_id sufficient) + const int warp_m_base = (warp_id >> 1) * 32; // 0 or 32 + const int warp_n_base = (warp_id & 1) * 32; // 0 or 32 + + // ── Shared memory layout ─────────────────────────────────────────────── + // smem_X [64×32] f16 = 4096 bytes — row-major [m_local, k_local] + // smem_WT [32×64] f16 = 4096 bytes — row-major [k_local, n_local] = W transposed + // smem_C [64×64] f32 = 16384 bytes — output accumulation tile + __shared__ __half smem_X [64 * 32]; // [m_local][k_local] + __shared__ __half smem_WT[32 * 64]; // [k_local][n_local] ← W^T + __shared__ float smem_C [64 * 64]; + + const int bpr = k_in / 32; + const int nblk_per_expert = n_out * bpr; + + // ── WMMA accumulators: 2 m-tiles × 2 n-tiles (each 16×16) per warp ─── + // warp_m_base covers 32 rows = 2 × m16, warp_n_base covers 32 cols = 2 × n16. + wmma::fragment c_frag[2][2]; + + // ── Per-expert sub-run loop (same as moe_bgemm_q4_bm64) ─────────────── + int sub_offset = 0; + for (int _sub = 0; _sub < 64; _sub++) { + int cur_row = m_tile_base + sub_offset; + if (sub_offset >= 64 || cur_row >= m_total) break; + unsigned cur_exp = indices[cur_row]; + + // Find sub-run end. + int sub_end = sub_offset; + for (int ii = sub_offset; ii < 64; ii++) { + int r = m_tile_base + ii; + if (r >= m_total || indices[r] != cur_exp) break; + sub_end = ii + 1; + } + + // Reset accumulators. + #pragma unroll + for (int mi = 0; mi < 2; mi++) + #pragma unroll + for (int ni = 0; ni < 2; ni++) + wmma::fill_fragment(c_frag[mi][ni], 0.0f); + + // ── K-loop: BK=32 per step ───────────────────────────────────────── + for (int kb = 0; kb < k_in; kb += 32) { + + // Stage X: 128 threads load 64×32 = 2048 halves (16 each). + // Thread t → row = t/2, k_half = (t&1)*16 → 16 consecutive k-values. + { + int t = threadIdx.x; + int local_row = t >> 1; + int k_half = (t & 1) << 4; // 0 or 16 + int global_row = m_tile_base + local_row; + bool in_run = (local_row >= sub_offset) & (local_row < sub_end) & (global_row < m_total); + // Clamp global_row to avoid OOB pointer arithmetic when out of run. + int safe_row = in_run ? global_row : 0; + const __half* xsrc = x + (size_t)safe_row * k_in + kb + k_half; + __half* xdst = smem_X + local_row * 32 + k_half; + __half zero = __float2half(0.f); + #pragma unroll + for (int i = 0; i < 16; i++) + xdst[i] = in_run ? xsrc[i] : zero; + } + + // Dequant W → smem_WT[k_local * 64 + n_local]. + // 128 threads × 16 elements = 2048 halves covering [32, 64]. + // Thread t → flat=t*16+i → k_local=flat/64, n_local=flat%64. + { + int t = threadIdx.x; + #pragma unroll + for (int i = 0; i < 16; i++) { + int flat = t * 16 + i; // 0..2047 + int k_local = flat >> 6; // 0..31 + int n_local = flat & 63; // 0..63 + int global_n = n_tile_base + n_local; + int k = kb + k_local; + // Q4 block for (global_n, k): + int row_blk = global_n * bpr + k / 32; // within-expert row block + int blk = (int)cur_exp * nblk_per_expert + row_blk; + int lane_in_blk = k & 31; + unsigned word = qs[(size_t)blk * 4 + lane_in_blk / 8]; + unsigned nib = (word >> ((lane_in_blk & 7) * 4)) & 0xfu; + int q_s = (int)(nib >= 8u ? nib - 16u : nib); + float sc_f = __half2float(scales[blk]); + smem_WT[k_local * 64 + n_local] = __float2half((float)q_s * sc_f); + } + } + __syncthreads(); + + // ── WMMA: 2 k-steps × 2 m-tiles × 2 n-tiles per warp ───────── + wmma::fragment a_frag; + wmma::fragment b_frag; + #pragma unroll + for (int ki = 0; ki < 2; ki++) { // 2 k-steps of 16 in BK=32 + int k_off = ki * 16; + #pragma unroll + for (int mi = 0; mi < 2; mi++) { + // A: smem_X[(warp_m_base + mi*16)*32 + k_off], ld=32 + wmma::load_matrix_sync(a_frag, + smem_X + (warp_m_base + mi * 16) * 32 + k_off, + 32); + #pragma unroll + for (int ni = 0; ni < 2; ni++) { + // B: smem_WT[k_off * 64 + (warp_n_base + ni*16)], ld=64 + wmma::load_matrix_sync(b_frag, + smem_WT + k_off * 64 + (warp_n_base + ni * 16), + 64); + wmma::mma_sync(c_frag[mi][ni], a_frag, b_frag, c_frag[mi][ni]); + } + } + } + __syncthreads(); + } // end K-loop + + // ── Store C frags → smem_C ───────────────────────────────────────── + #pragma unroll + for (int mi = 0; mi < 2; mi++) { + #pragma unroll + for (int ni = 0; ni < 2; ni++) { + wmma::store_matrix_sync( + smem_C + (warp_m_base + mi * 16) * 64 + (warp_n_base + ni * 16), + c_frag[mi][ni], 64, wmma::mem_row_major); + } + } + __syncthreads(); + + // ── Write smem_C → global out (only rows in this expert's run) ───── + { + int t = threadIdx.x; + #pragma unroll + for (int e = 0; e < 32; e++) { + int flat = t * 32 + e; + int local_row = flat >> 6; // 0..63 + int local_col = flat & 63; // 0..63 + int global_m = m_tile_base + local_row; + int global_n = n_tile_base + local_col; + bool valid = (local_row >= sub_offset) & (local_row < sub_end) + & (global_m < m_total) & (global_n < n_out); + if (valid) + out[(size_t)global_m * n_out + global_n] = + __float2half(smem_C[local_row * 64 + local_col]); + } + } + __syncthreads(); + + sub_offset = sub_end; + } // end per-expert sub-run loop +} +"#; + +// ── Marlin-permuted Q4 layout ───────────────────────────────────────────── +// +// `permute_q4_to_marlin` converts the standard row-major Q4 layout produced +// by `quantize_q4` into a tile-major "Marlin" layout that enables fully +// coalesced 128-bit global loads in `moe_w4a16_marlin`. +// +// Standard layout (per expert, row r = output neuron): +// qs[expert * n_out * bpr * 4 + r * bpr * 4 + b * 4 + word] +// where bpr = k_in/32, each u32 holds 8 nibbles for k-positions word*8..word*8+8 +// of block b (k-range b*32..(b+1)*32). +// +// Marlin tile layout (what `moe_w4a16_marlin` expects): +// For each expert e, each 64-row n-tile nt (n_tile_base = nt*64), each k-tile +// kt (k_tile_base = kt*32 = the kb value): +// tile_base = e*(n_tiles*k_tiles*256) + nt*(k_tiles*256) + kt*256 +// u32 at tile_base + j (j ∈ 0..256): +// k_local = j % 32 (position within BK=32 tile) +// row_group = j / 32 (0..7, each group = 8 consecutive rows) +// nibble i → output row (row_group*8 + i), k = k_tile_base + k_local +// +// This layout makes thread `t` load tile_base+t and tile_base+t+128 — two +// consecutive addresses — covering the full 64×32 tile with 4 cache lines +// per warp per load, vs ~64 scattered cache lines in the standard layout. +// +// `n_exp` = total experts in the weight pool (all experts, packed contiguously) +// `n_out` = output features per expert (must be multiple of 64) +// `k_in` = input features (must be multiple of 32) +// `qs_std` = standard `quantize_q4` output: `[n_exp * n_out * bpr * 4]` u32 +// Returns: `[n_exp * (n_out/64) * (k_in/32) * 256]` u32 (same total byte count) +pub fn permute_q4_to_marlin( + qs_std: &[u32], + n_exp: usize, + n_out: usize, + k_in: usize, +) -> Vec { + assert_eq!(n_out % 64, 0, "permute_q4_to_marlin: n_out must be multiple of 64"); + assert_eq!(k_in % 32, 0, "permute_q4_to_marlin: k_in must be multiple of 32"); + let bpr = k_in / 32; // Q4 blocks per weight row + let n_tiles = n_out / 64; // number of 64-row n-tiles per expert + let k_tiles = k_in / 32; // = bpr + + // Total u32s is identical to input: n_exp * n_out * bpr * 4 = n_exp * n_tiles * k_tiles * 256 + // Verify: n_tiles * 256 = (n_out/64) * 256 = n_out * 4; k_tiles = bpr → same total ✓ + let total = n_exp * n_tiles * k_tiles * 256; + assert_eq!(qs_std.len(), n_exp * n_out * bpr * 4); + let mut out = vec![0u32; total]; + + for e in 0..n_exp { + let exp_std_base = e * n_out * bpr * 4; + let exp_mar_base = e * n_tiles * k_tiles * 256; + for nt in 0..n_tiles { + for kt in 0..k_tiles { + let tile_mar_base = exp_mar_base + nt * k_tiles * 256 + kt * 256; + // Tile covers output rows [nt*64 .. (nt+1)*64) and + // k-positions [kt*32 .. (kt+1)*32). + // Fill 256 u32s: u32 at j → k_local=j%32, row_group=j/32. + for j in 0..256usize { + let k_local = j % 32; + let row_group = j / 32; // 0..7 + // Pack 8 nibbles for consecutive rows row_group*8..row_group*8+8 + // at k-position k_tile_base + k_local. + let k_abs = kt * 32 + k_local; // absolute k-position + let k_blk = k_abs / 32; // = kt (k_abs is already kt*32+k_local) + debug_assert_eq!(k_blk, kt); + let k_in_blk = k_abs % 32; // = k_local (position within block) + let word_in_blk = k_in_blk / 8; // which of the 4 u32s in the block + let nib_in_word = k_in_blk % 8; // which nibble in that u32 + + let mut packed = 0u32; + for i in 0..8usize { + let row = nt * 64 + row_group * 8 + i; // absolute output row + // Standard layout address for this (row, k_abs): + // qs_std[exp_std_base + row * bpr * 4 + k_blk * 4 + word_in_blk] + let src_word = qs_std[exp_std_base + row * bpr * 4 + k_blk * 4 + word_in_blk]; + let nib = (src_word >> (nib_in_word * 4)) & 0xf; + packed |= nib << (i * 4); + } + out[tile_mar_base + j] = packed; + } + } + } + } + out +} + +// ── Inverse: Marlin → standard layout (for validation) ───────────────────── +pub fn permute_q4_from_marlin( + qs_mar: &[u32], + n_exp: usize, + n_out: usize, + k_in: usize, +) -> Vec { + assert_eq!(n_out % 64, 0); + assert_eq!(k_in % 32, 0); + let bpr = k_in / 32; + let n_tiles = n_out / 64; + let k_tiles = bpr; + let mut out = vec![0u32; n_exp * n_out * bpr * 4]; + + for e in 0..n_exp { + let exp_std_base = e * n_out * bpr * 4; + let exp_mar_base = e * n_tiles * k_tiles * 256; + for nt in 0..n_tiles { + for kt in 0..k_tiles { + let tile_mar_base = exp_mar_base + nt * k_tiles * 256 + kt * 256; + for j in 0..256usize { + let k_local = j % 32; + let row_group = j / 32; + let k_abs = kt * 32 + k_local; + let k_blk = kt; + let k_in_blk = k_local; + let word_in_blk = k_in_blk / 8; + let nib_in_word = k_in_blk % 8; + let src_word = qs_mar[tile_mar_base + j]; + for i in 0..8usize { + let row = nt * 64 + row_group * 8 + i; + let nib = (src_word >> (i * 4)) & 0xf; + let dst_idx = exp_std_base + row * bpr * 4 + k_blk * 4 + word_in_blk; + // Clear the nibble slot and set it. + out[dst_idx] &= !(0xf << (nib_in_word * 4)); + out[dst_idx] |= nib << (nib_in_word * 4); + } + let _ = k_abs; // used only for assertion above + } + } + } + } + out +} + +// ── Marlin-layout W4A16 WMMA kernel ─────────────────────────────────────── +// +// Same contract as `moe_w4a16` but expects `qs` in Marlin tile-major layout +// (as produced by `permute_q4_to_marlin`). Replaces the per-nibble scattered +// read loop with two coalesced u32 loads per thread covering the full 64×32 +// tile, cutting Q4 global reads from ~64 scattered cache lines to 4 cache +// lines per warp per K-step. +// +// Tiling: 64(M) × 64(N) × 32(K) per block, 4 warps (128 threads), 2×2 warp +// layout, double-buffer not used (smem fits in one phase: X=4KB, WT=4KB, C=16KB). +// Grid: [n_out/64, ceil(m_total/64)]. +const MOE_W4A16_MARLIN_SRC: &str = r#" +#include +#include +using namespace nvcuda; + +// ── Kernel: moe_w4a16_marlin ─────────────────────────────────────────────── +// qs layout: Marlin tile-major. For expert e, n-tile nt, k-tile kt (= kb/32): +// tile_base = e*(n_tiles*k_tiles*256) + nt*(k_tiles*256) + kt*256 +// u32 at tile_base+j (j in 0..256): +// k_local = j%32, row_group = j/32 +// nibble i → output row row_group*8+i at k-position kt*32+k_local +// +// Thread t loads tile_base+t and tile_base+t+128 → perfectly coalesced. +extern "C" __global__ void moe_w4a16_marlin( + const unsigned int* __restrict__ qs, // Marlin Q4: n_exp*(n_out/64)*(k_in/32)*256 + const __half* __restrict__ scales, // standard: n_exp*n_out*(k_in/32) f16 + const __half* __restrict__ x, // [m_total, k_in] f16, sorted by expert + const unsigned int* __restrict__ indices, // [m_total] expert ids + __half* __restrict__ out, // [m_total, n_out] f16 + int m_total, int n_out, int k_in) +{ + // ── Tile / warp coordinates ──────────────────────────────────────────── + const int n_tile_base = blockIdx.x * 64; + const int m_tile_base = blockIdx.y * 64; + const int warp_id = threadIdx.x >> 5; + const int lane = threadIdx.x & 31; + // 2×2 warp layout: warp 0→(m=0,n=0), 1→(m=0,n=32), 2→(m=32,n=0), 3→(m=32,n=32) + const int warp_m_base = (warp_id >> 1) * 32; + const int warp_n_base = (warp_id & 1) * 32; + const int t = threadIdx.x; // 0..127 + + // ── Shared memory layout ─────────────────────────────────────────────── + // smem_X [64×32] f16 = 4096 bytes — row-major [m_local, k_local] + // smem_WT [32×64] f16 = 4096 bytes — row-major [k_local, n_local] (W^T) + // smem_C [64×64] f32 = 16384 bytes — output accumulation tile + __shared__ __half smem_X [64 * 32]; + __shared__ __half smem_WT[32 * 64]; + __shared__ float smem_C [64 * 64]; + + const int bpr = k_in / 32; // Q4 blocks per weight row (= k_tiles) + const int n_tiles = n_out / 64; + const int nblk_per_expert = n_out * bpr; // for scale indexing (unchanged layout) + const int nt = n_tile_base / 64; // n-tile index for this block + + // ── WMMA accumulators ───────────────────────────────────────────────── + wmma::fragment c_frag[2][2]; + + // ── Per-expert sub-run loop ──────────────────────────────────────────── + int sub_offset = 0; + for (int _sub = 0; _sub < 64; _sub++) { + int cur_row = m_tile_base + sub_offset; + if (sub_offset >= 64 || cur_row >= m_total) break; + unsigned cur_exp = indices[cur_row]; + + // Find sub-run end. + int sub_end = sub_offset; + for (int ii = sub_offset; ii < 64; ii++) { + int r = m_tile_base + ii; + if (r >= m_total || indices[r] != cur_exp) break; + sub_end = ii + 1; + } + + // Reset accumulators. + #pragma unroll + for (int mi = 0; mi < 2; mi++) + #pragma unroll + for (int ni = 0; ni < 2; ni++) + wmma::fill_fragment(c_frag[mi][ni], 0.0f); + + // Base of this expert's Marlin-packed Q4 in qs[]: + // expert e owns n_tiles * bpr * 256 u32s. + const unsigned int* qs_exp = qs + (size_t)cur_exp * n_tiles * bpr * 256; + + // ── K-loop: BK=32 per step ───────────────────────────────────────── + for (int kb = 0; kb < k_in; kb += 32) { + const int kt = kb / 32; // k-tile index + + // ── Stage X: 128 threads load 64×32 = 2048 halves ──────────── + // Thread t: row = t/2, k_half = (t&1)*16, loads 16 halves. + { + int local_row = t >> 1; + int k_half = (t & 1) << 4; + int global_row = m_tile_base + local_row; + bool in_run = (local_row >= sub_offset) & (local_row < sub_end) & (global_row < m_total); + int safe_row = in_run ? global_row : 0; + const __half* xsrc = x + (size_t)safe_row * k_in + kb + k_half; + __half* xdst = smem_X + local_row * 32 + k_half; + __half zero = __float2half(0.f); + #pragma unroll + for (int i = 0; i < 16; i++) + xdst[i] = in_run ? xsrc[i] : zero; + } + + // ── Stage WT: coalesced Marlin tile load + dequant ──────────── + // Tile base in qs_exp: nt * bpr * 256 + kt * 256 + // Thread t loads u32s at positions t and t+128 in the 256-u32 tile. + // Each u32 encodes 8 nibbles: nibble i → output row row_group*8+i + // at k_local within this k-tile, where row_group = j/32, k_local = j%32. + { + const unsigned int* tile = qs_exp + (size_t)nt * bpr * 256 + (size_t)kt * 256; + + // Load 0: j = t (covers row_groups 0..3 for t in 0..127) + { + unsigned word = tile[t]; + int k_local = t % 32; + int row_group = t / 32; + #pragma unroll + for (int i = 0; i < 8; i++) { + int n_local = row_group * 8 + i; // output row within tile + unsigned nib = (word >> (i * 4)) & 0xf; + int q_s = (int)(nib >= 8u ? nib - 16u : nib); + int blk = (n_tile_base + n_local) * bpr + kt; // scale block + float sc = __half2float(scales[(size_t)cur_exp * nblk_per_expert + blk]); + smem_WT[k_local * 64 + n_local] = __float2half((float)q_s * sc); + } + } + + // Load 1: j = t + 128 + { + unsigned word = tile[t + 128]; + int j2 = t + 128; + int k_local = j2 % 32; + int row_group = j2 / 32; // 4..7 for t in 0..127 + #pragma unroll + for (int i = 0; i < 8; i++) { + int n_local = row_group * 8 + i; + unsigned nib = (word >> (i * 4)) & 0xf; + int q_s = (int)(nib >= 8u ? nib - 16u : nib); + int blk = (n_tile_base + n_local) * bpr + kt; + float sc = __half2float(scales[(size_t)cur_exp * nblk_per_expert + blk]); + smem_WT[k_local * 64 + n_local] = __float2half((float)q_s * sc); + } + } + } + __syncthreads(); + + // ── WMMA: 2 k-steps × 2 m-tiles × 2 n-tiles per warp ───────── + wmma::fragment a_frag; + wmma::fragment b_frag; + #pragma unroll + for (int ki = 0; ki < 2; ki++) { + int k_off = ki * 16; + #pragma unroll + for (int mi = 0; mi < 2; mi++) { + wmma::load_matrix_sync(a_frag, + smem_X + (warp_m_base + mi * 16) * 32 + k_off, 32); + #pragma unroll + for (int ni = 0; ni < 2; ni++) { + wmma::load_matrix_sync(b_frag, + smem_WT + k_off * 64 + (warp_n_base + ni * 16), 64); + wmma::mma_sync(c_frag[mi][ni], a_frag, b_frag, c_frag[mi][ni]); + } + } + } + __syncthreads(); + } // end K-loop + + // ── Store C frags → smem_C ───────────────────────────────────────── + #pragma unroll + for (int mi = 0; mi < 2; mi++) + #pragma unroll + for (int ni = 0; ni < 2; ni++) + wmma::store_matrix_sync( + smem_C + (warp_m_base + mi * 16) * 64 + (warp_n_base + ni * 16), + c_frag[mi][ni], 64, wmma::mem_row_major); + __syncthreads(); + + // ── Write smem_C → global out ────────────────────────────────────── + #pragma unroll + for (int e = 0; e < 32; e++) { + int flat = t * 32 + e; + int local_row = flat >> 6; + int local_col = flat & 63; + int global_m = m_tile_base + local_row; + int global_n = n_tile_base + local_col; + bool valid = (local_row >= sub_offset) & (local_row < sub_end) + & (global_m < m_total) & (global_n < n_out); + if (valid) + out[(size_t)global_m * n_out + global_n] = + __float2half(smem_C[local_row * 64 + local_col]); + } + __syncthreads(); + + sub_offset = sub_end; + } // end per-expert sub-run loop + (void)lane; +} +"#; + +// ── 128×128×32 Marlin W4A16 WMMA kernel ─────────────────────────────────── +// +// Upgrades from 64×64×32 (4-warp) to 128×128×32 (8-warp) tile: +// - 4× accumulator area → 4× compute density at the same global bandwidth +// - Scales preloaded into smem at the start of each K-iteration (128 f32 +// values, avoids 8 __half2float per dequant inner loop) +// - Double-buffered X and WT staging (ping-pong, 2 smem slots each) +// +// Grid: [n_out/128, ceil(m_total/128)], block: [256, 1, 1] (8 warps). +// Warp layout (2 rows × 4 cols): +// warp_m_base = (warp_id >> 2) * 64 → row 0 or 64 within 128-row M-tile +// warp_n_base = (warp_id & 3) * 32 → col 0, 32, 64, or 96 in 128-col N-tile +// c_frag[4][2]: 4 mi × 2 ni = 8 WMMA frags covering 64×32 output per warp +// +// smem (per block): +// smem_X [2][128*32] f16 = 16384 bytes double-buffer +// smem_WT[2][32*128] f16 = 16384 bytes double-buffer +// smem_SC[128] f32 = 512 bytes scale preload (128 × f32) +// smem_C [128*128] f32 = 65536 bytes accumulator +// Total = 98816 bytes (~96KB, within GB10 228KB/SM) +// +// WT tile load (Marlin layout, two consecutive 64-N tiles per 128-N block): +// First half (n_local 0..63): tile = qs_exp + nt*bpr*256 + kt*256 +// Second half (n_local 64..127): tile = qs_exp + (nt+1)*bpr*256 + kt*256 +// 256 threads × 1 u32 each per tile → fully coalesced single-pass. +// Each u32 → 8 nibbles → 8 rows → written to smem_WT[k_local*128 + n_local]. +// +// `n_out % 128 == 0` required; `k_in % 32 == 0` required. +const MOE_W4A16_MARLIN128_SRC: &str = r#" +#include +#include +using namespace nvcuda; + +// ── Kernel: moe_w4a16_marlin128 ──────────────────────────────────────────── +// +// 128(M)×128(N)×32(K) tile, 256 threads (8 warps), 2-row × 4-col warp layout. +// +// Three improvements over moe_w4a16_marlin (64×64×32): +// 1. 4× larger tile: 128×128 accumulator → 4× compute density. +// 2. Scale preload: 128 f32 scales loaded into smem at each K-step, +// eliminating __half2float() inside the per-nibble dequant loop. +// 3. Double-buffered X and WT staging: next tile's loads overlap +// current tile's WMMA compute (ping-pong smem slots). +// +// qs layout: Marlin tile-major, same 64-row tiles as moe_w4a16_marlin. +// A 128-N-tile block straddles two consecutive 64-N Marlin tiles (nt0, nt0+1). +// 256 threads × 1 u32/tile → both tiles load in a single fully-coalesced pass. +// +// smem breakdown (per block): +// smem_X [2][128*32] f16 = 16384 B (double-buffered X) +// smem_WT[2][32*128] f16 = 16384 B (double-buffered W^T, already dequanted) +// smem_SC[128] f32 = 512 B (preloaded scales for this K-step) +// smem_C [128*128] f32 = 65536 B (accumulator) +// Total = 98816 B (~96 KB; GB10 has 228 KB/SM) +// +// n_out % 128 == 0 and k_in % 32 == 0 required. +extern "C" __global__ void moe_w4a16_marlin128( + const unsigned int* __restrict__ qs, // Marlin Q4: n_exp*(n_out/64)*(k_in/32)*256 + const __half* __restrict__ scales, // f16 scales: n_exp*n_out*(k_in/32) + const __half* __restrict__ x, // [m_total, k_in] f16 + const unsigned int* __restrict__ indices, // [m_total] expert ids + __half* __restrict__ out, // [m_total, n_out] f16 + int m_total, int n_out, int k_in) +{ + const int n_tile_base = blockIdx.x * 128; + const int m_tile_base = blockIdx.y * 128; + const int warp_id = threadIdx.x >> 5; // 0..7 + const int t = threadIdx.x; // 0..255 + + // Warp covers [warp_m_base..+64) × [warp_n_base..+32) of the 128×128 tile. + const int warp_m_base = (warp_id >> 2) * 64; // 0 or 64 + const int warp_n_base = (warp_id & 3) * 32; // 0, 32, 64, or 96 + + // smem layout (all offsets in bytes): + // [0 .. 16384) smem_X [2][128*32] __half + // [16384 .. 32768) smem_WT[2][32*128] __half + // [32768 .. 33280) smem_SC[128] float + // [33280 .. 98816) smem_C [128*128] float + extern __shared__ char smem_raw[]; + __half* smem_X = (__half*)(smem_raw); + __half* smem_WT = (__half*)(smem_raw + 16384); + float* smem_SC = (float* )(smem_raw + 32768); + float* smem_C = (float* )(smem_raw + 33280); + + const int bpr = k_in / 32; + const int n_tiles64 = n_out / 64; + const int nblk_per_expert = n_out * bpr; + const int nt0 = blockIdx.x * 2; // first 64-N Marlin tile index + + // 4×2 WMMA accumulator per warp: 4 m-frags × 2 n-frags = 64(m) × 32(n). + wmma::fragment c_frag[4][2]; + + // ── Helper: load scales for kt into smem_SC ──────────────────────────── + // Threads 0..127 each load one scale; threads 128..255 idle. + // Caller must __syncthreads() after this before reading smem_SC. + #define LOAD_SCALES(kt_) \ + if (t < 128) { \ + int n_col = n_tile_base + t; \ + smem_SC[t] = __half2float( \ + scales[(size_t)cur_exp * nblk_per_expert + n_col * bpr + (kt_)]); \ + } + + // ── Helper: load X for kb_ into smem_X[slot_] ───────────────────────── + // Thread t: local_row = t>>1, k_half = (t&1)<<4 → 16 halves. + #define LOAD_X(slot_, kb_) \ + { \ + int lr = t >> 1, kh = (t & 1) << 4; \ + int gr = m_tile_base + lr; \ + bool ok = (lr >= sub_offset) & (lr < sub_end) & (gr < m_total); \ + int sr = ok ? gr : 0; \ + const __half* xs = x + (size_t)sr * k_in + (kb_) + kh; \ + __half* xd = smem_X + (slot_) * (128*32) + lr * 32 + kh; \ + __half zero = __float2half(0.f); \ + _Pragma("unroll") \ + for (int _i = 0; _i < 16; _i++) xd[_i] = ok ? xs[_i] : zero; \ + } + + // ── Helper: load WT for kt_ into smem_WT[slot_] ─────────────────────── + // Two loads: half-0 (n_local 0..63 from nt0) and half-1 (64..127 from nt0+1). + // 256 threads × 1 u32 per tile (256 u32s/tile) → perfectly coalesced. + // smem_SC must already be populated for kt_. + #define LOAD_WT(slot_, kt_) \ + { \ + /* half-0: n_local 0..63, Marlin tile nt0 */ \ + { \ + unsigned w0 = qs_exp[(size_t)nt0 * bpr * 256 + (size_t)(kt_) * 256 + t]; \ + int kl = t % 32, rg = t / 32; \ + _Pragma("unroll") \ + for (int _i = 0; _i < 8; _i++) { \ + int nl = rg * 8 + _i; \ + unsigned nib = (w0 >> (_i*4)) & 0xf; \ + int qs_val = (int)(nib >= 8u ? nib - 16u : nib); \ + smem_WT[(slot_)*(32*128) + kl*128 + nl] = \ + __float2half((float)qs_val * smem_SC[nl]); \ + } \ + } \ + /* half-1: n_local 64..127, Marlin tile nt0+1 */ \ + { \ + unsigned w1 = qs_exp[(size_t)(nt0+1) * bpr * 256 + (size_t)(kt_) * 256 + t]; \ + int kl = t % 32, rg = t / 32; \ + _Pragma("unroll") \ + for (int _i = 0; _i < 8; _i++) { \ + int nl = 64 + rg * 8 + _i; \ + unsigned nib = (w1 >> (_i*4)) & 0xf; \ + int qs_val = (int)(nib >= 8u ? nib - 16u : nib); \ + smem_WT[(slot_)*(32*128) + kl*128 + nl] = \ + __float2half((float)qs_val * smem_SC[nl]); \ + } \ + } \ + } + + // ── Per-expert sub-run loop ──────────────────────────────────────────── + int sub_offset = 0; + for (int _sub = 0; _sub < 128; _sub++) { + int cur_row = m_tile_base + sub_offset; + if (sub_offset >= 128 || cur_row >= m_total) break; + unsigned cur_exp = indices[cur_row]; + + int sub_end = sub_offset; + for (int ii = sub_offset; ii < 128; ii++) { + int r = m_tile_base + ii; + if (r >= m_total || indices[r] != cur_exp) break; + sub_end = ii + 1; + } + + #pragma unroll + for (int mi = 0; mi < 4; mi++) + #pragma unroll + for (int ni = 0; ni < 2; ni++) + wmma::fill_fragment(c_frag[mi][ni], 0.0f); + + const unsigned int* qs_exp = qs + (size_t)cur_exp * n_tiles64 * bpr * 256; + + // ── Prologue: fill smem_SC[kt=0], then load buf=0 X + WT ────────── + LOAD_SCALES(0) + __syncthreads(); + LOAD_X(0, 0) + LOAD_WT(0, 0) + __syncthreads(); + + // ── Main K-loop: compute buf, prefetch next into 1-buf ───────────── + for (int kb = 0; kb < k_in; kb += 32) { + const int kt = kb / 32; + const int buf = kt & 1; + const int next_buf = buf ^ 1; + const int next_kt = kt + 1; + const int next_kb = kb + 32; + + // Start prefetching scales for next K-tile while we compute. + // The __syncthreads() at the end of this iteration makes them visible + // before the next LOAD_WT reads smem_SC. + if (next_kb < k_in) { + LOAD_SCALES(next_kt) + } + + // WMMA compute on buf: 2 ki-steps × 4 mi-frags × 2 ni-frags. + wmma::fragment a_frag; + wmma::fragment b_frag; + #pragma unroll + for (int ki = 0; ki < 2; ki++) { + const int k_off = ki * 16; + #pragma unroll + for (int mi = 0; mi < 4; mi++) { + wmma::load_matrix_sync(a_frag, + smem_X + buf * (128*32) + (warp_m_base + mi*16) * 32 + k_off, + 32); + #pragma unroll + for (int ni = 0; ni < 2; ni++) { + wmma::load_matrix_sync(b_frag, + smem_WT + buf * (32*128) + k_off * 128 + (warp_n_base + ni*16), + 128); + wmma::mma_sync(c_frag[mi][ni], a_frag, b_frag, c_frag[mi][ni]); + } + } + } + __syncthreads(); // scales prefetch + compute done; safe to write next_buf + + if (next_kb < k_in) { + LOAD_X(next_buf, next_kb) + LOAD_WT(next_buf, next_kt) + __syncthreads(); // next_buf ready for next iteration + } + } // end K-loop + + // ── Store accumulators → smem_C ──────────────────────────────────── + #pragma unroll + for (int mi = 0; mi < 4; mi++) + #pragma unroll + for (int ni = 0; ni < 2; ni++) + wmma::store_matrix_sync( + smem_C + (warp_m_base + mi*16) * 128 + (warp_n_base + ni*16), + c_frag[mi][ni], 128, wmma::mem_row_major); + __syncthreads(); + + // ── Write smem_C → global out ────────────────────────────────────── + // 256 threads × 64 elements = 16384 = 128×128 ✓ + #pragma unroll + for (int e = 0; e < 64; e++) { + int flat = t * 64 + e; + int local_row = flat >> 7; + int local_col = flat & 127; + int global_m = m_tile_base + local_row; + int global_n = n_tile_base + local_col; + bool valid = (local_row >= sub_offset) & (local_row < sub_end) + & (global_m < m_total) & (global_n < n_out); + if (valid) + out[(size_t)global_m * n_out + global_n] = + __float2half(smem_C[local_row * 128 + local_col]); + } + __syncthreads(); + + sub_offset = sub_end; + } // end per-expert loop + + #undef LOAD_SCALES + #undef LOAD_X + #undef LOAD_WT +} +"#; + +/// W4A16 MoE grouped GEMM — Marlin coalesced layout. +/// +/// Drop-in replacement for `moe_w4a16` but requires `qs` to be in Marlin +/// tile-major layout (from `permute_q4_to_marlin`). Achieves coalesced +/// 128-bit global reads for Q4 weights: 4 cache lines per warp per K-step +/// vs ~64 in the standard nibble-scattered layout. +/// +/// Dispatches the 128×128×32 tile (256-thread, 8-warp) kernel when +/// `n_out % 128 == 0` for higher compute density; falls back to the +/// 64×64×32 tile kernel otherwise (`n_out % 64 == 0` required). +/// `n_out % 128 == 0` for higher compute density; falls back to the +/// 64×64×32 tile kernel otherwise (`n_out % 64 == 0` required). +#[allow(clippy::too_many_arguments)] +pub fn moe_w4a16_marlin( + dev: &dyn Device, + x: &Tensor, // [m_total, k_in] f16, sorted by expert + qs: &Tensor, // Marlin Q4 weights (permute_q4_to_marlin output) + scales: &Tensor, // f16 scales (unchanged from quantize_q4) + indices: &Tensor, // [m_total] u32 expert ids + m_total: usize, + n_out: usize, + k_in: usize, +) -> Result { + if n_out % 64 != 0 { + return Err(Error::Msg(format!("moe_w4a16_marlin: n_out {n_out} must be multiple of 64"))); + } + let out = Tensor::empty(dev, vec![m_total, n_out], DType::F16)?; + let m_total_i = m_total as i32; + let n_out_i = n_out as i32; + let k_in_i = k_in as i32; + + // Use the 128×128 tile only when BOTH n_out and k_in are multiples of 128 + // (the 128-tile strides K by 128; a non-128 k_in -- e.g. inter=1856 on the + // down-GEMM -- would read past the weight buffer). Else fall to the 64-tile. + if n_out % 128 == 0 && k_in % 128 == 0 { + dev.dispatch_raw_cuda( + MOE_W4A16_MARLIN128_SRC, + "moe_w4a16_marlin128.cu", + "moe_w4a16_marlin128", + &[ + (qs.buffer.as_ref(), qs.offset), + (scales.buffer.as_ref(), scales.offset), + (x.buffer.as_ref(), x.offset), + (indices.buffer.as_ref(), indices.offset), + (out.buffer.as_ref(), 0), + ], + &[ + m_total_i.to_le_bytes().to_vec(), + n_out_i.to_le_bytes().to_vec(), + k_in_i.to_le_bytes().to_vec(), + ], + [(n_out as u32) / 128, (m_total as u32).div_ceil(128), 1], + [256, 1, 1], + // smem: X_db(16384) + WT_db(16384) + SC(512) + C(65536) = 98816 bytes (~96 KB) + 98816, + false, + )?; + } else { + dev.dispatch_raw_cuda( + MOE_W4A16_MARLIN_SRC, + "moe_w4a16_marlin.cu", + "moe_w4a16_marlin", + &[ + (qs.buffer.as_ref(), qs.offset), + (scales.buffer.as_ref(), scales.offset), + (x.buffer.as_ref(), x.offset), + (indices.buffer.as_ref(), indices.offset), + (out.buffer.as_ref(), 0), + ], + &[ + m_total_i.to_le_bytes().to_vec(), + n_out_i.to_le_bytes().to_vec(), + k_in_i.to_le_bytes().to_vec(), + ], + [(n_out as u32) / 64, (m_total as u32).div_ceil(64), 1], + [128, 1, 1], + // smem: X(4096) + WT(4096) + C(16384) = 24576 bytes + 24576, + false, + )?; + } + Ok(out) +} + +/// W4A16 MoE grouped GEMM (Marlin-style, WMMA tensor cores, inline Q4 dequant). +/// +/// Drop-in replacement for `moe_bgemm_q4_bm64`. Uses `mma.sync m16n16k16` with +/// weights staged as W^T in smem — no f16 weight scratch in global memory, no +/// 2× weight bandwidth. `n_out % 64 == 0` required. +/// +/// Returns `[m_total, n_out]` f16 (same contract as `moe_bgemm_q4_bm64`). +#[allow(clippy::too_many_arguments)] +pub fn moe_w4a16( + dev: &dyn Device, + x: &Tensor, // [m_total, k_in] f16, sorted by expert + qs: &Tensor, // Q4 weights + scales: &Tensor, // f16 scales + indices: &Tensor, // [m_total] u32 expert ids + m_total: usize, + n_out: usize, + k_in: usize, +) -> Result { + if n_out % 64 != 0 { + return Err(Error::Msg(format!("moe_w4a16: n_out {n_out} must be multiple of 64"))); + } + let out = Tensor::empty(dev, vec![m_total, n_out], DType::F16)?; + let m_total_i = m_total as i32; + let n_out_i = n_out as i32; + let k_in_i = k_in as i32; + dev.dispatch_raw_cuda( + MOE_W4A16_SRC, + "moe_w4a16.cu", + "moe_w4a16", + &[ + (qs.buffer.as_ref(), qs.offset), + (scales.buffer.as_ref(), scales.offset), + (x.buffer.as_ref(), x.offset), + (indices.buffer.as_ref(), indices.offset), + (out.buffer.as_ref(), 0), + ], + &[ + m_total_i.to_le_bytes().to_vec(), + n_out_i.to_le_bytes().to_vec(), + k_in_i.to_le_bytes().to_vec(), + ], + [(n_out as u32) / 64, (m_total as u32).div_ceil(64), 1], + [128, 1, 1], + // smem: X(4096) + WT(4096) + C(16384) = 24576 bytes + 24576, + false, + )?; + Ok(out) +} + +#[allow(clippy::too_many_arguments)] +pub fn moe_grouped_gemm( + dev: &dyn Device, + qs_up: &Tensor, + sc_up: &Tensor, + qs_dn: &Tensor, + sc_dn: &Tensor, + xs_f16: &Tensor, // [mt, hid] f16 — rows sorted by expert id + g_starts: &[usize], // length = n_active + 1 + expert_ids: &[usize], // expert id for each group + hid: usize, + inter: usize, + bpr_up: usize, // hid / 32 + bpr_dn: usize, // inter / 32 + up_scratch: Option<&Tensor>, // Pre-allocated [n_exp*inter, hid] f16 + dn_scratch: Option<&Tensor>, // Pre-allocated [n_exp*hid, inter] f16 +) -> Result { + let n_active = expert_ids.len(); + let mt = g_starts.last().copied().unwrap_or(0); + if n_active == 0 || mt == 0 { + return Tensor::empty(dev, vec![mt.max(1), hid], DType::F16); + } + + // Experts are processed in chunks of `chunk`: each chunk's weights are + // dequanted (one batched NVRTC launch) into distinct slots of the scratch, + // then GEMM'd. Smaller chunks keep the just-dequanted f16 weight L2-cache-hot + // for its GEMM (a full-slab dequant evicts the cache → the GEMM re-reads from + // HBM, ~2× weight bandwidth). Measured best at chunk=1 on GB10 (per-expert + // dequant→GEMM, cache-hot). NEMOTRON_MOE_CHUNK overrides (default 1). + // Note: chunks do NOT overlap on the GPU (single ordered stream); the win is + // purely cache locality. At large S (high tokens/expert) the GEMM dominates + // and this path beats the baseline per-expert loop (+6% @ S=4096, +17% @ S=8192). + let chunk: usize = std::env::var("NEMOTRON_MOE_CHUNK").ok() + .and_then(|v| v.parse().ok()).filter(|&c| c >= 1).unwrap_or(1); + let n_chunks = n_active.div_ceil(chunk); + + // Per-slot weight sizes (f16 elements). + let up_slot = inter * hid; + let dn_slot = hid * inter; + + // Defensive bounds check on caller-supplied scratch pools. The chunked + // batched-dequant writes f16 expert weights to slot `c0..c1` of the scratch + // at byte offset `c0*{up,dn}_slot*2`, and the per-expert GEMM reads/writes + // `slot*{up,dn}_slot*2` — so the scratch MUST hold `n_active` full slots + // (`n_active*{up,dn}_slot` f16 elems). A caller binding an under-sized pool + // (e.g. sized for fewer active experts than this token batch routed to) + // triggers an out-of-bounds WRITE — silent corruption on Metal, a + // deterministic MMU-fault on CUDA. Fail loudly here, naming the shapes. + if let Some(scratch) = up_scratch { + let need = n_active * up_slot; + let have = scratch.buffer.len() / DType::F16.size_bytes(); + if need > have { + return Err(Error::Msg(format!( + "moe_grouped_gemm OOB: up_scratch needs {need} f16 elems \ + (n_active={n_active} * inter={inter} * hid={hid}) but pool holds {have}" + ))); + } + } + if let Some(scratch) = dn_scratch { + let need = n_active * dn_slot; + let have = scratch.buffer.len() / DType::F16.size_bytes(); + if need > have { + return Err(Error::Msg(format!( + "moe_grouped_gemm OOB: dn_scratch needs {need} f16 elems \ + (n_active={n_active} * hid={hid} * inter={inter}) but pool holds {have}" + ))); + } + } + + // NOTE: A grouped-batched cuBLAS variant (one cublasGemmGroupedBatchedEx per + // pass instead of the per-expert gemm_tc_off loop) was measured on GB10 @ + // S=2048 and found to be both INCORRECT (output garbage) and SLOWER than the + // per-expert loop: with ~107 active experts each group has only ~19 tokens, so + // the grouped GEMM is a skinny m≈19 GEMM that tiles poorly (≈10 TFLOP/s vs the + // per-expert path's 27), and the per-call cuMemAlloc/free of the device pointer + // arrays serializes the stream. Per-expert cuBLAS (27 TFLOP/s) remains best. + + // ── UP scratch: full `n_active` slots (distinct regions per chunk so chunk + // N+1's dequant overlaps chunk N's GEMMs — same-buffer reuse would serialize). ── + let up_w_owned: Option; + let up_w = if let Some(scratch) = up_scratch { + Tensor { buffer: scratch.buffer.clone(), offset: 0, shape: vec![n_active * inter, hid], dtype: DType::F16 } + } else { + up_w_owned = Some(Tensor::empty(dev, vec![n_active * inter, hid], DType::F16)?); + up_w_owned.as_ref().unwrap().clone() + }; + + // [mt, inter] f16 output for UP pass. + let up_out = Tensor::empty(dev, vec![mt, inter], DType::F16)?; + + // ── UP pass: chunked dequant → GEMM ───────────────────────────────────── + for ci in 0..n_chunks { + let c0 = ci * chunk; + let c1 = (c0 + chunk).min(n_active); + let cn = c1 - c0; + // Upload this chunk's expert ids. + let eid_bytes: Vec = expert_ids[c0..c1].iter().flat_map(|&e| (e as u32).to_le_bytes()).collect(); + let eid_dev = dev.upload(&eid_bytes) + .map_err(|e| Error::Msg(format!("moe_grouped_gemm: upload eid UP chunk {ci}: {e:?}")))?; + // Batched dequant for this chunk → slots 0..cn of up_w. + let total = (cn * up_slot) as u32; + let scalars = [ + (cn as i32).to_le_bytes().to_vec(), + (inter as i32).to_le_bytes().to_vec(), + (hid as i32).to_le_bytes().to_vec(), + (bpr_up as i32).to_le_bytes().to_vec(), + ]; + // Dequant writes to slots c0..c1 of the full scratch (distinct per chunk). + dev.dispatch_raw_cuda( + BATCHED_DEQUANT_Q4_SRC, "moe_batched_dequant_q4.cu", "moe_batched_dequant_q4", + &[ + (qs_up.buffer.as_ref(), qs_up.offset), + (sc_up.buffer.as_ref(), sc_up.offset), + (eid_dev.as_ref(), 0), + (up_w.buffer.as_ref(), c0 * up_slot * 2), + ], + &scalars, + [total.div_ceil(256), 1, 1], [256, 1, 1], 0, false, + ).map_err(|e| Error::Msg(format!("moe_grouped_gemm: batched dequant UP chunk {ci}: {e:?}")))?; + // GEMM each expert in the chunk (reads its global slot in up_w). + for j in 0..cn { + let slot = c0 + j; + let rows = g_starts[slot + 1] - g_starts[slot]; + if rows == 0 { continue; } + dev.gemm_tc_off( + xs_f16.buffer.as_ref(), xs_f16.offset + g_starts[slot] * hid * 2, + up_w.buffer.as_ref(), slot * up_slot * 2, + up_out.buffer.as_ref(), g_starts[slot] * inter * 2, + rows, inter, hid, DType::F16, + ).map_err(|e| Error::Msg(format!("moe_grouped_gemm: UP gemm slot={slot}: {e:?}")))?; + } + } + + // Device relu² + scale 1/256 (no host round-trip). + let up_relu2 = relu2_scale_f16(dev, &up_out, 1.0f32 / 256.0)?; + drop(up_out); + if up_scratch.is_none() { drop(up_w); let _ = up_w_owned; } + + // ── DN scratch: full `n_active` slots (distinct regions per chunk). ────── + let dn_w_owned: Option; + let dn_w = if let Some(scratch) = dn_scratch { + Tensor { buffer: scratch.buffer.clone(), offset: 0, shape: vec![n_active * hid, inter], dtype: DType::F16 } + } else { + dn_w_owned = Some(Tensor::empty(dev, vec![n_active * hid, inter], DType::F16)?); + dn_w_owned.as_ref().unwrap().clone() + }; + + let dn_out = Tensor::empty(dev, vec![mt, hid], DType::F16)?; + + // ── DN pass: chunked dequant → GEMM ───────────────────────────────────── + for ci in 0..n_chunks { + let c0 = ci * chunk; + let c1 = (c0 + chunk).min(n_active); + let cn = c1 - c0; + let eid_bytes: Vec = expert_ids[c0..c1].iter().flat_map(|&e| (e as u32).to_le_bytes()).collect(); + let eid_dev = dev.upload(&eid_bytes) + .map_err(|e| Error::Msg(format!("moe_grouped_gemm: upload eid DN chunk {ci}: {e:?}")))?; + let total = (cn * dn_slot) as u32; + let scalars = [ + (cn as i32).to_le_bytes().to_vec(), + (hid as i32).to_le_bytes().to_vec(), + (inter as i32).to_le_bytes().to_vec(), + (bpr_dn as i32).to_le_bytes().to_vec(), + ]; + dev.dispatch_raw_cuda( + BATCHED_DEQUANT_Q4_SRC, "moe_batched_dequant_q4.cu", "moe_batched_dequant_q4", + &[ + (qs_dn.buffer.as_ref(), qs_dn.offset), + (sc_dn.buffer.as_ref(), sc_dn.offset), + (eid_dev.as_ref(), 0), + (dn_w.buffer.as_ref(), c0 * dn_slot * 2), + ], + &scalars, + [total.div_ceil(256), 1, 1], [256, 1, 1], 0, false, + ).map_err(|e| Error::Msg(format!("moe_grouped_gemm: batched dequant DN chunk {ci}: {e:?}")))?; + for j in 0..cn { + let slot = c0 + j; + let rows = g_starts[slot + 1] - g_starts[slot]; + if rows == 0 { continue; } + dev.gemm_tc_off( + up_relu2.buffer.as_ref(), up_relu2.offset + g_starts[slot] * inter * 2, + dn_w.buffer.as_ref(), slot * dn_slot * 2, + dn_out.buffer.as_ref(), g_starts[slot] * hid * 2, + rows, hid, inter, DType::F16, + ).map_err(|e| Error::Msg(format!("moe_grouped_gemm: DN gemm slot={slot}: {e:?}")))?; + } + } + if dn_scratch.is_none() { drop(dn_w); let _ = dn_w_owned; } + + Ok(dn_out) +} + +// ════════════════════════════════════════════════════════════════════════════ +// NVFP4 (mxf4nvf4) grouped MoE GEMM — native Blackwell block-scaled FP4 mma. +// Fragment-packed layout: weights repacked once at load (nvfp4_pack_w), acts +// quantized+packed per layer (moe_fp4_actq_pack), one fixed-launch grouped mma +// over BM=16 tile descriptors (graph-safe). Requires METALTILE_CUDA_ARCH= +// compute_121a (or _120a) — base-arch PTX rejects the mma. +// ════════════════════════════════════════════════════════════════════════════ + +const MOE_FP4_SRC: &str = r#" +#include + +// UE4M3 codebook (monotonic): e=0 subnormal m/8*2^-7? NO — matches quantize_nvfp4: +// e==0 → (m/8)*0.0078125 (=2^-7), else (1+m/8)*2^(e-7). +__device__ __forceinline__ float dec_ue4m3(unsigned b){ + int e=(b>>3)&0xF, m=b&7; + return (e==0) ? (m/8.f)*0.0078125f : (1.f+m/8.f)*exp2f((float)(e-7)); +} +__device__ __forceinline__ unsigned enc_ue4m3(float s){ + if(!(s>0.f)) return 0u; + unsigned c; + if(s>=480.f) return 127u; + else { + int e; float fr=frexpf(s,&e); // s = fr*2^e, fr in [0.5,1) + int E=e+6; // s = (2*fr)*2^(E-7) + if(E<1){ // subnormal: v=(m/8)*2^-7 + int m=(int)rintf(s*1024.f); + c = (m>7) ? 8u : (unsigned)m; + } else { + int m=(int)rintf((2.f*fr-1.f)*8.f); + if(m>7){m=0;E+=1;} + if(E>15){E=15;m=7;} + c=(unsigned)((E<<3)|m); + } + } + // neighbor refinement: closed-form rounding can land one code high at + // regime boundaries (subnormal->normal, exponent rollover); nearest of + // {c-1, c} matches the exhaustive-argmin reference exactly. + if(c>0){ + float vc=dec_ue4m3(c), vp=dec_ue4m3(c-1); + if(fabsf(s-vp)<=fabsf(s-vc)) c=c-1; + } + return c; +} +__device__ __forceinline__ unsigned enc_e2m1(float x, float inv_bs){ + float v=x*inv_bs; unsigned sg=(v<0.f)?8u:0u; v=fabsf(v); + // levels 0,.5,1,1.5,2,3,4,6 ; midpoints .25,.75,1.25,1.75,2.5,3.5,5 + unsigned m = (v>=.25f)+(v>=.75f)+(v>=1.25f)+(v>=1.75f)+(v>=2.5f)+(v>=3.5f)+(v>=5.f); + return sg|m; +} +__device__ __forceinline__ float dec_e2m1(unsigned code){ + const float L[8]={0.f,.5f,1.f,1.5f,2.f,3.f,4.f,6.f}; + float mag=L[code&7u]; return (code&8u)?-mag:mag; +} +__device__ __forceinline__ int b4b_map(int blk){ const int t[4]={0,2,1,3}; return t[blk]; } + +// amax over slabs: x f16 [n_slabs * slab_len]; atomicMax on positive float bits. +__device__ __forceinline__ float fp4_act(float v, int mode, float fs){ + if(mode==1){ float r=v>0.f?v:0.f; return r*r*fs; } + return v; +} +extern "C" __global__ void nvfp4_amax_slab( + const __half* __restrict__ x, unsigned* __restrict__ amax_bits, + long slab_len, int n_slabs, int act_mode, float act_scale) +{ + long total=slab_len*(long)n_slabs; + long stride=(long)gridDim.x*blockDim.x; + long i=(long)blockIdx.x*blockDim.x+threadIdx.x; + if(n_slabs==1){ + // hot path (per-call activation global): block-reduce, 1 atomic/block. + float m=0.f; + for(long p=i;p>=1) m=fmaxf(m,__shfl_down_sync(0xffffffffu,m,o)); + __shared__ float wm[32]; + if((threadIdx.x&31)==0) wm[threadIdx.x>>5]=m; + __syncthreads(); + if(threadIdx.x<32){ + float v=(threadIdx.x<(blockDim.x>>5))?wm[threadIdx.x]:0.f; + #pragma unroll + for(int o=16;o;o>>=1) v=fmaxf(v,__shfl_down_sync(0xffffffffu,v,o)); + if(threadIdx.x==0&&v>0.f) atomicMax(&amax_bits[0],__float_as_uint(v)); + } + return; + } + // setup-only path (per-expert weight slabs): local-run accumulation. + float m=0.f; int cur=-1; + for(long p=i;p=0&&m>0.f) atomicMax(&amax_bits[cur],__float_as_uint(m)); + cur=slab; m=v; + } else m=fmaxf(m,v); + } + if(cur>=0&&m>0.f) atomicMax(&amax_bits[cur],__float_as_uint(m)); +} + +// finalize: g[i] = amax/6/448 (per-tensor NVFP4 global), floor tiny. +extern "C" __global__ void nvfp4_globals( + const unsigned* __restrict__ amax_bits, float* __restrict__ g, int n) +{ + int i=blockIdx.x*blockDim.x+threadIdx.x; if(i>=n) return; + float a=__uint_as_float(amax_bits[i]); + float v=a/6.f/448.f; g[i]=(v>0.f)?v:1e-9f; +} + +// Weight quant+pack: w f16 [n_exp*N, K] row-major → fragment layout +// Wp [e][NT][KT][32][2] u32, Wsc [e][NT][KT][8] u32. One warp = (e,nt,kt). +extern "C" __global__ void nvfp4_pack_w( + const __half* __restrict__ w, const float* __restrict__ gw, + unsigned* __restrict__ Wp, unsigned* __restrict__ Wsc, + int n_exp, int N, int K) +{ + int KT=K/64, NT=N/8; + long warp=((long)blockIdx.x*blockDim.x+threadIdx.x)>>5; + long total=(long)n_exp*NT*KT; if(warp>=total) return; + int lane=threadIdx.x&31, gid=lane>>2, tig=lane&3; + int kt=(int)(warp%KT); long t2=warp/KT; int nt=(int)(t2%NT); int e=(int)(t2/NT); + float g=gw[e]; float inv_g=1.f/g; + int row=nt*8+gid; const __half* wr=w+((long)e*N+row)*K; + unsigned b0=0,b1=0; + // lane covers (blk in {tig>=2?2,3:0,1}, w in {tig&1?8..15:0..7}) of the 64-K tile + int blk0=(tig>=2)?2:0, w0=(tig&1)?8:0; + for(int bb=0;bb<2;bb++){ + int blk=blk0+bb; int bstart=kt*64+blk*16; + float amax=0.f; + #pragma unroll + for(int j=0;j<16;j++) amax=fmaxf(amax,fabsf(__half2float(wr[bstart+j]))); + unsigned sb=enc_ue4m3((amax/6.f)*inv_g); + float bs=dec_ue4m3(sb)*g; float inv_bs=(bs>0.f)?1.f/bs:0.f; + unsigned reg=0; + #pragma unroll + for(int j=0;j<8;j++){ + int kk=blk*16+w0+j; int gK=kt*64+kk; (void)kk; + unsigned code=enc_e2m1(__half2float(wr[gK]),inv_bs); + reg|=code<<(j*4); + } + if(blk&1) b1=reg; else b0=reg; + } + long fbase=((long)((long)e*NT+nt)*KT+kt); + Wp[(fbase*32+lane)*2+0]=b0; Wp[(fbase*32+lane)*2+1]=b1; + if(tig==0){ + unsigned s=0; + #pragma unroll + for(int blk=0;blk<4;blk++){ + int bstart=kt*64+blk*16; float amax=0.f; + for(int j=0;j<16;j++) amax=fmaxf(amax,fabsf(__half2float(wr[bstart+j]))); + s|=enc_ue4m3((amax/6.f)*inv_g)<<(b4b_map(blk)*8); + } + Wsc[fbase*8+gid]=s; + } +} + +// Activation quant+pack: a f16 [mt, K] (sorted rows) + tile descs → fragment +// layout Ap [tile][KT][32][4] u32 + Asc [tile][KT][32] u32. Rows past gend → 0. +// One warp = (tile, kt). gA read from device (dynamic per-call global). +extern "C" __global__ void moe_fp4_actq_pack( + const __half* __restrict__ a, const int* __restrict__ tok0, + const int* __restrict__ gend, const float* __restrict__ gA, + unsigned* __restrict__ Ap, unsigned* __restrict__ Asc, + const unsigned* __restrict__ st, + int maxt, int K, int mt_total, int act_mode, float act_scale, int use_st) +{ + // Warp-per-(tile,kt): stage the 16x64 f16 tile to smem with coalesced + // half2 loads (each lane: 2 rows x 16B), compute the 64 block-16 amaxes + // cooperatively (one per (row,blk) pair on lanes 0..31 x2), then encode. + int KT=K/64; + int wid=threadIdx.x>>5; + long warp=((long)blockIdx.x*(blockDim.x>>5))+wid; + if(warp>=(long)maxt*KT) return; + int lane=threadIdx.x&31, gid=lane>>2, tig=lane&3; + int kt=(int)(warp%KT); int t=(int)(warp/KT); + int base=tok0[t], end=gend[t]; + float g=gA[0]; float inv_g=1.f/g; + __shared__ __half smem_all[8][16*64]; // 8 warps/block x 2KB + __shared__ float bsmem_all[8][16*4]; // reconstructed block scales + __half* sm=smem_all[wid]; + float* bsm=bsmem_all[wid]; + // stage: lane loads rows (lane>>1) halves: 32 halves = 4x half4(8B). row-major. + { + int r0=lane>>1, c0=(lane&1)*32; // each lane: row r0, cols c0..c0+31 + int row=base+r0; + bool live=(r0 < end-base)&&(row>2, blk=pair&3; + const __half* bp=&sm[r*64+blk*16]; + float amax=0.f; + #pragma unroll + for(int j=0;j<16;j++) amax=fmaxf(amax,fabsf(__half2float(bp[j]))); + unsigned sb=enc_ue4m3((amax/6.f)*inv_g); + bsm[r*4+blk]=dec_ue4m3(sb)*g; + } + __syncwarp(); + unsigned regs[4]={0,0,0,0}; + int blk0=(tig>=2)?2:0, w0=(tig&1)?8:0; + #pragma unroll + for(int half=0;half<2;half++){ + int r=gid+half*8; + #pragma unroll + for(int bb=0;bb<2;bb++){ + int blk=blk0+bb; + float bs=bsm[r*4+blk]; float inv_bs=(bs>0.f)?1.f/bs:0.f; + unsigned reg=0; + #pragma unroll + for(int j=0;j<8;j++) + reg|=enc_e2m1(__half2float(sm[r*64+blk*16+w0+j]),inv_bs)<<(j*4); + int ri=(blk&1)?(half?3:2):(half?1:0); + regs[ri]|=reg; + } + } + long fbase=(long)t*KT+kt; + #pragma unroll + for(int r=0;r<4;r++) Ap[(fbase*32+lane)*4+r]=regs[r]; + // scale word (re-encode from reconstructed bs: enc(dec(x))==x for codebook values) + unsigned sw=0; + if(tig==0||tig==1){ + int r=gid+(tig==1?8:0); + #pragma unroll + for(int blk=0;blk<4;blk++) + sw|=enc_ue4m3(bsm[r*4+blk]*inv_g)<<(b4b_map(blk)*8); + } + Asc[fbase*32+lane]=sw; +} + +// VARIANT: each warp computes 4 consecutive n8-tiles — A frags loaded once, +// reused 4x; W streams per-subtile. Cuts A traffic 4x, raises mma density. +// 16-byte async global->shared copy (per-warp staging; no block syncs). +__device__ __forceinline__ void mt_cp16(void* sm, const void* gm){ + unsigned saddr=(unsigned)__cvta_generic_to_shared(sm); + asm volatile("cp.async.cg.shared.global [%0], [%1], 16;"::"r"(saddr),"l"(gm)); +} +extern "C" __global__ void moe_fp4_grouped_mma_x4( + const unsigned* __restrict__ Ap, const unsigned* __restrict__ Asc, + const unsigned* __restrict__ Wp, const unsigned* __restrict__ Wsc, + const int* __restrict__ tok0, const int* __restrict__ eidv, + const int* __restrict__ gend, const float* __restrict__ gA, + const float* __restrict__ gw, __half* __restrict__ out, + unsigned* amax_out, + int N, int K, int mt_total, int am_mode, float am_scale) +{ + // Per-warp cp.async 2-stage smem W pipeline + A register prefetch: the + // direct global->register W loads left the mma latency-bound (~42 TF); + // double-buffering the 4 W tiles per warp while the mma consumes the + // other stage measures 47.9 TF standalone on GB10 (+14%). Each warp owns + // its smem slot — per-warp wait_group + syncwarp only, NO __syncthreads + // (the cross-warp shared-W variant with block syncs measured SLOWER). + __shared__ unsigned smW[8][2][256]; // [warp][stage][4 tiles x 64 u32] + __shared__ unsigned smS[8][2][32]; // [warp][stage][4 tiles x 8 u32] + int KT=K/64, NT=N/8, NG=(NT+3)>>2; + int t=blockIdx.x; + int w=threadIdx.x>>5; + int ng=blockIdx.y*(blockDim.x>>5)+w; + if(ng>=NG) return; + int base=tok0[t], end=gend[t]; + if(end<=base) return; + int lane=threadIdx.x&31, gid=lane>>2, tig=lane&3; + unsigned eid=(unsigned)eidv[t]; + const unsigned* A =Ap +((long)t*KT*32)*4+lane*4; + const unsigned* As=Asc+ (long)t*KT*32+lane; + float c[4][4]={}; + int nt0=ng*4; + #define X4_ISSUE(kt_,st_) do{ \ + int c0=lane, c1=lane+32; \ + int j0=c0>>4, j1=c1>>4; \ + int nt_a=nt0+j0, nt_b=nt0+j1; \ + if(nt_a>1, hf=lane&1; int nt_s=nt0+js; \ + if(nt_s=NT) break; + unsigned b0=smW[w][st][j*64+lane*2], b1=smW[w][st][j*64+lane*2+1]; + unsigned sB=smS[w][st][j*8+gid]; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c[j][0]),"+f"(c[j][1]),"+f"(c[j][2]),"+f"(c[j][3]) + :"r"(a0),"r"(a1),"r"(a2),"r"(a3),"r"(b0),"r"(b1),"r"(sA),"r"(sB)); + } + a0=na0;a1=na1;a2=na2;a3=na3;sA=nsA; + __syncwarp(); + } + #undef X4_ISSUE + float g=gA[0]*gw[eid]; + int valid=end-base; + float am=0.f; + #pragma unroll + for(int j=0;j<4;j++){ + int nt=nt0+j; if(nt>=NT) break; + int col=nt*8+tig*2; + if(gid=0){am=fmaxf(am,fabsf(fp4_act(v0,am_mode,am_scale)));am=fmaxf(am,fabsf(fp4_act(v1,am_mode,am_scale)));} + } + if(gid+8=0){am=fmaxf(am,fabsf(fp4_act(v2,am_mode,am_scale)));am=fmaxf(am,fabsf(fp4_act(v3,am_mode,am_scale)));} + } + } + if(am_mode>=0){ + // fused next-GEMM activation amax: warp-reduce then 1 atomic/warp. + #pragma unroll + for(int o=16;o;o>>=1) am=fmaxf(am,__shfl_down_sync(0xffffffffu,am,o)); + if(lane==0&&am>0.f) atomicMax(amax_out,__float_as_uint(am)); + } +} + +// Grouped mma: one warp = (tile, nt). FIXED launch grid — padding tiles +// (gend==0) early-out → graph-safe. out f16 [mt, N] in sorted-row space. +extern "C" __global__ void moe_fp4_grouped_mma( + const unsigned* __restrict__ Ap, const unsigned* __restrict__ Asc, + const unsigned* __restrict__ Wp, const unsigned* __restrict__ Wsc, + const int* __restrict__ tok0, const int* __restrict__ eidv, + const int* __restrict__ gend, const float* __restrict__ gA, + const float* __restrict__ gw, __half* __restrict__ out, + int N, int K, int mt_total) +{ + int KT=K/64, NT=N/8; + int t=blockIdx.x; // tile index + int nt=blockIdx.y*(blockDim.x>>5)+(threadIdx.x>>5); // n8-tile + if(nt>=NT) return; + int base=tok0[t], end=gend[t]; + if(end<=base) return; // padding tile + int lane=threadIdx.x&31, gid=lane>>2, tig=lane&3; + unsigned eid=(unsigned)eidv[t]; + const unsigned* A =Ap +((long)t*KT*32)*4+lane*4; + const unsigned* As=Asc+ (long)t*KT*32+lane; + const unsigned* W =Wp +(((long)eid*NT+nt)*KT*32)*2+lane*2; + const unsigned* Ws=Wsc+(((long)eid*NT+nt)*KT)*8+gid; + float c0=0,c1=0,c2=0,c3=0; + for(int kt=0;kt scale = amax/448 and inv (one thread). +extern "C" __global__ void fp8_scale( + const unsigned* __restrict__ amax_bits, float* __restrict__ sc) +{ + float am=__uint_as_float(*amax_bits); + float s=(am>0.f)?(am/448.f):1.f; + sc[0]=s; // dequant scale (consumed by the GEMM) + sc[1]=1.f/s; // quant scale (consumed by fp8_quant) +} +// fp8_quant: x f16 [n] -> e4m3 bytes, v*inv clamped to ±448, round-nearest. +// Per-row e4m3 quant: each of R rows -> e4m3 + per-row dequant scale[R] +// (amax/448). Per-channel (weight rows=N) or per-token (act rows=M). +extern "C" __global__ void fp8_quant_perrow( + const __half* __restrict__ x, unsigned char* __restrict__ q, + float* __restrict__ scale, int R, int K) +{ + int r=blockIdx.x; if(r>=R) return; + const __half* xr=x+(long)r*K; + __shared__ float sm[256]; + float am=0.f; + for(int i=threadIdx.x;i>1;o>0;o>>=1){ if(threadIdx.x0.f)?sm[0]/448.f:1.f; float inv=1.f/sc; + if(threadIdx.x==0) scale[r]=sc; + unsigned char* qr=q+(long)r*K; + for(int i=threadIdx.x;i0.f)) c=0u; else if(a>=448.f) c=0x7Eu; + else { int e; float fr=frexpf(a,&e); int E=e+6; + if(E<1){ int m=(int)rintf(a*512.f); if(m>7)m=7; c=(unsigned)m; } + else { int m=(int)rintf((2.f*fr-1.f)*8.f); if(m>7){m=0;E+=1;} if(E>15){E=15;m=6;} c=(unsigned)((E<<3)|m); } } + qr[i]=(unsigned char)(s2|c); + } +} +extern "C" __global__ void fp8_quant_perrow_f32( + const float* __restrict__ x, unsigned char* __restrict__ q, + float* __restrict__ scale, int R, int K) +{ + int r=blockIdx.x; if(r>=R) return; + const float* xr=x+(long)r*K; + __shared__ float sm[256]; + float am=0.f; + for(int i=threadIdx.x;i>1;o>0;o>>=1){ if(threadIdx.x0.f)?sm[0]/448.f:1.f; float inv=1.f/sc; + if(threadIdx.x==0) scale[r]=sc; + unsigned char* qr=q+(long)r*K; + for(int i=threadIdx.x;i0.f)) c=0u; else if(a>=448.f) c=0x7Eu; + else { int e; float fr=frexpf(a,&e); int E=e+6; + if(E<1){ int m=(int)rintf(a*512.f); if(m>7)m=7; c=(unsigned)m; } + else { int m=(int)rintf((2.f*fr-1.f)*8.f); if(m>7){m=0;E+=1;} if(E>15){E=15;m=6;} c=(unsigned)((E<<3)|m); } } + qr[i]=(unsigned char)(s2|c); + } +} +// post-GEMM rowcol dequant: D[r,c] *= xsc[r] * wsc[c] (D [M,N] row-major). +extern "C" __global__ void fp8_rowcol_scale_f32( + float* __restrict__ o, const float* __restrict__ xsc, const float* __restrict__ wsc, int M, int N) +{ + long e=(long)blockIdx.x*blockDim.x+threadIdx.x; long n=(long)M*N; + long st=(long)gridDim.x*blockDim.x; + for(;e a*2^9... (m = a/2^-9) + if(m>7)m=7; c=(unsigned)m; + } else { + int e; float fr=frexpf(a,&e); // a = fr*2^e, fr in [0.5,1) + int E=e+6; // a = (2fr)*2^(E-7) + int m=(int)rintf((2.f*fr-1.f)*8.f); + if(m>7){m=0;E+=1;} + if(E<1){ int ms=(int)rintf(a*512.f); if(ms>7)ms=7; c=(unsigned)ms; } + else { if(E>15)E=15; if(E==15&&m>6)m=6; c=(unsigned)((E<<3)|m); } + } + q[i]=(unsigned char)(s|c); + } +} + +// f32-input variant of lt_fp4_quant — reads the residual-stream f32 tensor +// directly, killing the cast_f32_f16 + f16 intermediate round-trip that +// preceded every dense-projection quant (f32 -> f16 -> e2m1 became +// f32 -> e2m1; the e2m1 grid subsumes the f16 rounding). +extern "C" __global__ void lt_fp4_quant_f32( + const float* __restrict__ x, unsigned char* __restrict__ xp, + unsigned char* __restrict__ sf, int R, int K) +{ + int KB=K/16; + long nb=(long)R*KB; + long b=(long)blockIdx.x*blockDim.x+threadIdx.x; + if(b>=nb) return; + int r=(int)(b/KB), kb=(int)(b%KB); + const float* xb=x+(long)r*K+kb*16; + float v[16]; float am=0.f; + #pragma unroll + for(int i=0;i<16;i++){ v[i]=xb[i]; am=fmaxf(am,fabsf(v[i])); } + unsigned sc=enc_ue4m3(am/6.f); + if(sc==0u&&am>0.f) sc=1u; + float sd=dec_ue4m3(sc); + float inv=(sd>0.f)?(1.f/sd):0.f; + int KB4=(KB+3)>>2; + long soff=(long)(r>>7)*(512L*KB4)+(long)(kb>>2)*512+(r&31)*16+((r>>5)&3)*4+(kb&3); + sf[soff]=(unsigned char)sc; + unsigned char* o=xp+((long)r*K+kb*16)/2; + #pragma unroll + for(int i=0;i<8;i++){ + unsigned lo=enc_e2m1(v[2*i],inv), hi=enc_e2m1(v[2*i+1],inv); + o[i]=(unsigned char)(lo|(hi<<4)); + } +} + +// Quantize f16 [R,K] row-major to the cuBLASLt block-scaled NVFP4 format: +// packed e2m1 (2 elems/byte, linear row-major) + one ue4m3 scale per +// 16-element K-block stored in the hardware's swizzled 512-byte-block layout. +// One thread per 16-element block; per-block self-scaling (amax/6 -> ue4m3), +// no global amax pass. +extern "C" __global__ void lt_fp4_quant( + const __half* __restrict__ x, unsigned char* __restrict__ xp, + unsigned char* __restrict__ sf, int R, int K) +{ + int KB=K/16; + long nb=(long)R*KB; + long b=(long)blockIdx.x*blockDim.x+threadIdx.x; + if(b>=nb) return; + int r=(int)(b/KB), kb=(int)(b%KB); + const __half* xb=x+(long)r*K+kb*16; + float v[16]; float am=0.f; + #pragma unroll + for(int i=0;i<16;i++){ v[i]=__half2float(xb[i]); am=fmaxf(am,fabsf(v[i])); } + unsigned sc=enc_ue4m3(am/6.f); + if(sc==0u&&am>0.f) sc=1u; + float sd=dec_ue4m3(sc); + float inv=(sd>0.f)?(1.f/sd):0.f; + int KB4=(KB+3)>>2; + long soff=(long)(r>>7)*(512L*KB4)+(long)(kb>>2)*512+(r&31)*16+((r>>5)&3)*4+(kb&3); + sf[soff]=(unsigned char)sc; + unsigned char* o=xp+((long)r*K+kb*16)/2; + #pragma unroll + for(int i=0;i<8;i++){ + unsigned lo=enc_e2m1(v[2*i],inv), hi=enc_e2m1(v[2*i+1],inv); + o[i]=(unsigned char)(lo|(hi<<4)); + } +} + +// nvfp4_gscale: amax bits -> [global, inv_global] (one thread). global = +// amax/(6*448) (same convention as nvfp4_globals); 1.0 when amax == 0. +// Word 0 feeds the GEMM's per-group alpha; word 1 feeds the quant kernels. +extern "C" __global__ void nvfp4_gscale( + const unsigned* __restrict__ amax_bits, float* __restrict__ g) +{ + float a=__uint_as_float(*amax_bits); + float v=a/6.f/448.f; + v=(v>0.f)?v:1.f; + g[0]=v; g[1]=1.f/v; +} + +// fp4_sfa_offsets: device build of the per-group SFA byte offsets from the +// router offsets — sfa_off[g] = (sum_{j=nb) return; + int r=(int)(b/KB), kb=(int)(b%KB); + const __half* xb=x+(long)r*K+kb*16; + float v[16]; float am=0.f; + #pragma unroll + for(int i=0;i<16;i++){ v[i]=__half2float(xb[i]); am=fmaxf(am,fabsf(v[i])); } + float ig=1.f/gs[0]; + unsigned sc=enc_ue4m3((am/6.f)*ig); + if(sc==0u&&am>0.f) sc=1u; + float sd=dec_ue4m3(sc); + float inv=(sd>0.f)?(ig/sd):0.f; + int KB4=(KB+3)>>2; + long soff=(long)(r>>7)*(512L*KB4)+(long)(kb>>2)*512+(r&31)*16+((r>>5)&3)*4+(kb&3); + sf[soff]=(unsigned char)sc; + unsigned char* o=xp+((long)r*K+kb*16)/2; + #pragma unroll + for(int i=0;i<8;i++){ + unsigned lo=enc_e2m1(v[2*i],inv), hi=enc_e2m1(v[2*i+1],inv); + o[i]=(unsigned char)(lo|(hi<<4)); + } +} + +extern "C" __global__ void lt_fp4_quant_gs_46( + const __half* __restrict__ x, unsigned char* __restrict__ xp, + unsigned char* __restrict__ sf, const float* __restrict__ gs, int R, int K) +{ + int KB=K/16; long nb=(long)R*KB; + long b=(long)blockIdx.x*blockDim.x+threadIdx.x; if(b>=nb) return; + int r=(int)(b/KB), kb=(int)(b%KB); + const __half* xb=x+(long)r*K+kb*16; + float v[16]; float am=0.f; + #pragma unroll + for(int i=0;i<16;i++){ v[i]=__half2float(xb[i]); am=fmaxf(am,fabsf(v[i])); } + float ig=1.f/gs[0]; + // Four-Over-Six: scale-to-4 (denser, no 4-6 hole) vs scale-to-6 (full range), pick min-MSE per block. + unsigned sc6=enc_ue4m3((am/6.f)*ig); if(sc6==0u&&am>0.f) sc6=1u; float sd6=dec_ue4m3(sc6); float inv6=(sd6>0.f)?(ig/sd6):0.f; + unsigned sc4=enc_ue4m3((am/4.f)*ig); if(sc4==0u&&am>0.f) sc4=1u; float sd4=dec_ue4m3(sc4); float inv4=(sd4>0.f)?(ig/sd4):0.f; + float e6=0.f,e4=0.f; + #pragma unroll + for(int i=0;i<16;i++){ + float r6=(inv6>0.f)?dec_e2m1(enc_e2m1(v[i],inv6))/inv6:0.f; float d6=r6-v[i]; e6+=d6*d6; + float r4=(inv4>0.f)?dec_e2m1(enc_e2m1(v[i],inv4))/inv4:0.f; float d4=r4-v[i]; e4+=d4*d4; + } + // ue4m3 code 127 (e=15,m=7) is NaN in hardware e4m3 (max normal=448=code126). + // scale-to-4 can push the block scale >480 → enc_ue4m3 returns 127 → cuBLASLt + // reads a NaN block scale → garbage. scale-to-6 maxes at 448 (code126, safe). + // So only pick scale-to-4 when it stays a valid (non-NaN) ue4m3 code. + unsigned sc; float inv; + if(e4>2; + long soff=(long)(r>>7)*(512L*KB4)+(long)(kb>>2)*512+(r&31)*16+((r>>5)&3)*4+(kb&3); + sf[soff]=(unsigned char)sc; + unsigned char* o=xp+((long)r*K+kb*16)/2; + #pragma unroll + for(int i=0;i<8;i++){ + unsigned lo=enc_e2m1(v[2*i],inv), hi=enc_e2m1(v[2*i+1],inv); + o[i]=(unsigned char)(lo|(hi<<4)); + } +} +// f32-input global-aware variant of lt_fp4_quant_gs (dense activations read +// straight from the f32 residual stream; block scales relative to gs[0]). +extern "C" __global__ void lt_fp4_quant_gs_f32( + const float* __restrict__ x, unsigned char* __restrict__ xp, + unsigned char* __restrict__ sf, const float* __restrict__ gs, + int R, int K) +{ + int KB=K/16; + long nb=(long)R*KB; + long b=(long)blockIdx.x*blockDim.x+threadIdx.x; + if(b>=nb) return; + int r=(int)(b/KB), kb=(int)(b%KB); + const float* xb=x+(long)r*K+kb*16; + float v[16]; float am=0.f; + #pragma unroll + for(int i=0;i<16;i++){ v[i]=xb[i]; am=fmaxf(am,fabsf(v[i])); } + float ig=1.f/gs[0]; + unsigned sc=enc_ue4m3((am/6.f)*ig); + if(sc==0u&&am>0.f) sc=1u; + float sd=dec_ue4m3(sc); + float inv=(sd>0.f)?(ig/sd):0.f; + int KB4=(KB+3)>>2; + long soff=(long)(r>>7)*(512L*KB4)+(long)(kb>>2)*512+(r&31)*16+((r>>5)&3)*4+(kb&3); + sf[soff]=(unsigned char)sc; + unsigned char* o=xp+((long)r*K+kb*16)/2; + #pragma unroll + for(int i=0;i<8;i++){ + unsigned lo=enc_e2m1(v[2*i],inv), hi=enc_e2m1(v[2*i+1],inv); + o[i]=(unsigned char)(lo|(hi<<4)); + } +} +// D-scale fold: dscale[0] = act_global * weight_global (both gs[0]). +extern "C" __global__ void fp4_dscale( + const float* __restrict__ ga, const float* __restrict__ gw, float* __restrict__ d) +{ if(threadIdx.x==0&&blockIdx.x==0) d[0]=ga[0]*gw[0]; } +// post-GEMM per-tensor output scale by a device scalar (f32 or f16 out). +extern "C" __global__ void fp4_apply_dscale_f32(float* __restrict__ o, const float* __restrict__ d, long n) +{ long i=(long)blockIdx.x*blockDim.x+threadIdx.x; long st=(long)gridDim.x*blockDim.x; float s=d[0]; for(;i=nb) return; + int r=(int)(b/KB), kb=(int)(b%KB); + // largest g with g_starts[g] <= r (binary search; groups contiguous) + int gl=0, gh=n_groups-1; + while(gl>1; if(g_starts[gm]<=r) gl=gm; else gh=gm-1; } + int lr=r-g_starts[gl]; + int sr = use_st ? (int)st[r] : r; // gather-on-read from the ungathered source + const __half* xb=x+(long)sr*K+kb*16; + float v[16]; float am=0.f; + #pragma unroll + for(int i=0;i<16;i++){ v[i]=fp4_act(__half2float(xb[i]),act_mode,act_scale); am=fmaxf(am,fabsf(v[i])); } + float ig=gs[1]; + unsigned sc=enc_ue4m3((am/6.f)*ig); + if(sc==0u&&am>0.f) sc=1u; + float sd=dec_ue4m3(sc); + float inv=(sd>0.f)?(ig/sd):0.f; + int KB4=(KB+3)>>2; + long soff=(long long)sfa_off[gl]+(long)(lr>>7)*(512L*KB4)+(long)(kb>>2)*512+(lr&31)*16+((lr>>5)&3)*4+(kb&3); + sf[soff]=(unsigned char)sc; + unsigned char* o=xp+((long)r*K+kb*16)/2; + #pragma unroll + for(int i=0;i<8;i++){ + unsigned lo=enc_e2m1(v[2*i],inv), hi=enc_e2m1(v[2*i+1],inv); + o[i]=(unsigned char)(lo|(hi<<4)); + } +} + +// Routed-act Four-Over-Six: same as lt_fp4_quant_grouped but adaptive 4/6 block +// scale (NVFP4 quality). code-127 guard: scale-to-4 can hit ue4m3 NaN code. +extern "C" __global__ void lt_fp4_quant_grouped_46( + const __half* __restrict__ x, unsigned char* __restrict__ xp, + unsigned char* __restrict__ sf, const int* __restrict__ g_starts, + const long long* __restrict__ sfa_off, const float* __restrict__ gs, + const unsigned* __restrict__ st, + int n_groups, int R, int K, int act_mode, float act_scale, int use_st) +{ + int KB=K/16; long nb=(long)R*KB; + long b=(long)blockIdx.x*blockDim.x+threadIdx.x; if(b>=nb) return; + int r=(int)(b/KB), kb=(int)(b%KB); + int gl=0, gh=n_groups-1; + while(gl>1; if(g_starts[gm]<=r) gl=gm; else gh=gm-1; } + int lr=r-g_starts[gl]; + int sr = use_st ? (int)st[r] : r; + const __half* xb=x+(long)sr*K+kb*16; + float v[16]; float am=0.f; + #pragma unroll + for(int i=0;i<16;i++){ v[i]=fp4_act(__half2float(xb[i]),act_mode,act_scale); am=fmaxf(am,fabsf(v[i])); } + float ig=gs[1]; + unsigned sc6=enc_ue4m3((am/6.f)*ig); if(sc6==0u&&am>0.f) sc6=1u; float sd6=dec_ue4m3(sc6); float inv6=(sd6>0.f)?(ig/sd6):0.f; + unsigned sc4=enc_ue4m3((am/4.f)*ig); if(sc4==0u&&am>0.f) sc4=1u; float sd4=dec_ue4m3(sc4); float inv4=(sd4>0.f)?(ig/sd4):0.f; + float e6=0.f,e4=0.f; + #pragma unroll + for(int i=0;i<16;i++){ + float r6=(inv6>0.f)?dec_e2m1(enc_e2m1(v[i],inv6))/inv6:0.f; float d6=r6-v[i]; e6+=d6*d6; + float r4=(inv4>0.f)?dec_e2m1(enc_e2m1(v[i],inv4))/inv4:0.f; float d4=r4-v[i]; e4+=d4*d4; + } + unsigned sc; float inv; + if(e4>2; + long soff=(long long)sfa_off[gl]+(long)(lr>>7)*(512L*KB4)+(long)(kb>>2)*512+(lr&31)*16+((lr>>5)&3)*4+(kb&3); + sf[soff]=(unsigned char)sc; + unsigned char* o=xp+((long)r*K+kb*16)/2; + #pragma unroll + for(int i=0;i<8;i++){ + unsigned lo=enc_e2m1(v[2*i],inv), hi=enc_e2m1(v[2*i+1],inv); + o[i]=(unsigned char)(lo|(hi<<4)); + } +} +extern "C" __global__ void pad_rows_f16(const __half* src, __half* dst, int k, int kpad, int rows){ + long i = (long)blockIdx.x*blockDim.x + threadIdx.x; + long total = (long)rows*(long)kpad; + if(i>=total) return; + int c = (int)(i % (long)kpad); long r = i / (long)kpad; + dst[i] = (c Result<(Tensor, Tensor, Tensor)> { + let i = |v: i32| v.to_le_bytes().to_vec(); + let total = mt + 2 * s; + let st_e = Tensor::new(dev.alloc(total * 4)?, vec![total], DType::U32); + let sw_e = Tensor::new(dev.alloc(total * 4)?, vec![total], DType::F32); + let off_e = Tensor::new(dev.alloc((n_exp + 3) * 4)?, vec![n_exp + 3], DType::U32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "moe_extend_groups", + &[(st.buffer.as_ref(), st.offset), (sw.buffer.as_ref(), sw.offset), (off.buffer.as_ref(), off.offset), + (st_e.buffer.as_ref(), 0), (sw_e.buffer.as_ref(), 0), (off_e.buffer.as_ref(), 0)], + &[i(mt as i32), i(s as i32), i(n_exp as i32)], + [(total as u32).div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + Ok((st_e, sw_e, off_e)) +} + +/// Quantize an f16 `[r, k]` row-major tensor to cuBLASLt block-scaled NVFP4: +/// returns `(pack, sf)` — packed e2m1 `[r*k/2]` bytes + the swizzled ue4m3 +/// scale tensor sized `ceil(r/128)*512*ceil((k/16)/4)` bytes (zero-seeded so +/// padding rows decode to 0). Used for both weights (once, at setup) and +/// activations (per call). `k % 32 == 0` required. +pub fn lt_fp4_quant(dev: &dyn Device, x: &Tensor, r: usize, k: usize) -> Result<(Tensor, Tensor)> { + if k % 32 != 0 { return Err(Error::Msg(format!("lt_fp4_quant: k {k} %32!=0"))); } + let i = |v: i32| v.to_le_bytes().to_vec(); + let kb = k / 16; + let kb4 = kb.div_ceil(4); + let sf_bytes = r.div_ceil(128) * 512 * kb4; + let pack = Tensor::new(dev.alloc((r * k) / 2)?, vec![(r * k) / 2], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_bytes)?, vec![sf_bytes], DType::U32); + let nb = (r * kb) as u32; + // f32 input quantizes directly (no f16 staging round-trip). + let kname = if x.dtype == DType::F32 { "lt_fp4_quant_f32" } else { "lt_fp4_quant" }; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", kname, + &[(x.buffer.as_ref(), x.offset), (pack.buffer.as_ref(), 0), (sf.buffer.as_ref(), 0)], + &[i(r as i32), i(k as i32)], + [nb.div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + Ok((pack, sf)) +} + +/// Grouped-activation variant of [`lt_fp4_quant`] feeding the CUTLASS grouped +/// block-scaled GEMM: quantizes the sorted-token f16 `x [mt, k]` to packed +/// e2m1 `[mt*k/2]` bytes (global row-major) + a per-group swizzled ue4m3 +/// scale pool where group g's blob (group-LOCAL rows, padded to 128-row +/// stripes, zero-seeded) starts at byte `sfa_off[g]`. A per-call activation +/// GLOBAL (amax/(6·448), dynamic, device-computed — no host sync) centers the +/// block scales in the ue4m3 normal range; the GEMM folds it back in via the +/// per-group alpha (see [`moe_grouped_gemm_cutlass_fp4`]). `g_starts` has +/// `n_groups+1` entries (`g_starts[n_groups] == mt`). Returns +/// `(pack, sf, sfa_off, gs)` — `gs` = device `[global, inv_global]` f32 pair. +pub fn lt_fp4_quant_grouped( + dev: &dyn Device, x: &Tensor, mt: usize, k: usize, g_starts: &[usize], +) -> Result<(Tensor, Tensor, Vec, Tensor)> { + if k % 32 != 0 { return Err(Error::Msg(format!("lt_fp4_quant_grouped: k {k} %32!=0"))); } + let n_groups = g_starts.len() - 1; + let kb = k / 16; + let kb4 = kb.div_ceil(4); + let mut sfa_off: Vec = Vec::with_capacity(n_groups); + let mut sf_bytes: i64 = 0; + for g in 0..n_groups { + sfa_off.push(sf_bytes); + let m = g_starts[g + 1] - g_starts[g]; + sf_bytes += (m.div_ceil(128) * 512 * kb4) as i64; + } + let i = |v: i32| v.to_le_bytes().to_vec(); + let l = |v: i64| v.to_le_bytes().to_vec(); + // per-call activation global: amax over the whole [mt,k] then amax→scale. + let amax = Tensor::new(dev.alloc_zeroed(4)?, vec![1], DType::U32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(x.buffer.as_ref(), x.offset), (amax.buffer.as_ref(), 0)], + &[l((mt * k) as i64), i(1), i(0), 0f32.to_le_bytes().to_vec()], + [((mt * k) as u32).div_ceil(256 * 64).max(1), 1, 1], [256, 1, 1], 0, false)?; + let gs = Tensor::new(dev.alloc(8)?, vec![2], DType::F32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_gscale", + &[(amax.buffer.as_ref(), 0), (gs.buffer.as_ref(), 0)], + &[], [1, 1, 1], [1, 1, 1], 0, false)?; + let pack = Tensor::new(dev.alloc((mt * k) / 2)?, vec![(mt * k) / 2], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_bytes as usize)?, vec![sf_bytes as usize], DType::U32); + let starts_b: Vec = g_starts[..n_groups].iter().flat_map(|&s| (s as i32).to_le_bytes()).collect(); + let offs_b: Vec = sfa_off.iter().flat_map(|&o| o.to_le_bytes()).collect(); + let starts_d = Tensor::new(dev.upload(&starts_b)?, vec![n_groups], DType::U32); + let offs_d = Tensor::new(dev.upload(&offs_b)?, vec![n_groups * 2], DType::U32); + let nb = (mt * kb) as u32; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "lt_fp4_quant_grouped", + &[(x.buffer.as_ref(), x.offset), (pack.buffer.as_ref(), 0), (sf.buffer.as_ref(), 0), + (starts_d.buffer.as_ref(), 0), (offs_d.buffer.as_ref(), 0), (gs.buffer.as_ref(), 0), + (gs.buffer.as_ref(), 0)], + &[i(n_groups as i32), i(mt as i32), i(k as i32), i(0), 0f32.to_le_bytes().to_vec(), i(0)], + [nb.div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + Ok((pack, sf, sfa_off, gs)) +} + +/// Quantize a contiguous f16 expert slab `[n_exp*n, k]` to per-expert +/// block-scaled NVFP4 in the CUTLASS/cuBLASLt canonical layout: per-expert +/// packed e2m1 (`n*k/2` bytes each, contiguous) + per-expert swizzled ue4m3 +/// scales (`ceil(n/128)*512*ceil(k/64)` bytes each, contiguous, zero-seeded) +/// + per-expert GLOBALS `gw [n_exp]` f32 (amax/(6·448) — keeps the block +/// scales in the ue4m3 normal range; the GEMM folds `gw` back in via the +/// per-group alpha). One quant dispatch per expert so every expert's SF blob +/// is laid out from local row 0 (an `[n_exp*n, k]` whole-slab quant would +/// misalign stripes whenever `n % 128 != 0`). Setup-time only. +pub fn lt_fp4_quant_slab( + dev: &dyn Device, w: &Tensor, n_exp: usize, n: usize, k: usize, +) -> Result<(Tensor, Tensor, Tensor)> { + if k % 32 != 0 { return Err(Error::Msg(format!("lt_fp4_quant_slab: k {k} %32!=0"))); } + let i = |v: i32| v.to_le_bytes().to_vec(); + let l = |v: i64| v.to_le_bytes().to_vec(); + let kb = k / 16; + let kb4 = kb.div_ceil(4); + let sf_exp = n.div_ceil(128) * 512 * kb4; + let pack_exp = n * k / 2; + // per-expert globals (same two passes as the fragment-pack setup) + let amax = Tensor::new(dev.alloc_zeroed(n_exp * 4)?, vec![n_exp], DType::U32); + let gw = Tensor::empty(dev, vec![n_exp], DType::F32)?; + let slab = (n * k) as i64; + let total_elems = (n_exp * n * k) as u32; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(w.buffer.as_ref(), w.offset), (amax.buffer.as_ref(), 0)], + &[l(slab), i(n_exp as i32), i(0), 0f32.to_le_bytes().to_vec()], + [total_elems.div_ceil(256 * 8), 1, 1], [256, 1, 1], 0, false)?; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_globals", + &[(amax.buffer.as_ref(), 0), (gw.buffer.as_ref(), 0)], + &[i(n_exp as i32)], + [(n_exp as u32).div_ceil(64), 1, 1], [64, 1, 1], 0, false)?; + let pack = Tensor::new(dev.alloc(n_exp * pack_exp)?, vec![n_exp * pack_exp], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(n_exp * sf_exp)?, vec![n_exp * sf_exp], DType::U32); + let nb = (n * kb) as u32; + // B8a: routed-expert WEIGHT 4/6 (Four-Over-Six) under NEMOTRON_FP4_46 — free + // (setup-time, covers ~90% of params). Same per-expert global gw[e] (gs[0]). + let kname = if std::env::var("NEMOTRON_FP4_46").is_ok() { "lt_fp4_quant_gs_46" } else { "lt_fp4_quant_gs" }; + for e in 0..n_exp { + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", kname, + &[(w.buffer.as_ref(), w.offset + e * n * k * 2), + (pack.buffer.as_ref(), e * pack_exp), (sf.buffer.as_ref(), e * sf_exp), + (gw.buffer.as_ref(), e * 4)], + &[i(n as i32), i(k as i32)], + [nb.div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + } + Ok((pack, sf, gw)) +} + +/// Device-descriptor variant of [`lt_fp4_quant_grouped`]: per-group SFA +/// offsets are computed ON DEVICE from the router offsets (`off_dev`, u32 +/// `[n_groups+1]`) — no host offsets, no uploads, graph-safe. The SFA pool is +/// sized worst-case (`mt/128 + n_groups` stripes). Returns `(pack, sf, gs)`; +/// the GEMM run derives the same per-group offsets from `off_dev`. +pub fn lt_fp4_quant_grouped_dev( + dev: &dyn Device, x: &Tensor, mt: usize, k: usize, off_dev: &Tensor, n_groups: usize, + act_mode: i32, act_scale: f32, + gather_st: Option<(&Tensor, usize)>, +) -> Result<(Tensor, Tensor, Tensor)> { + if k % 32 != 0 { return Err(Error::Msg(format!("lt_fp4_quant_grouped_dev: k {k} %32!=0"))); } + let i = |v: i32| v.to_le_bytes().to_vec(); + let l = |v: i64| v.to_le_bytes().to_vec(); + let kb = k / 16; + let kb4 = kb.div_ceil(4); + let sf_bytes = (mt.div_ceil(128) + n_groups) * 512 * kb4; // worst-case stripes + let amax_rows = gather_st.map(|(_, s_rows)| s_rows).unwrap_or(mt); + let amax = Tensor::new(dev.alloc_zeroed(4)?, vec![1], DType::U32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(x.buffer.as_ref(), x.offset), (amax.buffer.as_ref(), 0)], + &[l((amax_rows * k) as i64), i(1), i(act_mode), act_scale.to_le_bytes().to_vec()], + [((amax_rows * k) as u32).div_ceil(256 * 64).max(1), 1, 1], [256, 1, 1], 0, false)?; + let gs = Tensor::new(dev.alloc(8)?, vec![2], DType::F32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_gscale", + &[(amax.buffer.as_ref(), 0), (gs.buffer.as_ref(), 0)], + &[], [1, 1, 1], [1, 1, 1], 0, false)?; + let sfa_off = Tensor::new(dev.alloc(n_groups * 8)?, vec![n_groups * 2], DType::U32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "fp4_sfa_offsets", + &[(off_dev.buffer.as_ref(), off_dev.offset), (sfa_off.buffer.as_ref(), 0)], + &[i(n_groups as i32), i(kb4 as i32)], + [1, 1, 1], [1, 1, 1], 0, false)?; + let pack = Tensor::new(dev.alloc((mt * k) / 2)?, vec![(mt * k) / 2], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_bytes)?, vec![sf_bytes], DType::U32); + let nb = (mt * kb) as u32; + // off_dev doubles as g_starts (u32 values, n_groups+1 entries; the kernel + // binary-searches the first n_groups). B8a: routed-ACT 4/6 under NEMOTRON_FP4_46. + let gkname = if std::env::var("NEMOTRON_FP4_46_ACT").is_ok() { "lt_fp4_quant_grouped_46" } else { "lt_fp4_quant_grouped" }; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", gkname, + &[(x.buffer.as_ref(), x.offset), (pack.buffer.as_ref(), 0), (sf.buffer.as_ref(), 0), + (off_dev.buffer.as_ref(), off_dev.offset), (sfa_off.buffer.as_ref(), 0), (gs.buffer.as_ref(), 0), + match gather_st { Some((t, _)) => (t.buffer.as_ref(), t.offset), None => (gs.buffer.as_ref(), 0) }], + &[i(n_groups as i32), i(mt as i32), i(k as i32), i(act_mode), act_scale.to_le_bytes().to_vec(), + i(if gather_st.is_some() { 1 } else { 0 })], + [nb.div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + Ok((pack, sf, gs)) +} + +/// Per-group alpha for the identity-expert device path: alpha[g] = gs[0]*gw[g], +/// written into the PERSISTENT `alpha` buffer the prepared handle points at. +pub fn fp4_group_alpha_id(dev: &dyn Device, gs: &Tensor, gw: &Tensor, alpha: &Tensor, n: usize) -> Result<()> { + let i = |v: i32| v.to_le_bytes().to_vec(); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "fp4_group_alpha_id", + &[(gs.buffer.as_ref(), gs.offset), (gw.buffer.as_ref(), gw.offset), (alpha.buffer.as_ref(), 0)], + &[i(n as i32)], + [(n as u32).div_ceil(64), 1, 1], [64, 1, 1], 0, false) +} + +/// CUTLASS grouped block-scaled NVFP4 MoE GEMM — `out[t,n](f16) = Σ_k +/// A[t,k]·W[eid][n,k]` over sorted-token groups with BOTH operands in packed +/// e2m1 + swizzled ue4m3 block-16 scales. `a_pack`/`a_sf`/`sfa_off`/`a_gs` +/// from [`lt_fp4_quant_grouped`]; `w_pack`/`w_sf`/`w_gw` from +/// [`lt_fp4_quant_slab`]. The per-tensor globals are folded back in through +/// the GEMM's per-group alpha (`gA·gw[eid]`, built device-side — no host +/// sync). `g_starts[g]..g_starts[g+1]` = group g's token rows; +/// `expert_ids[g]` selects the weight slab. ONE GEMM launch for all groups. +/// Returns `out [mt, n]` f16. CUDA + CUTLASS_DIR sm_120a/121a build only. +#[allow(clippy::too_many_arguments)] +pub fn moe_grouped_gemm_cutlass_fp4( + dev: &dyn Device, + a_pack: &Tensor, a_sf: &Tensor, sfa_off: &[i64], a_gs: &Tensor, + w_pack: &Tensor, w_sf: &Tensor, w_gw: &Tensor, + g_starts: &[usize], expert_ids: &[usize], + n: usize, k: usize, +) -> Result { + let n_active = expert_ids.len(); + let mt = g_starts.last().copied().unwrap_or(0); + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if n_active == 0 || mt == 0 { return Ok(out); } + let group_rows: Vec = (0..n_active).map(|g| (g_starts[g + 1] - g_starts[g]) as i32).collect(); + let eids: Vec = expert_ids.iter().map(|&e| e as i32).collect(); + // device per-group alpha = gA[0] * gw[eid[g]] + let i = |v: i32| v.to_le_bytes().to_vec(); + let eids_b: Vec = eids.iter().flat_map(|&e| e.to_le_bytes()).collect(); + let eids_d = Tensor::new(dev.upload(&eids_b)?, vec![n_active], DType::U32); + let alpha = Tensor::empty(dev, vec![n_active], DType::F32)?; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "fp4_group_alpha", + &[(a_gs.buffer.as_ref(), a_gs.offset), (w_gw.buffer.as_ref(), w_gw.offset), + (eids_d.buffer.as_ref(), 0), (alpha.buffer.as_ref(), 0)], + &[i(n_active as i32)], + [(n_active as u32).div_ceil(64), 1, 1], [64, 1, 1], 0, false)?; + dev.moe_grouped_cutlass_fp4( + a_pack.buffer.as_ref(), a_sf.buffer.as_ref(), + w_pack.buffer.as_ref(), w_sf.buffer.as_ref(), + out.buffer.as_ref(), &group_rows, &eids, sfa_off, + Some(alpha.buffer.as_ref()), n, k)?; + Ok(out) +} + +/// Zero-pad the K (column) dim of an f16 `[rows,k]` tensor to `[rows,kpad]`. +/// No-op clone when `kpad==k`. Used to satisfy the mxf8f6f4 SF atom-K=128 +/// constraint (e.g. down-proj K=1856 -> 1920); padded cols add 0 to the dot. +pub fn pad_rows_f16(dev: &dyn Device, x: &Tensor, rows: usize, k: usize, kpad: usize) -> Result { + if kpad == k { return Ok(x.clone()); } + let i = |v: i32| v.to_le_bytes().to_vec(); + let dst = Tensor::new(dev.alloc(rows * kpad * 2)?, vec![rows * kpad], DType::F16); + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", "pad_rows_f16", + &[(x.buffer.as_ref(), x.offset), (dst.buffer.as_ref(), 0)], + &[i(k as i32), i(kpad as i32), i(rows as i32)], + [((rows * kpad) as u32).div_ceil(256).max(1), 1, 1], [256, 1, 1], 0, false)?; + Ok(dst) +} + +/// W4A8 per-group SFA byte offsets (atom-padded, ue8m0 = 1 byte/elem) for the +/// grouped act-quant + GEMM. MX per-32 blocks: kb = k/32. Per group the SFA +/// section is `ceil(m/128)*512*ceil(kb/4)` bytes (same swizzle atom as NVFP4, +/// only the block size differs). Returns `(offsets[n_groups], total_bytes)`. +pub fn w4a8_sfa_offsets(g_starts: &[usize], k: usize) -> (Vec, usize) { + let n_groups = g_starts.len() - 1; + let kb4 = (k / 32).div_ceil(4); + let mut offs = Vec::with_capacity(n_groups); + let mut acc: i64 = 0; + for g in 0..n_groups { + offs.push(acc); + let m = g_starts[g + 1] - g_starts[g]; + acc += (m.div_ceil(128) * 512 * kb4) as i64; + } + (offs, acc as usize) +} + +/// W4A8 grouped MoE GEMM: fp8 e4m3 acts x mxfp4 weights (cutlass mxf8f6f4). +/// Both SF are per-32 ue8m0 (full-range), so NO per-tensor global scale / alpha +/// (alpha=1.0). `sfa_off` = host per-group SFA byte offsets (atom-padded); +/// `sfb_exp_bytes` = per-expert SFB stride in bytes (from `w4a8_packw`). +#[allow(clippy::too_many_arguments)] +pub fn moe_grouped_gemm_w4a8( + dev: &dyn Device, + a_pack: &Tensor, a_sf: &Tensor, sfa_off: &[i64], + w_pack: &Tensor, w_sf: &Tensor, sfb_exp_bytes: i64, + g_starts: &[usize], expert_ids: &[usize], + n: usize, k: usize, +) -> Result { + let n_active = expert_ids.len(); + let mt = g_starts.last().copied().unwrap_or(0); + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if n_active == 0 || mt == 0 { return Ok(out); } + let group_rows: Vec = (0..n_active).map(|g| (g_starts[g + 1] - g_starts[g]) as i32).collect(); + let eids: Vec = expert_ids.iter().map(|&e| e as i32).collect(); + dev.moe_grouped_cutlass_w4a8( + a_pack.buffer.as_ref(), a_sf.buffer.as_ref(), + w_pack.buffer.as_ref(), w_sf.buffer.as_ref(), + out.buffer.as_ref(), &group_rows, &eids, sfa_off, + sfb_exp_bytes, None, n, k)?; + Ok(out) +} + +/// W4A8 fp8 act-quant: f16 `x[mt,k]` -> e4m3 bytes `[mt*k]` + ue8m0 SF (per-32, +/// group-aware SFA). `sfa_off` = host per-group SFA byte offsets (atom-padded). +/// Returns `(q, sf)`. `sf_total_bytes` = caller-sized SFA buffer length. +pub fn w4a8_actquant_grouped( + dev: &dyn Device, + x: &Tensor, group_rows: &[i32], sfa_off: &[i64], sf_total_bytes: usize, + mt: usize, k: usize, +) -> Result<(Tensor, Tensor)> { + let q = Tensor::new(dev.alloc(mt * k)?, vec![mt * k], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_total_bytes.max(1))?, vec![sf_total_bytes.max(1)], DType::U32); + dev.w4a8_actquant(x.buffer.as_ref(), q.buffer.as_ref(), sf.buffer.as_ref(), + group_rows, sfa_off, mt, k)?; + Ok((q, sf)) +} + +/// W4A8 mxfp4 weight pack: f16 `w[n_exp,n,k]` -> e2m1 bytes + ue8m0 SF (per-32). +/// Returns `(packed, sf, sfb_exp_elems)`; the GEMM's `sfb_exp_bytes` = sfb_exp_elems. +pub fn w4a8_packw( + dev: &dyn Device, + w: &Tensor, n_exp: usize, n: usize, k: usize, +) -> Result<(Tensor, Tensor, i64)> { + let packed = Tensor::new(dev.alloc(n_exp * n * k / 2)?, vec![n_exp * n * k / 2], DType::U32); + // SFB size: per-expert atom-padded element count, queried from the first pack. + // Over-allocate generously (per-32 -> n*k/32 + atom padding) then trust the + // returned per-expert element count for the GEMM stride. + let sf_cap = n_exp * ((n * k / 32) + 4 * 128 * 4); + let sf = Tensor::new(dev.alloc_zeroed(sf_cap.max(1))?, vec![sf_cap.max(1)], DType::U32); + let sfb_exp_elems = dev.w4a8_packw(w.buffer.as_ref(), packed.buffer.as_ref(), sf.buffer.as_ref(), + n_exp, n, k)?; + Ok((packed, sf, sfb_exp_elems)) +} + +/// `sfb_exp_bytes` = per-expert SFB stride in bytes (from `w8a8_packw`). +#[allow(clippy::too_many_arguments)] +pub fn moe_grouped_gemm_w8a8( + dev: &dyn Device, + a_pack: &Tensor, a_sf: &Tensor, sfa_off: &[i64], + w_pack: &Tensor, w_sf: &Tensor, sfb_exp_bytes: i64, + g_starts: &[usize], expert_ids: &[usize], + n: usize, k: usize, +) -> Result { + let n_active = expert_ids.len(); + let mt = g_starts.last().copied().unwrap_or(0); + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if n_active == 0 || mt == 0 { return Ok(out); } + let group_rows: Vec = (0..n_active).map(|g| (g_starts[g + 1] - g_starts[g]) as i32).collect(); + let eids: Vec = expert_ids.iter().map(|&e| e as i32).collect(); + dev.moe_grouped_cutlass_w8a8( + a_pack.buffer.as_ref(), a_sf.buffer.as_ref(), + w_pack.buffer.as_ref(), w_sf.buffer.as_ref(), + out.buffer.as_ref(), &group_rows, &eids, sfa_off, + sfb_exp_bytes, None, n, k)?; + Ok(out) +} + +/// W4A8 fp8 act-quant: f16 `x[mt,k]` -> e4m3 bytes `[mt*k]` + ue8m0 SF (per-32, +/// group-aware SFA). `sfa_off` = host per-group SFA byte offsets (atom-padded). +/// Returns `(q, sf)`. `sf_total_bytes` = caller-sized SFA buffer length. +pub fn w8a8_actquant_grouped( + dev: &dyn Device, + x: &Tensor, group_rows: &[i32], sfa_off: &[i64], sf_total_bytes: usize, + mt: usize, k: usize, +) -> Result<(Tensor, Tensor)> { + let q = Tensor::new(dev.alloc(mt * k)?, vec![mt * k], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_total_bytes.max(1))?, vec![sf_total_bytes.max(1)], DType::U32); + dev.w8a8_actquant(x.buffer.as_ref(), q.buffer.as_ref(), sf.buffer.as_ref(), + group_rows, sfa_off, mt, k)?; + Ok((q, sf)) +} + +/// W4A8 mxfp4 weight pack: f16 `w[n_exp,n,k]` -> e2m1 bytes + ue8m0 SF (per-32). +/// Returns `(packed, sf, sfb_exp_elems)`; the GEMM's `sfb_exp_bytes` = sfb_exp_elems. +pub fn w8a8_packw( + dev: &dyn Device, + w: &Tensor, n_exp: usize, n: usize, k: usize, +) -> Result<(Tensor, Tensor, i64)> { + let packed = Tensor::new(dev.alloc(n_exp * n * k)?, vec![n_exp * n * k], DType::U32); + // SFB size: per-expert atom-padded element count, queried from the first pack. + // Over-allocate generously (per-32 -> n*k/32 + atom padding) then trust the + // returned per-expert element count for the GEMM stride. + let sf_cap = n_exp * ((n * k / 32) + 4 * 128 * 4); + let sf = Tensor::new(dev.alloc_zeroed(sf_cap.max(1))?, vec![sf_cap.max(1)], DType::U32); + let sfb_exp_elems = dev.w8a8_packw(w.buffer.as_ref(), packed.buffer.as_ref(), sf.buffer.as_ref(), + n_exp, n, k)?; + Ok((packed, sf, sfb_exp_elems)) +} + + +/// Quantize an f16 tensor to e4m3 with a dynamic per-tensor scale. +/// Returns `(q, sc)` — e4m3 bytes `[n]` + a 2-f32 scale buffer +/// `[dequant_scale, quant_scale]` (the GEMM consumes word 0). +pub fn fp8_quant(dev: &dyn Device, x: &Tensor, n: usize) -> Result<(Tensor, Tensor)> { + let i = |v: i32| v.to_le_bytes().to_vec(); + let l = |v: i64| v.to_le_bytes().to_vec(); + let amax = Tensor::new(dev.alloc_zeroed(4)?, vec![1], DType::U32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(x.buffer.as_ref(), x.offset), (amax.buffer.as_ref(), 0)], + &[l(n as i64), i(1), i(0), 0f32.to_le_bytes().to_vec()], + [((n as u32).div_ceil(256 * 64)).max(1), 1, 1], [256, 1, 1], 0, false)?; + let sc = Tensor::new(dev.alloc(8)?, vec![2], DType::F32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "fp8_scale", + &[(amax.buffer.as_ref(), 0), (sc.buffer.as_ref(), 0)], + &[], [1, 1, 1], [1, 1, 1], 0, false)?; + let q = Tensor::new(dev.alloc(n)?, vec![n], DType::U32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "fp8_quant", + &[(x.buffer.as_ref(), x.offset), (q.buffer.as_ref(), 0), (sc.buffer.as_ref(), 0)], + &[l(n as i64)], + [(n as u32).div_ceil(256 * 4).max(1), 1, 1], [256, 1, 1], 0, false)?; + Ok((q, sc)) +} + +/// FP8 e4m3 dense GEMM with per-tensor scales (vendor-recipe precision for +/// the Mamba in/out projections + shared expert on this model family). +#[allow(clippy::too_many_arguments)] +pub fn gemm_fp8( + dev: &dyn Device, + x_q: &Tensor, x_sc: &Tensor, + w_q: &Tensor, w_sc: &Tensor, + m: usize, n: usize, k: usize, out_f32: bool, +) -> Result { + let out = Tensor::empty(dev, vec![m, n], if out_f32 { DType::F32 } else { DType::F16 })?; + dev.gemm_fp8( + x_q.buffer.as_ref(), x_sc.buffer.as_ref(), + w_q.buffer.as_ref(), w_sc.buffer.as_ref(), + out.buffer.as_ref(), m, n, k, out_f32)?; + Ok(out) +} + +/// Per-row e4m3 quant of an f16/f32 `[r,k]` tensor → `(q [r*k] bytes, scale [r] f32)`. +/// Per-channel (weights, rows=n) / per-token (acts, rows=m) — the quality- +/// preserving FP8 granularity (per-tensor crushed cosine to ~0.94). +pub fn fp8_quant_perrow(dev: &dyn Device, x: &Tensor, r: usize, k: usize) -> Result<(Tensor, Tensor)> { + let i = |v: i32| v.to_le_bytes().to_vec(); + let q = Tensor::new(dev.alloc(r * k)?, vec![r * k], DType::U32); + let sc = Tensor::new(dev.alloc(r * 4)?, vec![r], DType::F32); + let kn = if x.dtype == DType::F32 { "fp8_quant_perrow_f32" } else { "fp8_quant_perrow" }; + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", kn, + &[(x.buffer.as_ref(), x.offset), (q.buffer.as_ref(), 0), (sc.buffer.as_ref(), 0)], + &[i(r as i32), i(k as i32)], [r as u32, 1, 1], [256, 1, 1], 0, false)?; + Ok((q, sc)) +} + +/// FP8 e4m3 GEMM with PER-CHANNEL weight + PER-TOKEN act scales, folded as a +/// post-pass (`D[r,c] *= xsc[r]·wsc[c]`) — sidesteps cuBLASLt's unsupported +/// OUTER_VEC FP8 path. `x_q`/`x_sc` per-token (rows=m), `w_q`/`w_sc` +/// per-channel (rows=n). Quality-preserving (cosine >0.98 typical). out[m,n] f32. +#[allow(clippy::too_many_arguments)] +pub fn gemm_fp8_perchan( + dev: &dyn Device, + x_q: &Tensor, x_sc: &Tensor, + w_q: &Tensor, w_sc: &Tensor, + m: usize, n: usize, k: usize, +) -> Result { + let out = Tensor::empty(dev, vec![m, n], DType::F32)?; + // identity per-tensor scale (1.0) — the real per-channel/per-token scales + // are folded in the post-pass below. D_raw = Wq·Xq in f32. + let one = scalar_buf(dev, (1.0f32).to_bits())?; + dev.gemm_fp8( + x_q.buffer.as_ref(), one.as_ref(), + w_q.buffer.as_ref(), one.as_ref(), + out.buffer.as_ref(), m, n, k, true)?; + let i = |v: i32| v.to_le_bytes().to_vec(); + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", "fp8_rowcol_scale_f32", + &[(out.buffer.as_ref(), 0), (x_sc.buffer.as_ref(), x_sc.offset), (w_sc.buffer.as_ref(), w_sc.offset)], + &[i(m as i32), i(n as i32)], + [((m*n) as u32).div_ceil(256*4).max(1), 1, 1], [256, 1, 1], 0, false)?; + Ok(out) +} + +/// Block-scaled NVFP4 dense GEMM: `out[m,n](f16) = X·Wᵀ` with both sides in +/// the `lt_fp4_quant` format. Routes through the backend's cublasLt FP4 path +/// (~4x the f16 tensor-core GEMM on GB10). +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] +pub fn gemm_fp4( + dev: &dyn Device, + x_pack: &Tensor, x_sf: &Tensor, + w_pack: &Tensor, w_sf: &Tensor, + m: usize, n: usize, k: usize, out_f32: bool, + d_scale: Option<&Tensor>, +) -> Result { + let out = Tensor::empty(dev, vec![m, n], if out_f32 { DType::F32 } else { DType::F16 })?; + dev.gemm_fp4( + x_pack.buffer.as_ref(), x_sf.buffer.as_ref(), + w_pack.buffer.as_ref(), w_sf.buffer.as_ref(), + out.buffer.as_ref(), m, n, k, out_f32, None)?; + if let Some(ds) = d_scale { + let l = |v: i64| v.to_le_bytes().to_vec(); + let ne = (m * n) as i64; + let kn = if out_f32 { "fp4_apply_dscale_f32" } else { "fp4_apply_dscale_f16" }; + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", kn, + &[(out.buffer.as_ref(), 0), (ds.buffer.as_ref(), ds.offset)], + &[l(ne)], [((ne as u32).div_ceil(256*4)).max(1),1,1],[256,1,1],0,false)?; + } + Ok(out) +} + +/// Global-aware dense FP4 quant: per-TENSOR global keeps the ue4m3 block +/// scales in normal range (block-amax/6 relative to the global) instead of +/// the subnormal floor that wrecks small-magnitude dense weights (cosine +/// 0.44 -> ~0.97). Returns (pack, sf, gs[2]=[global,unused]); the GEMM folds +/// the act·weight globals back via its D-scale. f16 or f32 input. +/// Four-Over-Six variant (adaptive 4/6 block scale; NVFP4 quality, GEMM-identical = free). f16 in. +pub fn lt_fp4_quant_g_46(dev: &dyn Device, x: &Tensor, r: usize, k: usize) -> Result<(Tensor, Tensor, Tensor)> { + if k % 32 != 0 { return Err(Error::Msg(format!("lt_fp4_quant_g_46: k {k} %32!=0"))); } + let i = |v: i32| v.to_le_bytes().to_vec(); + let l = |v: i64| v.to_le_bytes().to_vec(); + let kb = k / 16; let kb4 = kb.div_ceil(4); + let sf_bytes = r.div_ceil(128) * 512 * kb4; + let amax = Tensor::new(dev.alloc_zeroed(4)?, vec![1], DType::U32); + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(x.buffer.as_ref(), x.offset), (amax.buffer.as_ref(), 0)], + &[l((r*k) as i64), i(1), i(0), 0f32.to_le_bytes().to_vec()], + [((r*k) as u32).div_ceil(256*64).max(1),1,1],[256,1,1],0,false)?; + let gs = Tensor::new(dev.alloc(8)?, vec![2], DType::F32); + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_gscale", + &[(amax.buffer.as_ref(),0),(gs.buffer.as_ref(),0)], &[], [1,1,1],[1,1,1],0,false)?; + let pack = Tensor::new(dev.alloc((r*k)/2)?, vec![(r*k)/2], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_bytes)?, vec![sf_bytes], DType::U32); + let nb = (r*kb) as u32; + let kname = if x.dtype == DType::F32 { "lt_fp4_quant_gs_f32" } else { "lt_fp4_quant_gs_46" }; + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", kname, + &[(x.buffer.as_ref(), x.offset), (pack.buffer.as_ref(),0), (sf.buffer.as_ref(),0), (gs.buffer.as_ref(),0)], + &[i(r as i32), i(k as i32)], + [nb.div_ceil(256),1,1],[256,1,1],0,false)?; + Ok((pack, sf, gs)) +} + +pub fn lt_fp4_quant_g(dev: &dyn Device, x: &Tensor, r: usize, k: usize) -> Result<(Tensor, Tensor, Tensor)> { + if k % 32 != 0 { return Err(Error::Msg(format!("lt_fp4_quant_g: k {k} %32!=0"))); } + let i = |v: i32| v.to_le_bytes().to_vec(); + let l = |v: i64| v.to_le_bytes().to_vec(); + let kb = k / 16; let kb4 = kb.div_ceil(4); + let sf_bytes = r.div_ceil(128) * 512 * kb4; + // per-tensor global = amax over [r,k] -> nvfp4_gscale (gs[0]=amax/(6*448)). + let amax = Tensor::new(dev.alloc_zeroed(4)?, vec![1], DType::U32); + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(x.buffer.as_ref(), x.offset), (amax.buffer.as_ref(), 0)], + &[l((r*k) as i64), i(1), i(0), 0f32.to_le_bytes().to_vec()], + [((r*k) as u32).div_ceil(256*64).max(1),1,1],[256,1,1],0,false)?; + let gs = Tensor::new(dev.alloc(8)?, vec![2], DType::F32); + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_gscale", + &[(amax.buffer.as_ref(),0),(gs.buffer.as_ref(),0)], &[], [1,1,1],[1,1,1],0,false)?; + let pack = Tensor::new(dev.alloc((r*k)/2)?, vec![(r*k)/2], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_bytes)?, vec![sf_bytes], DType::U32); + let nb = (r*kb) as u32; + let kname = if x.dtype == DType::F32 { "lt_fp4_quant_gs_f32" } else { "lt_fp4_quant_gs" }; + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", kname, + &[(x.buffer.as_ref(), x.offset), (pack.buffer.as_ref(),0), (sf.buffer.as_ref(),0), (gs.buffer.as_ref(),0)], + &[i(r as i32), i(k as i32)], + [nb.div_ceil(256),1,1],[256,1,1],0,false)?; + Ok((pack, sf, gs)) +} + +/// No-global FP4 quant: per-16 ue4m3 block scales absorb the FULL magnitude +/// (global gs=[1,1]), so the dequant stays ~true-scale and the f16-output GEMM +/// (out_f32=false) does NOT overflow — fast f16 path AND correct (vs the +/// two-level global variant whose globally-normalized raw blows past f16 max). +pub fn lt_fp4_quant_g_ng(dev: &dyn Device, x: &Tensor, r: usize, k: usize) -> Result<(Tensor, Tensor, Tensor)> { + if k % 32 != 0 { return Err(Error::Msg(format!("lt_fp4_quant_g_ng: k {k} %32!=0"))); } + let i = |v: i32| v.to_le_bytes().to_vec(); + let kb = k / 16; let kb4 = kb.div_ceil(4); + let sf_bytes = r.div_ceil(128) * 512 * kb4; + let one_bytes: Vec = [1f32, 1f32].iter().flat_map(|f| f.to_le_bytes()).collect(); + let gs = Tensor::new(dev.upload(&one_bytes)?, vec![2], DType::F32); // gs=[1, 1]: no global + let pack = Tensor::new(dev.alloc((r*k)/2)?, vec![(r*k)/2], DType::U32); + let sf = Tensor::new(dev.alloc_zeroed(sf_bytes)?, vec![sf_bytes], DType::U32); + let nb = (r*kb) as u32; + let kname = if x.dtype == DType::F32 { "lt_fp4_quant_gs_f32" } else { "lt_fp4_quant_gs" }; + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", kname, + &[(x.buffer.as_ref(), x.offset), (pack.buffer.as_ref(),0), (sf.buffer.as_ref(),0), (gs.buffer.as_ref(),0)], + &[i(r as i32), i(k as i32)], + [nb.div_ceil(256),1,1],[256,1,1],0,false)?; + Ok((pack, sf, gs)) +} + +/// dscale[0] = act_global * weight_global (device, one f32) for the FP4 D-scale fold. +pub fn fp4_dscale(dev: &dyn Device, ga: &Tensor, gw: &Tensor) -> Result { + let d = Tensor::new(dev.alloc(4)?, vec![1], DType::F32); + dev.dispatch_raw_cuda(MOE_FP4_SRC, "moe_fp4.cu", "fp4_dscale", + &[(ga.buffer.as_ref(), ga.offset), (gw.buffer.as_ref(), gw.offset), (d.buffer.as_ref(),0)], + &[], [1,1,1],[1,1,1],0,false)?; + Ok(d) +} + +/// Quantize+fragment-pack MoE expert weights to NVFP4 on device. +/// `w_f16`: f16 `[n_exp*n, k]` row-major (concatenated experts). Returns +/// `(wp, wsc, gw)` — fragment codes, scale words, per-expert globals (device). +pub fn moe_fp4_pack_weights( + dev: &dyn Device, w_f16: &Tensor, n_exp: usize, n: usize, k: usize, +) -> Result<(Tensor, Tensor, Tensor)> { + if k % 64 != 0 { return Err(Error::Msg(format!("moe_fp4_pack_weights: K {k} %64!=0"))); } + if n % 8 != 0 { return Err(Error::Msg(format!("moe_fp4_pack_weights: N {n} %8!=0"))); } + let (kt, ntl) = (k / 64, n / 8); + let amax = Tensor::new(dev.alloc_zeroed(n_exp * 4).map_err(|e| Error::Msg(format!("{e:?}")))?, vec![n_exp], DType::U32); + let gw = Tensor::empty(dev, vec![n_exp], DType::F32)?; + let i = |x: i32| x.to_le_bytes().to_vec(); + let l = |x: i64| x.to_le_bytes().to_vec(); + let slab = (n * k) as i64; + let total_elems = (n_exp * n * k) as u32; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(w_f16.buffer.as_ref(), w_f16.offset), (amax.buffer.as_ref(), 0)], + &[l(slab), i(n_exp as i32), i(0), 0f32.to_le_bytes().to_vec()], + [total_elems.div_ceil(256 * 8), 1, 1], [256, 1, 1], 0, false)?; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_globals", + &[(amax.buffer.as_ref(), 0), (gw.buffer.as_ref(), 0)], + &[i(n_exp as i32)], + [(n_exp as u32).div_ceil(64), 1, 1], [64, 1, 1], 0, false)?; + let wp = Tensor::empty(dev, vec![n_exp * ntl * kt * 32 * 2], DType::U32)?; + let wsc = Tensor::empty(dev, vec![n_exp * ntl * kt * 8], DType::U32)?; + let warps = (n_exp * ntl * kt) as u32; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_pack_w", + &[(w_f16.buffer.as_ref(), w_f16.offset), (gw.buffer.as_ref(), 0), + (wp.buffer.as_ref(), 0), (wsc.buffer.as_ref(), 0)], + &[i(n_exp as i32), i(n as i32), i(k as i32)], + [(warps * 32).div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + Ok((wp, wsc, gw)) +} + +/// Fully-on-device NVFP4 grouped MoE GEMM (W4A4): quantizes the f16 sorted +/// activations `a [mt,k]` to NVFP4 (dynamic per-call global), packs fragments, +/// and runs the Blackwell block-scaled mma over BM=16 tile descriptors built +/// from `offsets` (device `[n_exp+1]`). Fixed launch — graph-safe, no host +/// syncs. Returns `out [mt, n]` f16. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] +pub fn moe_fp4_grouped_mma_dev( + dev: &dyn Device, a: &Tensor, wp: &Tensor, wsc: &Tensor, gw: &Tensor, + offsets: &Tensor, n_exp: usize, mt: usize, n: usize, k: usize, + act_mode: i32, act_scale: f32, + amax_in: Option<&Tensor>, amax_out: Option<(&Tensor, i32, f32)>, + gather_idx: Option<&Tensor>, +) -> Result { + if k % 64 != 0 { return Err(Error::Msg(format!("moe_fp4_grouped_mma_dev: K {k} %64!=0"))); } + let out = Tensor::empty(dev, vec![mt.max(1), n], DType::F16)?; + if mt == 0 { return Ok(out); } + let (kt, ntl) = (k / 64, n / 8); + let maxt = mt.div_ceil(16) + n_exp; + let t0d = Tensor::empty(dev, vec![maxt], DType::U32)?; + let eidd = Tensor::empty(dev, vec![maxt], DType::U32)?; + let ged = Tensor::empty(dev, vec![maxt], DType::U32)?; + let i = |x: i32| x.to_le_bytes().to_vec(); + let l = |x: i64| x.to_le_bytes().to_vec(); + dev.dispatch_raw_cuda( + MOE_BUILD_TILES_SRC, "moe_build_tiles.cu", "moe_build_tiles", + &[(offsets.buffer.as_ref(), offsets.offset), (t0d.buffer.as_ref(), 0), + (eidd.buffer.as_ref(), 0), (ged.buffer.as_ref(), 0)], + &[i(n_exp as i32), i(16), i(maxt as i32)], + [1, 1, 1], [1, 1, 1], 0, false)?; + // dynamic activation global: use the producer-GEMM's fused amax when + // provided (skips a full re-read of the activation tensor). + let amax_owned; + let amax: &Tensor = match amax_in { + Some(t) => t, + None => { + amax_owned = Tensor::new(dev.alloc_zeroed(4).map_err(|e| Error::Msg(format!("{e:?}")))?, vec![1], DType::U32); + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_amax_slab", + &[(a.buffer.as_ref(), a.offset), (amax_owned.buffer.as_ref(), 0)], + &[l(a.elem_count() as i64), i(1), i(act_mode), act_scale.to_le_bytes().to_vec()], + [(a.elem_count() as u32).div_ceil(256 * 64).max(1), 1, 1], [256, 1, 1], 0, false)?; + &amax_owned + } + }; + let ga = Tensor::empty(dev, vec![1], DType::F32)?; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "nvfp4_globals", + &[(amax.buffer.as_ref(), 0), (ga.buffer.as_ref(), 0)], + &[i(1)], + [1, 1, 1], [64, 1, 1], 0, false)?; + // act quant+pack + let ap = Tensor::empty(dev, vec![maxt * kt * 32 * 4], DType::U32)?; + let asc = Tensor::empty(dev, vec![maxt * kt * 32], DType::U32)?; + let warps = (maxt * kt) as u32; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "moe_fp4_actq_pack", + &[(a.buffer.as_ref(), a.offset), (t0d.buffer.as_ref(), 0), (ged.buffer.as_ref(), 0), + (ga.buffer.as_ref(), 0), (ap.buffer.as_ref(), 0), (asc.buffer.as_ref(), 0), + match gather_idx { Some(t) => (t.buffer.as_ref(), t.offset), None => (t0d.buffer.as_ref(), 0) }], + &[i(maxt as i32), i(k as i32), i(mt as i32), i(act_mode), act_scale.to_le_bytes().to_vec(), + i(if gather_idx.is_some() { 1 } else { 0 })], + [(warps * 32).div_ceil(256), 1, 1], [256, 1, 1], 0, false)?; + // grouped mma: grid [maxt, NT/8] × 8 warps/block + // x4 variant: 4 n8-tiles per warp (A frags register-reused) — +49% on + // GB10 vs 1 nt/warp (L2-poorer than desktop Blackwell, where x1 wins). + let warps_per_block = 8u32; + let ng = ntl.div_ceil(4) as u32; + dev.dispatch_raw_cuda( + MOE_FP4_SRC, "moe_fp4.cu", "moe_fp4_grouped_mma_x4", + &[(ap.buffer.as_ref(), 0), (asc.buffer.as_ref(), 0), + (wp.buffer.as_ref(), wp.offset), (wsc.buffer.as_ref(), wsc.offset), + (t0d.buffer.as_ref(), 0), (eidd.buffer.as_ref(), 0), (ged.buffer.as_ref(), 0), + (ga.buffer.as_ref(), 0), (gw.buffer.as_ref(), gw.offset), + (out.buffer.as_ref(), 0), + match &amax_out { Some((t, _, _)) => (t.buffer.as_ref(), t.offset), None => (ga.buffer.as_ref(), 0) }], + &[i(n as i32), i(k as i32), i(mt as i32), + i(amax_out.as_ref().map(|(_, m, _)| *m).unwrap_or(-1)), + amax_out.as_ref().map(|(_, _, sc)| *sc).unwrap_or(0.0).to_le_bytes().to_vec()], + [maxt as u32, ng.div_ceil(warps_per_block), 1], + [warps_per_block * 32, 1, 1], 0, false)?; + Ok(out) +} + +/// Fused residual-add + RMS norm: `residual_out = a + b`, `normed_out = +/// rms_norm(a + b, w)` — one pass instead of an `add` kernel plus a +/// `rms_norm` kernel re-reading the sum (the per-layer residual hot path). +pub fn add_rms_norm( + dev: &dyn Device, a: &Tensor, b: &Tensor, weight: &Tensor, eps: f32, +) -> Result<(Tensor, Tensor)> { + let n = *a.shape.last().ok_or_else(|| Error::Msg("add_rms_norm: scalar input".into()))?; + if n % 4 != 0 || n > 4096 * 4 { + return Err(Error::Msg(format!("add_rms_norm: n {n} unsupported (need %4, fits one block)"))); + } + let rows = a.elem_count() / n; + let residual = Tensor::empty(dev, a.shape.clone(), a.dtype)?; + let normed = Tensor::empty(dev, a.shape.clone(), a.dtype)?; + let eps_buf = scalar_buf(dev, eps.to_bits())?; + // Reduction mode: one threadgroup per row, `tid` = lane within the row + // (the same TG=n/4 contract as mt_rms_norm). + let k = cached_ir("mt_add_rms_norm", a.dtype, || { + let mut k = metaltile_std::mlx::unary::mt_add_rms_norm::kernel_ir_for(a.dtype); + k.mode = metaltile_core::ir::KernelMode::Reduction; + k + }); + let grid = Grid { grid: [rows as u32, 1, 1], block: [(n / 4) as u32, 1, 1] }; + dev.dispatch( + &k, + &[ + Binding::Buffer(a.buffer.clone()), + Binding::Buffer(b.buffer.clone()), + Binding::Buffer(weight.buffer.clone()), + Binding::Buffer(eps_buf), + Binding::Buffer(residual.buffer.clone()), + Binding::Buffer(normed.buffer.clone()), + Binding::Scalar((n as u32).to_le_bytes().to_vec()), + ], + grid, + )?; + Ok((residual, normed)) +} diff --git a/rust/crates/ffai-ops/src/ssd_scan.rs b/rust/crates/ffai-ops/src/ssd_scan.rs new file mode 100644 index 00000000..e9db865c --- /dev/null +++ b/rust/crates/ffai-ops/src/ssd_scan.rs @@ -0,0 +1,1281 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # Mamba2 SSD chunked-MATMUL prefill scan (state-space duality) +//! +//! Replaces the sequential-in-T `ssm_prefill_scan` with the chunk-scan form +//! (Dao & Gu, "Transformers are SSMs") that runs the scan as cuBLAS tensor-core +//! GEMMs. Split T into `nc = ceil(T/L)` chunks of length L; per (chunk, head): +//! +//! ```text +//! A = -exp(a_log[h]) +//! Lcs[i] = Σ_{k≤i} A*dt[k] (inclusive cumsum within chunk) +//! Lmask[i,j] = exp(Lcs[i]-Lcs[j]) (i≥j) else 0 +//! +//! 1. CB = C · Bᵀ [L,L] (G1, batched) +//! M[i,j] = CB[i,j]·Lmask[i,j]·dt[j] (custom kernel) +//! 2. y_intra = M · x [L,dh] (G2, batched) +//! 3. S_chunk = (decay·dt·B)ᵀ · x [ds,dh] (G3, batched) +//! decay[j] = exp(Lcs[L-1]-Lcs[j]) +//! 4. recurrence (serial over nc): S_in[c+1]=αc·S_in[c]+S_chunk[c] +//! αc = exp(Lcs[L-1]) of chunk c +//! 5. CS = C · S_in [L,dh] (G4, batched) +//! y[i] = y_intra[i] + exp(Lcs[i])·CS[i] + x[i]·D[h] (custom kernel) +//! ``` +//! +//! Steps 1,2,3,5 are `cublasGemmStridedBatchedEx` over batch = nc*H. B/C are +//! shared across heads-per-group (group g = h/hpg) — the build kernels fan the +//! group out to each head's batch slot. FP16 GEMM in / FP32 accumulate; all the +//! decay / segsum / elementwise math stays FP32. Gated by `NEMOTRON_SSD_MATMUL`. +//! +//! Fixed for NemotronH: dh=64, ds=128, H=64, G=8 (hpg=8). L ∈ {128,256}. +//! +//! Lives in its own module (not `lib.rs`) to avoid edit contention with the +//! concurrent tensor-core attention work on the same branch. + +use ffai_core::{DType, Device, Error, Result, Tensor}; + +/// CUDA: inclusive cumsum of A*dt within each chunk → Lcs (f32), one thread per +/// (chunk, head). A = -exp(a_log[h]). dt layout [T, H]. Lcs layout [nc*H, L]. +const SSD_LCS_SRC: &str = r#" +extern "C" __global__ void ssd_lcs( + const float* __restrict__ dt, // [T, H] + const float* __restrict__ a_log, // [H] + float* __restrict__ lcs, // [nc*H, L] inclusive cumsum of A*dt + unsigned int T, unsigned int H, unsigned int L, unsigned int nc) +{ + unsigned int idx = blockIdx.x * blockDim.x + threadIdx.x; // bh = c*H + h + if (idx >= nc * H) return; + unsigned int c = idx / H; + unsigned int h = idx % H; + float a = -expf(a_log[h]); + float acc = 0.f; + for (unsigned int i = 0; i < L; ++i) { + unsigned int t = c * L + i; + float dtv = (t < T) ? dt[t * H + h] : 0.f; + acc += a * dtv; + lcs[idx * L + i] = acc; + } +} +"#; + +/// CUDA: gather/cast C,B from [T,G,ds] into [nc*H, L, ds] (head h uses group +/// h/hpg), broadcasting the group operand into every head's batch slot. f16. +const SSD_GATHER_BC_SRC: &str = r#" +#include +extern "C" __global__ void ssd_gather_bc( + const float* __restrict__ b_mat, // [T, G, ds] + const float* __restrict__ c_mat, // [T, G, ds] + __half* __restrict__ b_out, // [nc*H, L, ds] f16 + __half* __restrict__ c_out, // [nc*H, L, ds] f16 + unsigned int T, unsigned int H, unsigned int G, unsigned int hpg, + unsigned int L, unsigned int ds, unsigned int nc) +{ + unsigned long long n = (unsigned long long)nc * H * L * ds; + unsigned long long e = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + if (e >= n) return; + unsigned int s = (unsigned int)(e % ds); + unsigned int i = (unsigned int)((e / ds) % L); + unsigned int bh = (unsigned int)(e / ((unsigned long long)ds * L)); + unsigned int c = bh / H; + unsigned int h = bh % H; + unsigned int g = h / hpg; + unsigned int t = c * L + i; + float bv = 0.f, cv = 0.f; + if (t < T) { + bv = b_mat[(t * G + g) * ds + s]; + cv = c_mat[(t * G + g) * ds + s]; + } + b_out[e] = __float2half(bv); + c_out[e] = __float2half(cv); +} +"#; + +/// CUDA (FUSED path): gather/cast C,B from [T,G,ds] into PER-GROUP [nc*G, L, ds] +/// f16 — NO 8× head broadcast (that 8× write is replaced by a device-pointer +/// array in the batched GEMM, where head h's batch slot points at group h/hpg's +/// slice). 8× fewer writes / 8× smaller scratch than `ssd_gather_bc`. +const SSD_GATHER_BC_G_SRC: &str = r#" +#include +extern "C" __global__ void ssd_gather_bc_g( + const float* __restrict__ b_mat, // B base (row-strided; see rs/b_off) + const float* __restrict__ c_mat, // C base + __half* __restrict__ b_out, // [nc*G, L, ds] f16 (per group) + __half* __restrict__ c_out, // [nc*G, L, ds] f16 (per group) + unsigned int T, unsigned int G, + unsigned int L, unsigned int ds, unsigned int nc, + unsigned int rs, unsigned int b_off, unsigned int c_off) // row stride + col offsets +{ + unsigned long long n = (unsigned long long)nc * G * L * ds; + unsigned long long e = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + if (e >= n) return; + unsigned int s = (unsigned int)(e % ds); + unsigned int i = (unsigned int)((e / ds) % L); + unsigned int cg = (unsigned int)(e / ((unsigned long long)ds * L)); // c*G + g + unsigned int c = cg / G; + unsigned int g = cg % G; + unsigned int t = c * L + i; + float bv = 0.f, cv = 0.f; + if (t < T) { + bv = b_mat[(unsigned long long)t * rs + b_off + g * ds + s]; + cv = c_mat[(unsigned long long)t * rs + c_off + g * ds + s]; + } + b_out[e] = __float2half(bv); + c_out[e] = __float2half(cv); +} +"#; + +/// CUDA (GLUE-FUSE front): one launch that does ssd_lcs + ssd_gather_bc_g + +/// ssd_xt. These three are independent (distinct source tensors, distinct +/// outputs) so this merges 3 kernel launches → 1 and lets the scheduler overlap +/// the three traffic streams. BIT-IDENTICAL to the three separate kernels (same +/// math, same writes). A grid-stride sweep handles each output's element count +/// independently. Gated by NEMOTRON_SSD_GLUE_FUSE. +const SSD_FRONT_FUSED_SRC: &str = r#" +#include +extern "C" __global__ void ssd_front_fused( + const float* __restrict__ dt, // [T, H] + const float* __restrict__ a_log, // [H] + float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ b_mat, // B base (row-strided) + const float* __restrict__ c_mat, // C base + __half* __restrict__ b_out, // [nc*G, L, ds] f16 + __half* __restrict__ c_out, // [nc*G, L, ds] f16 + const float* __restrict__ x, // x base (row-strided) + __half* __restrict__ xt, // [nc*H, dh, L] f16 + unsigned int T, unsigned int H, unsigned int G, + unsigned int L, unsigned int ds, unsigned int dh, unsigned int nc, + unsigned int bc_rs, unsigned int b_off, unsigned int c_off, + unsigned int x_rs, unsigned int x_off) +{ + unsigned long long gid = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + unsigned long long gstride = (unsigned long long)gridDim.x * blockDim.x; + + // --- (1) lcs cumsum: one thread per (chunk, head) = nc*H --- + for (unsigned long long idx = gid; idx < (unsigned long long)nc * H; idx += gstride) { + unsigned int c = (unsigned int)(idx / H); + unsigned int h = (unsigned int)(idx % H); + float a = -expf(a_log[h]); + float acc = 0.f; + for (unsigned int i = 0; i < L; ++i) { + unsigned int t = c * L + i; + float dtv = (t < T) ? dt[t * H + h] : 0.f; + acc += a * dtv; + lcs[idx * L + i] = acc; + } + } + + // --- (2) gather B/C per-group: nc*G*L*ds elements --- + unsigned int hpg = H / G; + unsigned long long nbc = (unsigned long long)nc * G * L * ds; + for (unsigned long long e = gid; e < nbc; e += gstride) { + unsigned int s = (unsigned int)(e % ds); + unsigned int i = (unsigned int)((e / ds) % L); + unsigned int cg = (unsigned int)(e / ((unsigned long long)ds * L)); + unsigned int c = cg / G; + unsigned int g = cg % G; + unsigned int t = c * L + i; + float bv = 0.f, cv = 0.f; + if (t < T) { + bv = b_mat[(unsigned long long)t * bc_rs + b_off + g * ds + s]; + cv = c_mat[(unsigned long long)t * bc_rs + c_off + g * ds + s]; + } + b_out[e] = __float2half(bv); + c_out[e] = __float2half(cv); + } + (void)hpg; + + // --- (3) transpose x → xt [nc*H, dh, L]: nc*H*dh*L elements --- + unsigned long long nx = (unsigned long long)nc * H * dh * L; + for (unsigned long long e = gid; e < nx; e += gstride) { + unsigned int i = (unsigned int)(e % L); + unsigned int p = (unsigned int)((e / L) % dh); + unsigned int bh = (unsigned int)(e / ((unsigned long long)L * dh)); + unsigned int c = bh / H; + unsigned int h = bh % H; + unsigned int t = c * L + i; + float v = (t < T) ? x[(unsigned long long)t * x_rs + x_off + h * dh + p] : 0.f; + xt[e] = __float2half(v); + } +} +"#; + +/// CUDA: transpose x [T,H,dh] → xT [nc*H, dh, L] (f16). Head h, chunk c. +const SSD_XT_SRC: &str = r#" +#include +extern "C" __global__ void ssd_xt( + const float* __restrict__ x, // x base (row-strided) + __half* __restrict__ xt, // [nc*H, dh, L] + unsigned int T, unsigned int H, unsigned int dh, unsigned int L, unsigned int nc, + unsigned int rs, unsigned int x_off) +{ + unsigned long long n = (unsigned long long)nc * H * dh * L; + unsigned long long e = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + if (e >= n) return; + unsigned int i = (unsigned int)(e % L); // position within chunk + unsigned int p = (unsigned int)((e / L) % dh); // head_dim index + unsigned int bh = (unsigned int)(e / ((unsigned long long)L * dh)); + unsigned int c = bh / H; + unsigned int h = bh % H; + unsigned int t = c * L + i; + float v = (t < T) ? x[(unsigned long long)t * rs + x_off + h * dh + p] : 0.f; + xt[e] = __float2half(v); +} +"#; + +/// CUDA: M[i,j] = CB[i,j]·exp(Lcs[i]-Lcs[j])·dt[j], causal (i≥j) else 0. f16. +/// CB is the f16 output of G1 ([nc*H, L, L]). dt layout [T,H]. One thread/elem. +const SSD_MMASK_SRC: &str = r#" +#include +extern "C" __global__ void ssd_mmask( + const __half* __restrict__ cb, // [nc*H, L, L] = C·Bᵀ + const float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ dt, // [T, H] + __half* __restrict__ m_out, // [nc*H, L, L] f16 + unsigned int T, unsigned int H, unsigned int L, unsigned int nc) +{ + unsigned long long n = (unsigned long long)nc * H * L * L; + unsigned long long e = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + if (e >= n) return; + unsigned int j = (unsigned int)(e % L); + unsigned int i = (unsigned int)((e / L) % L); + unsigned int bh = (unsigned int)(e / ((unsigned long long)L * L)); + if (i < j) { m_out[e] = __float2half(0.f); return; } + unsigned int c = bh / H; + unsigned int h = bh % H; + unsigned int tj = c * L + j; + float dtj = (tj < T) ? dt[tj * H + h] : 0.f; + float decay = expf(lcs[bh * L + i] - lcs[bh * L + j]); + float v = __half2float(cb[e]) * decay * dtj; + m_out[e] = __float2half(v); +} +"#; + +/// CUDA (GLUE-FUSE): in-place variant of `ssd_mmask` — masks `cb` IN PLACE +/// (single inout buffer, no separate `mmask` materialization). Each thread reads +/// and writes only its own element `e`, so the in-place update is hazard-free. +/// Dropping the distinct `mmask` buffer keeps the just-read tile L2-resident for +/// the write, cutting effective DRAM traffic. Bit-identical to `ssd_mmask`. +const SSD_MMASK_INPLACE_SRC: &str = r#" +#include +extern "C" __global__ void ssd_mmask_inplace( + __half* __restrict__ cb, // [nc*H, L, L] (masked in place) + const float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ dt, // [T, H] + unsigned int T, unsigned int H, unsigned int L, unsigned int nc) +{ + unsigned long long n = (unsigned long long)nc * H * L * L; + unsigned long long e = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + if (e >= n) return; + unsigned int j = (unsigned int)(e % L); + unsigned int i = (unsigned int)((e / L) % L); + unsigned int bh = (unsigned int)(e / ((unsigned long long)L * L)); + if (i < j) { cb[e] = __float2half(0.f); return; } + unsigned int c = bh / H; + unsigned int h = bh % H; + unsigned int tj = c * L + j; + float dtj = (tj < T) ? dt[tj * H + h] : 0.f; + float decay = expf(lcs[bh * L + i] - lcs[bh * L + j]); + float v = __half2float(cb[e]) * decay * dtj; + cb[e] = __float2half(v); +} +"#; + +/// CUDA: build BdT[s,j] = exp(Lcs[L-1]-Lcs[j])·dt[j]·B[j,s] → [nc*H, ds, L] f16. +/// (the decayed, dt-weighted, transposed B for chunk-state G3.) B is [T,G,ds]. +const SSD_BDT_SRC: &str = r#" +#include +extern "C" __global__ void ssd_bdt( + const float* __restrict__ b_mat, // B base (row-strided) + const float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ dt, // [T, H] + __half* __restrict__ bdt, // [nc*H, ds, L] + unsigned int T, unsigned int H, unsigned int G, unsigned int hpg, + unsigned int L, unsigned int ds, unsigned int nc, + unsigned int rs, unsigned int b_off) +{ + unsigned long long n = (unsigned long long)nc * H * ds * L; + unsigned long long e = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + if (e >= n) return; + unsigned int j = (unsigned int)(e % L); // position + unsigned int s = (unsigned int)((e / L) % ds); // state index + unsigned int bh = (unsigned int)(e / ((unsigned long long)L * ds)); + unsigned int c = bh / H; + unsigned int h = bh % H; + unsigned int g = h / hpg; + unsigned int tj = c * L + j; + float bv = 0.f, dtj = 0.f; + if (tj < T) { bv = b_mat[(unsigned long long)tj * rs + b_off + g * ds + s]; dtj = dt[tj * H + h]; } + float decay = expf(lcs[bh * L + (L - 1)] - lcs[bh * L + j]); + bdt[e] = __float2half(decay * dtj * bv); +} +"#; + +/// CUDA: serial inter-chunk recurrence. For each (head, state s, dh p): +/// S_in[c] = state at START of chunk c; S_in[0] = state_in[h,p,s] +/// S_in[c+1] = αc·S_in[c] + S_chunk[c], αc = exp(Lcs[bh,L-1]) +/// Emits SinT[bh] = S_in[c]ᵀ as [nc*H, dh, ds] (transposed for G4), all f16, +/// and writes the FINAL state (after chunk nc-1) to state_out [H, dh, ds] f32. +/// Grid: one thread per (head, s, p) = H*ds*dh threads; loops nc serially. +const SSD_RECUR_SRC: &str = r#" +#include +extern "C" __global__ void ssd_recur( + const __half* __restrict__ s_chunk, // [nc*H, ds, dh] + const float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ state_in, // [H, dh, ds] f32 + __half* __restrict__ sin_t, // [nc*H, dh, ds] f16 (S_inᵀ per chunk) + float* __restrict__ state_out,// [H, dh, ds] f32 + unsigned int H, unsigned int dh, unsigned int ds, unsigned int L, unsigned int nc) +{ + unsigned long long idx = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + unsigned long long tot = (unsigned long long)H * ds * dh; + if (idx >= tot) return; + unsigned int p = (unsigned int)(idx % dh); // head_dim + unsigned int s = (unsigned int)((idx / dh) % ds); // state + unsigned int h = (unsigned int)(idx / ((unsigned long long)dh * ds)); + // initial state for chunk 0 + float st = state_in[(h * dh + p) * ds + s]; + for (unsigned int c = 0; c < nc; ++c) { + unsigned int bh = c * H + h; + // emit S_inᵀ for this chunk BEFORE applying it: SinT[bh, p, s] = st + sin_t[(bh * dh + p) * ds + s] = __float2half(st); + float alpha = expf(lcs[bh * L + (L - 1)]); + float sc = __half2float(s_chunk[(bh * ds + s) * dh + p]); + st = alpha * st + sc; + } + state_out[(h * dh + p) * ds + s] = st; +} +"#; + +/// Transposed-coalesced inter-chunk recurrence: consumes S_chunkᵀ +/// `[nc*H, dh, ds]` (G3 emitted with swapped operands) with an s-fastest +/// thread map, so BOTH the chunk-state reads and the SinT/state writes are +/// fully coalesced — the original layout scattered one side 32-way across +/// the serial nc loop. Same math, same per-thread accumulation order. +const SSD_RECUR_T_SRC: &str = r#" +#include +extern "C" __global__ void ssd_recur_t( + const __half* __restrict__ s_chunk_t, // [nc*H, dh, ds] + const float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ state_in, // [H, dh, ds] f32 + __half* __restrict__ sin_t, // [nc*H, dh, ds] f16 + float* __restrict__ state_out, // [H, dh, ds] f32 + unsigned int H, unsigned int dh, unsigned int ds, unsigned int L, unsigned int nc) +{ + unsigned long long idx = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + unsigned long long tot = (unsigned long long)H * ds * dh; + if (idx >= tot) return; + unsigned int sct = (unsigned int)(idx % ds); // state (fastest) + unsigned int p = (unsigned int)((idx / ds) % dh); // head_dim + unsigned int h = (unsigned int)(idx / ((unsigned long long)ds * dh)); + float st = state_in[(h * dh + p) * ds + sct]; + for (unsigned int c = 0; c < nc; ++c) { + unsigned int bh = c * H + h; + sin_t[(bh * dh + p) * ds + sct] = __float2half(st); + float alpha = expf(lcs[bh * L + (L - 1)]); + float sc = __half2float(s_chunk_t[((unsigned long long)bh * dh + p) * ds + sct]); + st = alpha * st + sc; + } + state_out[(h * dh + p) * ds + sct] = st; +} +"#; + +/// Varlen inter-chunk recurrence. Identical to `ssd_recur` but resets the +/// carried state to zero at every chunk flagged in `seg_reset[c]` — i.e. the +/// first chunk of each packed sequence starts fresh. Requires each packed +/// segment length to be a multiple of `L` (chunk_len) so no chunk straddles a +/// boundary. With an all-zero `seg_reset` this is bit-identical to `ssd_recur`. +const SSD_RECUR_VARLEN_SRC: &str = r#" +#include +extern "C" __global__ void ssd_recur_varlen( + const __half* __restrict__ s_chunk, // [nc*H, ds, dh] + const float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ state_in, // [H, dh, ds] f32 + const unsigned int* __restrict__ seg_reset, // [nc] 1 → chunk starts a new sequence + __half* __restrict__ sin_t, // [nc*H, dh, ds] f16 + float* __restrict__ state_out,// [H, dh, ds] f32 + unsigned int H, unsigned int dh, unsigned int ds, unsigned int L, unsigned int nc) +{ + unsigned long long idx = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + unsigned long long tot = (unsigned long long)H * ds * dh; + if (idx >= tot) return; + unsigned int p = (unsigned int)(idx % dh); + unsigned int s = (unsigned int)((idx / dh) % ds); + unsigned int h = (unsigned int)(idx / ((unsigned long long)dh * ds)); + float st = state_in[(h * dh + p) * ds + s]; + for (unsigned int c = 0; c < nc; ++c) { + if (seg_reset[c]) st = 0.0f; // packed boundary → fresh recurrent state + unsigned int bh = c * H + h; + sin_t[(bh * dh + p) * ds + s] = __float2half(st); + float alpha = expf(lcs[bh * L + (L - 1)]); + float sc = __half2float(s_chunk[(bh * ds + s) * dh + p]); + st = alpha * st + sc; + } + state_out[(h * dh + p) * ds + s] = st; +} +"#; + +/// CUDA: final combine. y[t,h,p] = y_intra[bh,i,p] + exp(Lcs[bh,i])·CS[bh,i,p] +/// + x[t,h,p]·D[h]. y_intra, CS are f16 [nc*H, L, dh]; output y f32 [T,H,dh]. +const SSD_COMBINE_SRC: &str = r#" +#include +extern "C" __global__ void ssd_combine( + const __half* __restrict__ y_intra, // [nc*H, L, dh] + const __half* __restrict__ cs, // [nc*H, L, dh] + const float* __restrict__ lcs, // [nc*H, L] + const float* __restrict__ x, // x base (row-strided) + const float* __restrict__ d_skip, // [H] + float* __restrict__ y, // [T, H, dh] + unsigned int T, unsigned int H, unsigned int dh, unsigned int L, unsigned int nc, + unsigned int rs, unsigned int x_off) +{ + unsigned long long e = (unsigned long long)blockIdx.x * blockDim.x + threadIdx.x; + unsigned long long n = (unsigned long long)nc * H * L * dh; + if (e >= n) return; + unsigned int p = (unsigned int)(e % dh); + unsigned int i = (unsigned int)((e / dh) % L); + unsigned int bh = (unsigned int)(e / ((unsigned long long)L * dh)); + unsigned int c = bh / H; + unsigned int h = bh % H; + unsigned int t = c * L + i; + if (t >= T) return; + float yi = __half2float(y_intra[e]); + float decay = expf(lcs[bh * L + i]); + float ci = __half2float(cs[e]) * decay; + float xv = x[(unsigned long long)t * rs + x_off + h * dh + p]; + y[(t * H + h) * dh + p] = yi + ci + xv * d_skip[h]; +} +"#; + +/// FUSED tensor-core (wmma) SSD intra-chunk kernel: replaces G1 (CB=C·Bᵀ) + +/// `ssd_mmask` + G2 (y_intra=M·x) with ONE kernel — keeps both matmuls on the +/// tensor cores (wmma 16×16×16 f16→f32) and round-trips the L×L score tile +/// through SHARED only (never HBM), killing the `cb` + `mmask` [nc*H,L,L] +/// materializations. The SSD intra path is masked attention with NO softmax: +/// M[i,j] = (C[i]·B[j]) · exp(Lcs[i]-Lcs[j]) · dt[j] (causal i≥j, else 0) +/// y_intra[i,p] = Σ_j M[i,j]·x[j,p] +/// The decay's within-chunk `Lcs[i]-Lcs[j]` subtraction stays in fp32 in-kernel +/// (stability-critical — exp(individual Lcs) underflows). One block per +/// (chunk, head, 16-row tile); reads the per-GROUP c_f16/b_f16 slices directly +/// (fused gather path). Fixed: ds=128, dh=64 (NemotronH). f16 in C/B/x, f16 out. +const SSD_INTRA_WMMA_SRC: &str = r#" +#include +#include +using namespace nvcuda; +// FUSED tensor-core SSD intra-chunk: ONE block per (chunk,head), 8 warps (=L/16 +// row-tiles), C/B wmma frags loaded DIRECT FROM GLOBAL (L2-cached; matrix_b +// col_major handles the Bᵀ), x+lcs staged in ~28KB dynamic smem (->3 blocks/SM), +// O kept in register accumulator frags, output stored via the reused Ss tile. +// Replaces cuBLAS G1(CB)+ssd_mmask+G2(y_intra) with no cb/mmask HBM round-trip. +// Beats cuBLAS on GB10 (0.75 vs ~0.91 ms/layer @ S=2048). f16 CB round matches +// the chunked reference. Fixed NemotronH cell ds=128,dh=64; L=128. +extern "C" __global__ void ssd_intra_wmma( + const __half* __restrict__ c_f16, const __half* __restrict__ b_f16, + const float* __restrict__ x_mat, const float* __restrict__ lcs, + const float* __restrict__ dt, __half* __restrict__ y_intra, + unsigned T,unsigned H,unsigned G,unsigned hpg,unsigned L,unsigned ds,unsigned dh,unsigned nc,unsigned x_rs,unsigned x_off) +{ + const int BR=16,BC=16; int ds_nc=ds/16, dh_nc=dh/16; + int bh=blockIdx.x; int c=bh/H,h=bh%H,g=h/hpg; long cg=(long)c*G+g; + int warp=threadIdx.x>>5, lane=threadIdx.x&31, R0=warp*BR; int nwarp=L/BR; + extern __shared__ char smem[]; + __half* Xs=(__half*)smem; + float* Lc=(float*)(Xs + (size_t)L*dh); + float* Ssw=(float*)(Lc + L); + __half* Psw=(__half*)(Ssw + (size_t)nwarp*BR*BC); + float (*Ss)[BC]=(float(*)[BC])(Ssw + (size_t)warp*BR*BC); + __half (*Ps)[BC]=(__half(*)[BC])(Psw + (size_t)warp*BR*BC); + int tid=threadIdx.x,nth=blockDim.x; + for(int e=tid;e<(int)(L*dh);e+=nth){int p=e/(int)dh,pp=e%(int)dh;int t=c*(int)L+p;Xs[p*dh+pp]=(t<(int)T)?__float2half(x_mat[(long)t*x_rs+x_off+h*dh+pp]):__float2half(0.f);} + for(int e=tid;e<(int)L;e+=nth)Lc[e]=lcs[(long)bh*L+e]; + __syncthreads(); + wmma::fragment oacc[4]; + for(int t=0;t=(int)L)maxj=(int)L-1; + const __half* Cbase=&c_f16[(cg*L+R0)*ds]; + wmma::fragment s_acc; + for(int kj0=0;kj0<=maxj;kj0+=BC){ + wmma::fill_fragment(s_acc,0.f); + const __half* Bbase=&b_f16[(cg*L+kj0)*ds]; + for(int cc=0;cca; + wmma::fragmentb; + wmma::load_matrix_sync(a,Cbase+cc*16,ds); + wmma::load_matrix_sync(b,Bbase+cc*16,ds); + wmma::mma_sync(s_acc,a,b,s_acc); + } + wmma::store_matrix_sync(&Ss[0][0],s_acc,BC,wmma::mem_row_major); __syncwarp(); + if(lane=pj){float dtj=dt[(long)tj*H+h];float dcay=__expf(li-Lc[pj]);float cbv=__half2float(__float2half(Ss[i][j]));w=cbv*dcay*dtj;} + Ps[i][j]=__float2half(w);}} + __syncwarp(); + for(int dc=0;dcpa; + wmma::fragmentvb; + wmma::load_matrix_sync(pa,&Ps[0][0],BC); + wmma::load_matrix_sync(vb,&Xs[kj0*dh+dc*16],dh); + wmma::mma_sync(oacc[dc],pa,vb,oacc[dc]); + } + __syncwarp(); + } + for(int dc=0;dc +#include +using namespace nvcuda; +extern "C" __global__ void ssd_maskpv( + const __half* __restrict__ cb, const float* __restrict__ x_mat, + const float* __restrict__ lcs, const float* __restrict__ dt, __half* __restrict__ y_intra, + unsigned T,unsigned H,unsigned L,unsigned dh,unsigned nc,unsigned x_rs,unsigned x_off) +{ + const int BR=16,BC=16; int dh_nc=dh/16; + int bh=blockIdx.x; int c=bh/H,h=bh%H; + int warp=threadIdx.x>>5,lane=threadIdx.x&31,R0=warp*BR; int nwarp=L/BR; + extern __shared__ char smem[]; + __half* Xs=(__half*)smem; + float* Lc=(float*)(Xs+(size_t)L*dh); + __half* Psw=(__half*)(Lc+L); + float* Ssw=(float*)(Psw+(size_t)nwarp*BR*BC); + __half (*Ps)[BC]=(__half(*)[BC])(Psw+(size_t)warp*BR*BC); + float (*Ss)[BC]=(float(*)[BC])(Ssw+(size_t)warp*BR*BC); + int tid=threadIdx.x,nth=blockDim.x; + for(int e=tid;e<(int)(L*dh);e+=nth){int p=e/(int)dh,pp=e%(int)dh;int t=c*(int)L+p;Xs[p*dh+pp]=(t<(int)T)?__float2half(x_mat[(long)t*x_rs+x_off+h*dh+pp]):__float2half(0.f);} + for(int e=tid;e<(int)L;e+=nth)Lc[e]=lcs[(long)bh*L+e]; + __syncthreads(); + wmma::fragment oacc[4]; + for(int t=0;t=(int)L)maxj=(int)L-1; + for(int kj0=0;kj0<=maxj;kj0+=BC){ + if(lane=pj){float cbv=__half2float(cb[((long)bh*L+ri)*L+pj]);float dtj=dt[(long)tj*H+h];float dcay=__expf(li-Lc[pj]);w=cbv*dcay*dtj;} + Ps[i][j]=__float2half(w);}} + __syncwarp(); + for(int dc=0;dcpa; + wmma::fragmentvb; + wmma::load_matrix_sync(pa,&Ps[0][0],BC); + wmma::load_matrix_sync(vb,&Xs[kj0*dh+dc*16],dh); + wmma::mma_sync(oacc[dc],pa,vb,oacc[dc]); + } + __syncwarp(); + } + for(int dc=0;dc +#include +using namespace nvcuda; + +#define LOG2E 1.4426950408889634f +__device__ __forceinline__ float fast_exp(float x){ return expf(x); } + +#define DS 128 +#define DH 64 +#define L 128 + +extern "C" __global__ void __launch_bounds__(128,1) +ssd_inter_fused2( + const __half* __restrict__ c_f16, + const __half* __restrict__ b_f16, + const float* __restrict__ x_mat, + const float* __restrict__ lcs, + const float* __restrict__ dt, + const __half* __restrict__ y_intra, + const float* __restrict__ Dvec, + float* __restrict__ y_out, + unsigned int T, unsigned int H, unsigned int G, unsigned int nc) +{ + const int h = blockIdx.x; + const int hpg = H / G; + const int g = h / hpg; + const int tid = threadIdx.x; + const int warp = tid >> 5; // 0..3 (each warp owns 2 of the 8 row-tiles) + + extern __shared__ char smem[]; + // Layout (81KB, fits 99KB sm_120 optin): + // S_in [DS][DH] fp32 = 32KB (persistent recurrence state; row=d, col=p) + // big0 32KB = C tile [L][DS] half / B^T tile [DS][L] half; + // reused as CS [L][DH] fp32 (8192 floats) after the mma. + // Xtile 16KB = S_in-half mirror [DS][DH] / X tile [L][DH] half. + // sh_scale[L], sh_lcs[L] fp32. + float* S_in = (float*)smem; // 32KB + __half* big0 = (__half*)(S_in + DS*DH); // 32KB (16384 halfs) + float* CS = (float*)big0; // alias: CS fp32 [L][DH] = 8192 floats = 32KB + __half* Xtile = big0 + 16384; // 16KB (8192 halfs) + float* sh_scale = (float*)(Xtile + L*DH); // [L] + float* sh_lcs = sh_scale + L; // [L] + + // zero S_in + for(int e=tid; e acc[2][DH/16]; + for(int ri=0; ri<2; ri++){ int rt=warp+ri*4; + for(int ct=0; ct a; + wmma::fragment b; + wmma::load_matrix_sync(a, &big0[(rt*16)*DS + kt*16], DS); + wmma::load_matrix_sync(b, &Xtile[(kt*16)*DH + ct*16], DH); + wmma::mma_sync(acc[ri][ct],a,b,acc[ri][ct]); + } + } + } + __syncthreads(); // all big0 reads done; safe to overwrite big0 as CS fp32 + for(int ri=0; ri<2; ri++){ int rt=warp+ri*4; + for(int ct=0; ct acc[2][DH/16]; + for(int ri=0; ri<2; ri++){ int rt=warp+ri*4; + for(int ct=0; ct a; + wmma::fragment b; + wmma::load_matrix_sync(a,&big0[(rt*16)*L + kt*16], L); + wmma::load_matrix_sync(b,&Xtile[(kt*16)*DH + ct*16], DH); + wmma::mma_sync(acc[ri][ct],a,b,acc[ri][ct]); + } + } + } + __syncthreads(); // all big0 reads done + for(int ri=0; ri<2; ri++){ int rt=warp+ri*4; + for(int ct=0; ctf32) — fits the 99 KB +/// consumer-Blackwell smem cap at L=128 and keeps M/Mm off HBM. NO cp.async / +/// TMA — plain global->smem + EXPLICIT __syncthreads — so the sm_121 +/// async-barrier divergence cannot occur. FP32 accumulate. I/O matches the live +/// compact call site: X token-major row-strided x[t*x_rs+x_off+h*dh+p]; Bg/Cg +/// token-major bc[(t*G+grp)*ds+n]; dt token-major dt[t*H+h]; a_log[H] RAW +/// (A_h=-exp(a_log[h]) negated in-kernel); D[H] residual; Y token-major +/// y[(t*H+h)*dh+p] f32; state_out[H,dh,ds] state_out[(h*dh+d)*ds+n]. Internal +/// scratch (Yintra[H,S,dh], stateC[H,G,ds,dh], cumChunk[H,G], Sin[H,G,ds,dh]) +/// stays head-major/private. Fixed NemotronH H=64,dh=64,ds=128,G=8,L=128. +/// Gated by NEMOTRON_SSD_FUSED_TC at the call site. +const SSD_FUSED_TC_SRC: &str = r#" +#include +#include +using namespace nvcuda; +#define H_ 64 +#define DS 128 +#define DH 64 +#define GG 8 +#define L_ 128 + +extern "C" __global__ void ssd_fused_phaseA_tc( + const float* __restrict__ X, + const float* __restrict__ Bg, + const float* __restrict__ Cg, + const float* __restrict__ a_log, + const float* __restrict__ dt, + float* __restrict__ Yintra, + float* __restrict__ stateC, + float* __restrict__ cumChunk, + unsigned int S, unsigned int x_rs, unsigned int x_off) +{ + const int g = blockIdx.x, h = blockIdx.y; + const int G = (int)(S / L_) + ((S % L_) ? 1 : 0); + const int grp = h / (H_ / GG); + const int tid = threadIdx.x, nth = blockDim.x; + const int warp = tid >> 5, lane = tid & 31, nwarp = nth >> 5; + const float Ah = -__expf(a_log[h]); + const int chunk0 = g * L_; + extern __shared__ char smc[]; + float* s_cum = (float*)smc; + half* s_Xb = (half*)(s_cum + L_); + half* s_Bh = s_Xb + L_*DH; + half* s_Ch = s_Bh + L_*DS; + float* s_Msc = (float*)(s_Ch + L_*DS); + half* s_Mh = (half*)(s_Msc + nwarp*16*16); + if (tid == 0) { float acc = 0.f; + for (int i = 0; i < L_; ++i) { int t = chunk0 + i; + float dtv = (t < (int)S) ? dt[(size_t)t*H_ + h] : 0.f; acc += dtv; s_cum[i] = Ah*acc; } + cumChunk[(size_t)h*G + g] = Ah*acc; } + __syncthreads(); + for (int i = tid; i < L_*DS; i += nth) { int r = i / DS, c = i % DS; int t = chunk0 + r; + float bv = (t < (int)S) ? Bg[((size_t)t*GG + grp)*DS + c] : 0.f; + float cv = (t < (int)S) ? Cg[((size_t)t*GG + grp)*DS + c] : 0.f; + s_Bh[i] = __float2half(bv); s_Ch[i] = __float2half(cv); } + for (int i = tid; i < L_*DH; i += nth) { int r = i / DH, c = i % DH; int t = chunk0 + r; + float dtv = (t < (int)S) ? dt[(size_t)t*H_ + h] : 0.f; + float xv = (t < (int)S) ? X[(size_t)t*x_rs + x_off + (size_t)h*DH + c] : 0.f; + s_Xb[i] = __float2half(xv * dtv); } + __syncthreads(); + float* myMsc = s_Msc + warp*16*16; half* myMh = s_Mh + warp*16*16; + for (int mi = warp*16; mi < L_; mi += nwarp*16) { + wmma::fragment yacc[DH/16]; + #pragma unroll + for (int q = 0; q < DH/16; ++q) wmma::fill_fragment(yacc[q], 0.f); + for (int nj = 0; nj <= mi; nj += 16) { + wmma::fragment macc; wmma::fill_fragment(macc, 0.f); + for (int k = 0; k < DS; k += 16) { + wmma::fragment fa; + wmma::fragment fb; + wmma::load_matrix_sync(fa, s_Ch + mi*DS + k, DS); + wmma::load_matrix_sync(fb, s_Bh + nj*DS + k, DS); + wmma::mma_sync(macc, fa, fb, macc); + } + wmma::store_matrix_sync(myMsc, macc, 16, wmma::mem_row_major); + __syncwarp(); + for (int e = lane; e < 256; e += 32) { int r = e >> 4, c = e & 15; int gr = mi+r, gc = nj+c; + float v = 0.f; if (gc <= gr) v = myMsc[e] * __expf(s_cum[gr] - s_cum[gc]); myMh[e] = __float2half(v); } + __syncwarp(); + wmma::fragment fM; + wmma::load_matrix_sync(fM, myMh, 16); + #pragma unroll + for (int q = 0; q < DH/16; ++q) { + wmma::fragment fX; + wmma::load_matrix_sync(fX, s_Xb + nj*DH + q*16, DH); + wmma::mma_sync(yacc[q], fM, fX, yacc[q]); + } + } + #pragma unroll + for (int q = 0; q < DH/16; ++q) + wmma::store_matrix_sync(Yintra + ((size_t)h*S + chunk0 + mi)*DH + q*16, yacc[q], DH, wmma::mem_row_major); + } + __syncthreads(); + float cumlast = s_cum[L_-1]; + for (int i = tid; i < DS*DH; i += nth) { int n = i / DH, d = i % DH; float acc = 0.f; + for (int j = 0; j < L_; ++j) { float w = __expf(cumlast - s_cum[j]); + acc += w * __half2float(s_Bh[j*DS + n]) * __half2float(s_Xb[j*DH + d]); } + stateC[(((size_t)h*G + g)*DS + n)*DH + d] = acc; } +} + +extern "C" __global__ void ssd_fused_phaseB( + const float* __restrict__ stateC, + const float* __restrict__ cumChunk, + float* __restrict__ Sin, + float* __restrict__ state_out, + unsigned int S) +{ + int h = blockIdx.x; int G = (int)(S / L_) + ((S % L_) ? 1 : 0); + int tid = threadIdx.x, nth = blockDim.x; + extern __shared__ float run[]; + for (int i = tid; i < DS*DH; i += nth) run[i] = 0.f; __syncthreads(); + for (int g = 0; g < G; ++g) { + for (int i = tid; i < DS*DH; i += nth) Sin[(((size_t)h*G + g)*DS*DH) + i] = run[i]; + __syncthreads(); + float decay = __expf(cumChunk[(size_t)h*G + g]); + for (int i = tid; i < DS*DH; i += nth) run[i] = decay*run[i] + stateC[(((size_t)h*G + g)*DS*DH) + i]; + __syncthreads(); + } + for (int i = tid; i < DS*DH; i += nth) { int n = i / DH, d = i % DH; + state_out[((size_t)h*DH + d)*DS + n] = run[i]; } +} + +extern "C" __global__ void ssd_fused_phaseC( + const float* __restrict__ Cg, + const float* __restrict__ a_log, + const float* __restrict__ dt, + const float* __restrict__ X, + const float* __restrict__ D, + const float* __restrict__ Sin, + const float* __restrict__ Yintra, + float* __restrict__ Y, + unsigned int S, unsigned int x_rs, unsigned int x_off) +{ + int g = blockIdx.x, h = blockIdx.y; int G = (int)(S / L_) + ((S % L_) ? 1 : 0); + int grp = h / (H_ / GG); int tid = threadIdx.x, nth = blockDim.x; + const float Ah = -__expf(a_log[h]); const int chunk0 = g * L_; + extern __shared__ float s_cum[]; + if (tid == 0) { float acc = 0.f; + for (int i = 0; i < L_; ++i) { int t = chunk0 + i; + float dtv = (t < (int)S) ? dt[(size_t)t*H_ + h] : 0.f; acc += dtv; s_cum[i] = Ah*acc; } } + __syncthreads(); + const float* SinG = Sin + (((size_t)h*G + g)*DS*DH); + for (int idx = tid; idx < L_*DH; idx += nth) { int r = idx / DH, d = idx % DH; int t = chunk0 + r; + if (t >= (int)S) continue; + const float* Cr = &Cg[((size_t)t*GG + grp)*DS]; + float inc = 0.f; for (int n = 0; n < DS; ++n) inc += Cr[n] * SinG[(size_t)n*DH + d]; + float w = __expf(s_cum[r]); + float xv = X[(size_t)t*x_rs + x_off + (size_t)h*DH + d]; + float yi = Yintra[((size_t)h*S + t)*DH + d]; + Y[((size_t)t*H_ + h)*DH + d] = yi + w*inc + D[h]*xv; + } +} +"#; + +/// Mamba2 SSD **chunked-matmul prefill scan** for the NemotronH cell +/// (dh=64, ds=128, H=64, G=8). Runs the scan as cuBLAS tensor-core GEMMs +/// (state-space duality). Returns `(state_out, y)` — same signature/layout as +/// `ssm_prefill_scan`: `y` is `[T·H·dh]` f32, `state_out` matches `state_in`. +/// Inputs are f32 (cast internally to f16 for GEMMs). `t_total` need not be a +/// multiple of L — the tail chunk is zero-padded. `chunk_len` is typically 128 +/// or 256. Gated by `NEMOTRON_SSD_MATMUL` at the call site. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] +pub fn ssm_prefill_scan_ssd_strided( + dev: &dyn Device, + // ONE row-strided base holding [x | B | C] per token row (e.g. the conv + // output read in place — no mamba_split_conv materialization). For the + // compact 3-tensor form pass the same tensors with rs = their natural + // widths via `ssm_prefill_scan_ssd`. + x: &Tensor, + x_rs: u32, x_off: u32, + b_mat: &Tensor, + c_mat: &Tensor, + bc_rs: u32, b_off: u32, c_off: u32, + a_log: &Tensor, + d_skip: &Tensor, + dt: &Tensor, + state_in: &Tensor, + t_total: u32, + dh: u32, + ds: u32, + n_heads: u32, + n_groups: u32, + chunk_len: u32, + // Varlen packed-prefill: `[nc]` u32 buffer, 1 where a chunk starts a new + // packed sequence (resets the recurrent state). `None` = single sequence + // (dense path, bit-identical to before). + seg_reset: Option<&Tensor>, +) -> Result<(Tensor, Tensor)> { + if (dh, ds, n_heads, n_groups) != (64, 128, 64, 8) { + return Err(Error::Msg(format!( + "ssm_prefill_scan_ssd: only Nemotron cell (64,128,64,8) wired, got ({dh},{ds},{n_heads},{n_groups})" + ))); + } + if x.dtype != DType::F32 { + return Err(Error::Msg("ssm_prefill_scan_ssd: only F32 input wired (cast first)".into())); + } + let (t, h, dhu, dsu, g) = ( + t_total as usize, n_heads as usize, dh as usize, ds as usize, n_groups as usize, + ); + let hpg = h / g; // heads per group + let l = chunk_len as usize; + let nc = t.div_ceil(l); // number of chunks (tail zero-padded) + let bhc = nc * h; // batch count for the GEMMs + let bgc = nc * g; // per-GROUP batch count (fused path: 8× smaller B/C) + + // FUSED path (DEFAULT-ON; escape NEMOTRON_SSD_FUSED_OFF=1): materialize B/C + // only PER-GROUP [nc*G, L, ds] (8× smaller / 8× fewer writes than the + // broadcast gather), and fan the head→group slice into the G1/G4 GEMMs via a + // per-batch DEVICE POINTER ARRAY (cublasGemmBatchedEx) instead of the 8× + // redundant write. cuBLAS tensor cores preserved; argmax bit-identical to the + // strided path (same f16 GEMM inputs, just read from the shared group slice). + // (NEMOTRON_SSD_FUSED still force-enables it for explicit A/B.) + let fused = std::env::var("NEMOTRON_SSD_FUSED_OFF").is_err(); + // Fused tensor-core intra-chunk (NEMOTRON_SSD_INTRA_WMMA=1, requires fused + // per-group gather): ONE wmma kernel replaces G1(CB)+mmask+G2(y_intra), + // killing the cb+mmask [nc*H,L,L] HBM materializations. Fixed Nemotron cell. + let intra_wmma = fused && std::env::var("NEMOTRON_SSD_INTRA_WMMA").is_ok(); + let hybrid_pv = fused && !intra_wmma && std::env::var("NEMOTRON_SSD_HYBRID").is_ok(); + + // ── Output + final state (f32, matching the sequential scan). ───────── + let y = Tensor::empty(dev, vec![t * h * dhu], DType::F32)?; + let state_out = Tensor::empty(dev, state_in.shape.clone(), DType::F32)?; + + // ── Scratch buffers. ────────────────────────────────────────────────── + // b/c sized per-GROUP in the fused path (bgc), per-HEAD otherwise (bhc). + let bc_batch = if fused { bgc } else { bhc }; + let lcs = Tensor::empty(dev, vec![bhc * l], DType::F32)?; // [nc*H, L] + let b_f16 = Tensor::empty(dev, vec![bc_batch * l * dsu], DType::F16)?; // [nc*{G|H}, L, ds] + let c_f16 = Tensor::empty(dev, vec![bc_batch * l * dsu], DType::F16)?; // [nc*{G|H}, L, ds] + let xt = Tensor::empty(dev, vec![bhc * dhu * l], DType::F16)?; // [nc*H, dh, L] + let cb = Tensor::empty(dev, vec![bhc * l * l], DType::F16)?; // [nc*H, L, L] + let mmask = Tensor::empty(dev, vec![bhc * l * l], DType::F16)?; // [nc*H, L, L] + let y_intra = Tensor::empty(dev, vec![bhc * l * dhu], DType::F16)?; // [nc*H, L, dh] + let bdt = Tensor::empty(dev, vec![bhc * dsu * l], DType::F16)?; // [nc*H, ds, L] + let s_chunk = Tensor::empty(dev, vec![bhc * dsu * dhu], DType::F16)?;// [nc*H, ds, dh] + let sin_t = Tensor::empty(dev, vec![bhc * dhu * dsu], DType::F16)?; // [nc*H, dh, ds] + let cs = Tensor::empty(dev, vec![bhc * l * dhu], DType::F16)?; // [nc*H, L, dh] + + let raw = |src: &str, name: &str, fnn: &str, + ptrs: &[(&dyn ffai_core::DeviceBuffer, usize)], + scalars: &[u32], n_threads: usize| -> Result<()> { + let block = 256u32; + let grid = [((n_threads as u32) + block - 1) / block, 1, 1]; + let sc: Vec> = scalars.iter().map(|v| v.to_le_bytes().to_vec()).collect(); + dev.dispatch_raw_cuda(src, name, fnn, ptrs, &sc, grid, [block, 1, 1], 0, false) + }; + fn bb(t: &Tensor) -> &dyn ffai_core::DeviceBuffer { t.buffer.as_ref() } + let (tt, hh, gg, ll, dsd, dhd, ncn, hpgn) = ( + t_total, n_heads, n_groups, l as u32, ds, dh, nc as u32, hpg as u32, + ); + + // FULLY-FUSED tensor-core SSD (NEMOTRON_SSD_FUSED_TC=1, A/B-able, default + // OFF) — 3 launches replace the WHOLE trio. Fixed NemotronH cell at L=128. + // Reads X/B/C/dt in the live token-major compact layout; writes Y [T,H,dh] + // f32 + state_out [H,dh,ds]. No async copy (sm_121-safe). See SSD_FUSED_TC_SRC. + let fused_tc = std::env::var("NEMOTRON_SSD_FUSED_TC").is_ok(); + if fused_tc { + if l != 128 { + return Err(Error::Msg("NEMOTRON_SSD_FUSED_TC requires chunk_len=128 (set NEMOTRON_SSD_L=128)".into())); + } + let yintra = Tensor::empty(dev, vec![h * t * dhu], DType::F32)?; + let state_c = Tensor::empty(dev, vec![h * nc * dsu * dhu], DType::F32)?; + let cum_chunk = Tensor::empty(dev, vec![h * nc], DType::F32)?; + let sin_buf = Tensor::empty(dev, vec![h * nc * dsu * dhu], DType::F32)?; + let smem_a = (l*4 + l*dhu*2 + 2*l*dsu*2 + 8*16*16*4 + 8*16*16*2) as u32; + let smem_b = (dsu*dhu*4) as u32; + let smem_c = (l*4) as u32; + let sc_a: Vec> = [tt, x_rs, x_off].iter().map(|v| v.to_le_bytes().to_vec()).collect(); + dev.dispatch_raw_cuda( + SSD_FUSED_TC_SRC, "ssd_fused_tc.cu", "ssd_fused_phaseA_tc", + &[(bb(x), 0), (bb(b_mat), 0), (bb(c_mat), 0), (bb(a_log), 0), (bb(dt), 0), + (bb(&yintra), 0), (bb(&state_c), 0), (bb(&cum_chunk), 0)], + &sc_a, [ncn, hh, 1], [256, 1, 1], smem_a, false, + )?; + let sc_b: Vec> = [tt].iter().map(|v| v.to_le_bytes().to_vec()).collect(); + dev.dispatch_raw_cuda( + SSD_FUSED_TC_SRC, "ssd_fused_tc.cu", "ssd_fused_phaseB", + &[(bb(&state_c), 0), (bb(&cum_chunk), 0), (bb(&sin_buf), 0), (bb(&state_out), 0)], + &sc_b, [hh, 1, 1], [256, 1, 1], smem_b, false, + )?; + let sc_c: Vec> = [tt, x_rs, x_off].iter().map(|v| v.to_le_bytes().to_vec()).collect(); + dev.dispatch_raw_cuda( + SSD_FUSED_TC_SRC, "ssd_fused_tc.cu", "ssd_fused_phaseC", + &[(bb(c_mat), 0), (bb(a_log), 0), (bb(dt), 0), (bb(x), 0), (bb(d_skip), 0), + (bb(&sin_buf), 0), (bb(&yintra), 0), (bb(&y), 0)], + &sc_c, [ncn, hh, 1], [256, 1, 1], smem_c, false, + )?; + return Ok((state_out, y)); + } + + // GLUE-FUSE front: merge ssd_lcs + ssd_gather_bc_g + ssd_xt into ONE launch + // (bit-identical; 3 launches → 1, overlapped traffic streams). Requires the + // per-group fused gather path. Escape: leave NEMOTRON_SSD_GLUE_FUSE unset. + let glue_fuse = fused && std::env::var("NEMOTRON_SSD_GLUE_FUSE").is_ok(); + // Front-fusion (lcs+gather+xt → 1 launch) is a separate experiment flag — + // measured ~5% SLOWER (HBM-neutral; loses per-kernel grid tuning), kept off. + let front_fuse = fused && std::env::var("NEMOTRON_SSD_FRONT_FUSE").is_ok(); + if front_fuse { + // Grid sized to the biggest output (B/C gather: nc*G*L*ds); grid-stride + // loops inside the kernel cover the smaller lcs/xt element counts too. + let n_front = (bgc * l * dsu).max(bhc * dhu * l); + let sc: Vec> = [tt, hh, gg, ll, dsd, dhd, ncn, bc_rs, b_off, c_off, x_rs, x_off] + .iter().map(|v| v.to_le_bytes().to_vec()).collect(); + let block = 256u32; + let grid = [((n_front as u32) + block - 1) / block, 1, 1]; + dev.dispatch_raw_cuda( + SSD_FRONT_FUSED_SRC, "ssd_front_fused.cu", "ssd_front_fused", + &[(bb(dt), 0), (bb(a_log), 0), (bb(&lcs), 0), + (bb(b_mat), 0), (bb(c_mat), 0), (bb(&b_f16), 0), (bb(&c_f16), 0), + (bb(x), 0), (bb(&xt), 0)], + &sc, grid, [block, 1, 1], 0, false, + )?; + } else { + // 1. Lcs cumsum. + raw(SSD_LCS_SRC, "ssd_lcs.cu", "ssd_lcs", + &[(bb(dt), 0), (bb(a_log), 0), (bb(&lcs), 0)], + &[tt, hh, ll, ncn], bhc)?; + // 2. Gather B/C → f16. Fused: per-GROUP [nc*G,L,ds] (no 8× broadcast). + if fused { + raw(SSD_GATHER_BC_G_SRC, "ssd_gather_bc_g.cu", "ssd_gather_bc_g", + &[(bb(b_mat), 0), (bb(c_mat), 0), (bb(&b_f16), 0), (bb(&c_f16), 0)], + &[tt, gg, ll, dsd, ncn, bc_rs, b_off, c_off], bgc * l * dsu)?; + } else { + if (bc_rs, b_off, c_off) != ((g * dsu) as u32, 0, 0) { + return Err(Error::Msg("ssm_prefill_scan_ssd: strided B/C requires the fused gather (unset NEMOTRON_SSD_FUSED_OFF)".into())); + } + raw(SSD_GATHER_BC_SRC, "ssd_gather_bc.cu", "ssd_gather_bc", + &[(bb(b_mat), 0), (bb(c_mat), 0), (bb(&b_f16), 0), (bb(&c_f16), 0)], + &[tt, hh, gg, hpgn, ll, dsd, ncn], bhc * l * dsu)?; + } + // 3. Transpose x → xT [nc*H, dh, L] f16. + raw(SSD_XT_SRC, "ssd_xt.cu", "ssd_xt", + &[(bb(x), 0), (bb(&xt), 0)], + &[tt, hh, dhd, ll, ncn, x_rs, x_off], bhc * dhu * l)?; + } + + // Byte size of one [L,ds] f16 group slice — the broadcast stride for the + // device-pointer arrays (head h → group h/hpg slice c*G + h/hpg). + let el = 2i64; // f16 element size (bytes) + let st_lds = (l * dsu) as i64 * el; // stride of one [L,ds] matrix + let st_ll = (l * l) as i64 * el; + // head→group slice byte offset for batch bh = c*H + h. + let grp_off = |bh: usize| -> usize { + let c = bh / h; + let hh_ = bh % h; + (c * g + hh_ / hpg) * (l * dsu) * (el as usize) + }; + + let st_ldh = (l * dhu) as i64 * el; + let st_dhl = (dhu * l) as i64 * el; + if intra_wmma { + // ── FUSED: one wmma kernel does CB + mask + M·x → y_intra directly, + // reading the per-group c_f16/b_f16 slices + x in place (no cb/mmask). ── + // grid: one block per (chunk,head); block = L/16 warps (256 thr); ~28KB + // dynamic smem (x+lcs staged, per-warp Ss/Ps) → 3 blocks/SM. Beats cuBLAS. + let nwarp = (l / 16) as u32; + let smem_b = (l * dhu * 2 + l * 4 + (l/16) * 16 * 16 * 4 + (l/16) * 16 * 16 * 2) as u32; + let sc: Vec> = [tt, hh, gg, hpgn, ll, dsd, dhd, ncn, x_rs, x_off] + .iter().map(|v| v.to_le_bytes().to_vec()).collect(); + dev.dispatch_raw_cuda( + SSD_INTRA_WMMA_SRC, "ssd_intra_wmma.cu", "ssd_intra_wmma", + &[(bb(&c_f16), 0), (bb(&b_f16), 0), (bb(x), 0), (bb(&lcs), 0), (bb(dt), 0), (bb(&y_intra), 0)], + &sc, [bhc as u32, 1, 1], [nwarp * 32, 1, 1], smem_b, false, + )?; + } else { + // ── G1: CB = C · Bᵀ [L,L] = C[L,ds]·B[L,ds]ᵀ. ───────────────────────── + // primitive C[m,n]=X[m,k]·W[n,k]ᵀ → m=L, n=L, k=ds, X=c_f16, W=b_f16. + if fused { + // Device-ptr-array batched GEMM: X=C, W=B both point at the per-group + // slice (broadcast), out=cb per-head batch slot. m=L,n=L,k=ds. + let c_offs: Vec = (0..bhc).map(grp_off).collect(); + let b_offs: Vec = c_offs.clone(); + let cb_offs: Vec = (0..bhc).map(|bh| bh * (l * l) * (el as usize)).collect(); + dev.gemm_batched( + bb(&c_f16), &c_offs, bb(&b_f16), &b_offs, bb(&cb), &cb_offs, + l, l, dsu, DType::F16, + )?; + } else { + dev.gemm_strided_batched( + bb(&c_f16), st_lds, bb(&b_f16), st_lds, bb(&cb), st_ll, + l, l, dsu, bhc, DType::F16, + )?; + } + + if hybrid_pv { + // HYBRID: cb (cuBLAS, exact) → fused mask+PV (no mmask materialization, + // no G2). One block per (chunk,head), L/16 warps, ~28KB dynamic smem. + let nwarp = (l / 16) as u32; + let smem_b = (l * dhu * 2 + l * 4 + (l/16) * 16 * 16 * 2 + (l/16) * 16 * 16 * 4) as u32; + let sc2: Vec> = [tt, hh, ll, dhd, ncn, x_rs, x_off] + .iter().map(|v| v.to_le_bytes().to_vec()).collect(); + dev.dispatch_raw_cuda( + SSD_MASKPV_SRC, "ssd_maskpv.cu", "ssd_maskpv", + &[(bb(&cb), 0), (bb(x), 0), (bb(&lcs), 0), (bb(dt), 0), (bb(&y_intra), 0)], + &sc2, [bhc as u32, 1, 1], [nwarp * 32, 1, 1], smem_b, false, + )?; + } else { + // 4. M = CB ⊙ Lmask ⊙ dt[j], causal. + // GLUE-FUSE: write the mask IN-PLACE over `cb` (which is dead after this + // read) instead of a fresh `mmask` buffer — kills the separate 33MB mmask + // materialization and keeps the freshly-read `cb` tile L2-resident for the + // in-place write, cutting effective DRAM write+read of mmask. Bit-identical + // (same elementwise math; `m_out` aliases `cb`). The kernel reads cb[e] then + // writes the SAME index e, so the alias is hazard-free per element. + let mmask_buf: &Tensor = if glue_fuse { &cb } else { &mmask }; + if glue_fuse { + // in-place: mask cb directly (no separate mmask buffer / round-trip). + raw(SSD_MMASK_INPLACE_SRC, "ssd_mmask_inplace.cu", "ssd_mmask_inplace", + &[(bb(&cb), 0), (bb(&lcs), 0), (bb(dt), 0)], + &[tt, hh, ll, ncn], bhc * l * l)?; + } else { + raw(SSD_MMASK_SRC, "ssd_mmask.cu", "ssd_mmask", + &[(bb(&cb), 0), (bb(&lcs), 0), (bb(dt), 0), (bb(&mmask), 0)], + &[tt, hh, ll, ncn], bhc * l * l)?; + } + + // ── G2: y_intra = M · x [L,dh]. m=L, n=dh, k=L, X=mmask, W=xt[dh,L]. ─── + dev.gemm_strided_batched( + bb(mmask_buf), st_ll, bb(&xt), st_dhl, bb(&y_intra), st_ldh, + l, dhu, l, bhc, DType::F16, + )?; + } + } + + + // FUSED inter-chunk (NEMOTRON_SSD_INTER): ONE launch (one block/head, S_in + // resident in SRAM across all chunks) replaces bdt+G3+recur+G4+combine and + // the states/sin_t/CS HBM round-trips. Consumes y_intra; writes final y. + // Prefill-only (no state_out carry); requires the compact x layout (x_rs=H*dh). + let inter_fused = seg_reset.is_none() && std::env::var("NEMOTRON_SSD_INTER").is_ok(); + if inter_fused { + let sc3: Vec> = [tt, hh, gg, ncn].iter().map(|v| v.to_le_bytes().to_vec()).collect(); + dev.dispatch_raw_cuda( + SSD_INTER_FUSED2_SRC, "ssd_inter_fused2.cu", "ssd_inter_fused2", + &[(bb(&c_f16), 0), (bb(&b_f16), 0), (bb(x), 0), (bb(&lcs), 0), (bb(dt), 0), + (bb(&y_intra), 0), (bb(d_skip), 0), (bb(&y), 0)], + &sc3, [hh, 1, 1], [128, 1, 1], 82944, false, + )?; + return Ok((state_out, y)); + } + + // 5. BdT[s,j] = decay·dt·B [ds,L] f16. + raw(SSD_BDT_SRC, "ssd_bdt.cu", "ssd_bdt", + &[(bb(b_mat), 0), (bb(&lcs), 0), (bb(dt), 0), (bb(&bdt), 0)], + &[tt, hh, gg, hpgn, ll, dsd, ncn, bc_rs, b_off], bhc * dsu * l)?; + + // ── G3: S_chunk = BdT · xᵀ-form [ds,dh]. m=ds, n=dh, k=L, + // X=bdt[ds,L], W=xt[dh,L]. ───────────────────────────────────── + // NEMOTRON_SSD_RECUR_T=1: emit S_chunkᵀ [dh,ds] instead (operands + // swapped — same products, GEMM-internal accumulation order may differ) + // and run the fully-coalesced transposed recurrence. Quant-regime lever; + // default off to preserve the exact path's bit pattern. + let recur_t = seg_reset.is_none() && std::env::var("NEMOTRON_SSD_RECUR_T").is_ok(); + let st_dsl = (dsu * l) as i64 * el; + let st_dsdh = (dsu * dhu) as i64 * el; + if recur_t { + dev.gemm_strided_batched( + bb(&xt), st_dhl, bb(&bdt), st_dsl, bb(&s_chunk), st_dsdh, + dhu, dsu, l, bhc, DType::F16, + )?; + } else { + dev.gemm_strided_batched( + bb(&bdt), st_dsl, bb(&xt), st_dhl, bb(&s_chunk), st_dsdh, + dsu, dhu, l, bhc, DType::F16, + )?; + } + + // 6. Serial inter-chunk recurrence → SinT [nc*H, dh, ds] + state_out. + // Varlen: reset state at packed-sequence boundaries (seg_reset[c]). + if recur_t { + raw(SSD_RECUR_T_SRC, "ssd_recur_t.cu", "ssd_recur_t", + &[(bb(&s_chunk), 0), (bb(&lcs), 0), (bb(state_in), 0), (bb(&sin_t), 0), (bb(&state_out), 0)], + &[hh, dhd, dsd, ll, ncn], h * dsu * dhu)?; + } else { + match seg_reset { + Some(sr) => raw(SSD_RECUR_VARLEN_SRC, "ssd_recur_varlen.cu", "ssd_recur_varlen", + &[(bb(&s_chunk), 0), (bb(&lcs), 0), (bb(state_in), 0), (bb(sr), 0), (bb(&sin_t), 0), (bb(&state_out), 0)], + &[hh, dhd, dsd, ll, ncn], h * dsu * dhu)?, + None => raw(SSD_RECUR_SRC, "ssd_recur.cu", "ssd_recur", + &[(bb(&s_chunk), 0), (bb(&lcs), 0), (bb(state_in), 0), (bb(&sin_t), 0), (bb(&state_out), 0)], + &[hh, dhd, dsd, ll, ncn], h * dsu * dhu)?, + }; + } + + // ── G4: CS = C · S_in [L,dh]. m=L, n=dh, k=ds, X=c_f16, W=sinT[dh,ds]. ─ + let st_dhds = (dhu * dsu) as i64 * el; + if fused { + // X=C reads the per-group slice (broadcast via grp_off); W=sin_t and + // out=cs stay per-head batch slots. m=L, n=dh, k=ds. + let c_offs: Vec = (0..bhc).map(grp_off).collect(); + let sin_offs: Vec = (0..bhc).map(|bh| bh * (dhu * dsu) * (el as usize)).collect(); + let cs_offs: Vec = (0..bhc).map(|bh| bh * (l * dhu) * (el as usize)).collect(); + dev.gemm_batched( + bb(&c_f16), &c_offs, bb(&sin_t), &sin_offs, bb(&cs), &cs_offs, + l, dhu, dsu, DType::F16, + )?; + } else { + dev.gemm_strided_batched( + bb(&c_f16), st_lds, bb(&sin_t), st_dhds, bb(&cs), st_ldh, + l, dhu, dsu, bhc, DType::F16, + )?; + } + + // 7. Combine → y [T,H,dh] f32. + raw(SSD_COMBINE_SRC, "ssd_combine.cu", "ssd_combine", + &[(bb(&y_intra), 0), (bb(&cs), 0), (bb(&lcs), 0), (bb(x), 0), (bb(d_skip), 0), (bb(&y), 0)], + &[tt, hh, dhd, ll, ncn, x_rs, x_off], bhc * l * dhu)?; + + Ok((state_out, y)) +} + +/// Compact 3-tensor form of [`ssm_prefill_scan_ssd_strided`] — original +/// signature preserved for existing callers (x [T,H·dh], B/C [T,G·ds]). +#[allow(clippy::too_many_arguments)] +pub fn ssm_prefill_scan_ssd( + dev: &dyn Device, + x: &Tensor, + a_log: &Tensor, + b_mat: &Tensor, + c_mat: &Tensor, + d_skip: &Tensor, + dt: &Tensor, + state_in: &Tensor, + t_total: u32, + dh: u32, + ds: u32, + n_heads: u32, + n_groups: u32, + chunk_len: u32, + seg_reset: Option<&Tensor>, +) -> Result<(Tensor, Tensor)> { + ssm_prefill_scan_ssd_strided( + dev, + x, n_heads * dh, 0, + b_mat, c_mat, n_groups * ds, 0, 0, + a_log, d_skip, dt, state_in, + t_total, dh, ds, n_heads, n_groups, chunk_len, seg_reset, + ) +} diff --git a/rust/crates/ffai-ops/src/ssd_scan_portable.rs b/rust/crates/ffai-ops/src/ssd_scan_portable.rs new file mode 100644 index 00000000..7efd6f07 --- /dev/null +++ b/rust/crates/ffai-ops/src/ssd_scan_portable.rs @@ -0,0 +1,331 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # Mamba2 SSD chunked-MATMUL prefill scan — PORTABLE backend. +//! +//! Same state-space-duality chunked-matmul algorithm as the CUDA +//! `ssm_prefill_scan_ssd` (in `ssd_scan.rs`), but built ENTIRELY from portable +//! registry kernels that codegen to MSL / HIP / SPIR-V — no `dispatch_raw_cuda`, +//! no cuBLAS `cublasGemmStridedBatchedEx`. Runs on Apple hardware MMA (and HIP / +//! Vulkan / RDNA4) via the portable `ffai_gemm_batched` Reduction-mode GEMM. +//! +//! The 4 batched GEMMs (steps 1/2/4/5 below) run on `ffai_gemm_batched` +//! (batch = nc·H, the SAME `out = input · weightᵀ` contraction `ffai_gemm` +//! uses, replicated over the batch axis with per-matrix strides). The small +//! segsum / Lmask / decay / transpose / recurrence / combine kernels run on +//! the portable `ssd_*` `#[kernel]` ops in `metaltile-std::ffai::ssm`. +//! +//! Everything stays f32 (no f16 GEMM) — portability + correctness first; the +//! sequential scan also accumulates in f32, so this matches it tightly. +//! +//! Gated by `NEMOTRON_SSD_PORTABLE` at the call site (A/B-able vs the +//! sequential `ssm_prefill_scan`). The CUDA `ssm_prefill_scan_ssd` is left +//! byte-for-byte unchanged. +//! +//! Fixed for NemotronH: dh=64, ds=128, H=64, G=8 (hpg=8). L ∈ {128,256}. + +use ffai_core::{Binding, DType, Device, Error, Grid, Result, Tensor}; +use metaltile_core::ir::KernelMode; + +use crate::cached_ir; + +/// Portable Mamba2 SSD **chunked-matmul prefill scan** for the NemotronH cell +/// (dh=64, ds=128, H=64, G=8). Drop-in replacement for the sequential +/// `ssm_prefill_scan`: returns `(state_out, y)` with `y` `[T·H·dh]` f32 and +/// `state_out` matching `state_in`. Inputs are f32. `t_total` need not be a +/// multiple of `chunk_len` — the tail chunk is zero-padded. `chunk_len` is +/// typically 128 or 256. Backend-portable (runs on Metal/HIP/Vulkan). +#[allow(clippy::too_many_arguments)] +pub fn ssm_prefill_scan_ssd_portable( + dev: &dyn Device, + x: &Tensor, + a_log: &Tensor, + b_mat: &Tensor, + c_mat: &Tensor, + d_skip: &Tensor, + dt: &Tensor, + state_in: &Tensor, + t_total: u32, + dh: u32, + ds: u32, + n_heads: u32, + n_groups: u32, + chunk_len: u32, +) -> Result<(Tensor, Tensor)> { + if (dh, ds, n_heads, n_groups) != (64, 128, 64, 8) { + return Err(Error::Msg(format!( + "ssm_prefill_scan_ssd_portable: only Nemotron cell (64,128,64,8) wired, got ({dh},{ds},{n_heads},{n_groups})" + ))); + } + if x.dtype != DType::F32 { + return Err(Error::Msg( + "ssm_prefill_scan_ssd_portable: only F32 input wired (cast first)".into(), + )); + } + let (t, h, dhu, dsu, g) = ( + t_total as usize, + n_heads as usize, + dh as usize, + ds as usize, + n_groups as usize, + ); + let hpg = h / g; // heads per group + let l = chunk_len as usize; + let nc = t.div_ceil(l); // number of chunks (tail zero-padded) + let bhc = nc * h; // batch count for the GEMMs + + // ── Output + final state (f32, matching the sequential scan). ───────── + let y = Tensor::empty(dev, vec![t * h * dhu], DType::F32)?; + let state_out = Tensor::empty(dev, state_in.shape.clone(), DType::F32)?; + + // ── Scratch buffers (all f32). ──────────────────────────────────────── + // NOTE: b_g/c_g ([nc*H, L, ds]) and cb ([nc*H, L, L]) are GONE — the G1 + // (CB→M) and G4 (CS) GEMMs now read B/C straight from [T, G, ds] with the + // 8× per-head broadcast folded into the tile load (ssd_g1_cb / ssd_g4_cs), + // and G1 applies the decay-mask in its epilogue (ssd_mmask fused away). + // That kills the 8× redundant gather_bc write+read AND the [L,L] CB + // round-trip per chunk. + let lcs = Tensor::empty(dev, vec![bhc * l], DType::F32)?; // [nc*H, L] + let xt = Tensor::empty(dev, vec![bhc * dhu * l], DType::F32)?; // [nc*H, dh, L] + let mmask = Tensor::empty(dev, vec![bhc * l * l], DType::F32)?; // [nc*H, L, L] (M from G1) + let y_intra = Tensor::empty(dev, vec![bhc * l * dhu], DType::F32)?; // [nc*H, L, dh] + let bdt = Tensor::empty(dev, vec![bhc * dsu * l], DType::F32)?; // [nc*H, ds, L] + let s_chunk = Tensor::empty(dev, vec![bhc * dsu * dhu], DType::F32)?; // [nc*H, ds, dh] + let sin_t = Tensor::empty(dev, vec![bhc * dhu * dsu], DType::F32)?; // [nc*H, dh, ds] + let cs = Tensor::empty(dev, vec![bhc * l * dhu], DType::F32)?; // [nc*H, L, dh] + + let u = |v: u32| Binding::Scalar(v.to_le_bytes().to_vec()); + let (tt, hh, gg, ll, dsd, dhd, ncn, hpgn) = ( + t_total, + n_heads, + n_groups, + l as u32, + ds, + dh, + nc as u32, + hpg as u32, + ); + + // Grid3D elementwise dispatch helper: one thread per output element. + let elem = |name: &str, bufs: &[&Tensor], scalars: &[Binding], n_threads: usize| -> Result<()> { + let kern = cached_ir(name, DType::F32, || { + let mut k = build_ssd_ir(name); + k.mode = KernelMode::Grid3D; + k + }); + let mut bindings: Vec = + bufs.iter().map(|t| Binding::Buffer(t.buffer.clone())).collect(); + bindings.extend_from_slice(scalars); + // 1-D grid; cap block at 256 threads per group. + let block = 256u32; + let grid = Grid { + grid: [(n_threads as u32).div_ceil(block), 1, 1], + block: [block, 1, 1], + }; + dev.dispatch(&kern, &bindings, grid) + }; + + // Batched GEMM helper: out[bh][r,o] = Σ_k weight[bh][o,k]·input[bh][r,k]. + // Reduction-mode, grid [(out_dim/32),(n_rows/32), batch]. All operands f32. + let gemm_b = |weight: &Tensor, + input: &Tensor, + out: &Tensor, + m: usize, // n_rows + n: usize, // out_dim + k: usize, // in_dim + w_stride: usize, + x_stride: usize, + o_stride: usize| + -> Result<()> { + let kern = cached_ir("ffai_gemm_batched", DType::F32, || { + let mut k = metaltile_std::ffai::gemm::ffai_gemm_batched::kernel_ir_for(DType::F32); + k.mode = KernelMode::Reduction; + k + }); + let bindings = vec![ + Binding::Buffer(weight.buffer.clone()), + Binding::Buffer(input.buffer.clone()), + Binding::Buffer(out.buffer.clone()), + u(k as u32), // in_dim + u(n as u32), // out_dim + u(m as u32), // n_rows + u(w_stride as u32), + u(x_stride as u32), + u(o_stride as u32), + ]; + let grid = Grid { + grid: [ + (n as u32).div_ceil(32), + (m as u32).div_ceil(32), + bhc as u32, + ], + block: [1024, 1, 1], + }; + dev.dispatch(&kern, &bindings, grid) + }; + + // Fused gather-GEMM dispatch (ssd_g1_cb / ssd_g4_cs). Reduction-mode, + // grid [(out_dim/32),(n_rows/32), bhc]; bindings vary per kernel (broadcast + // index math is baked in-kernel), so the caller passes the binding list. + let gemm_fused = |name: &str, bindings: Vec, n: usize, m: usize| -> Result<()> { + let kern = cached_ir(name, DType::F32, || { + let mut k = build_ssd_ir(name); + k.mode = KernelMode::Reduction; + k + }); + let grid = Grid { + grid: [(n as u32).div_ceil(32), (m as u32).div_ceil(32), bhc as u32], + block: [1024, 1, 1], + }; + dev.dispatch(&kern, &bindings, grid) + }; + + // Optional per-phase profiler (env SSD_PHASE_PROF=1): synchronize + time + // each phase, bucketed into elementwise / intra-GEMM / inter-recur / + // state-GEMM. Zero cost when the env var is unset (default). + let phase_prof = std::env::var("SSD_PHASE_PROF").is_ok(); + let t_pre = std::cell::Cell::new(0.0f64); // prologue elementwise (lcs/gather/xt) + let t_lcs = std::cell::Cell::new(0.0f64); // sub: lcs cumsum + let t_gbc = std::cell::Cell::new(0.0f64); // sub: gather_bc + let t_xt = std::cell::Cell::new(0.0f64); // sub: xt transpose + let t_g1 = std::cell::Cell::new(0.0f64); // CB = C·Bᵀ (intra) + let t_mmask = std::cell::Cell::new(0.0f64); // M = CB⊙mask (elementwise) + let t_g2 = std::cell::Cell::new(0.0f64); // y_intra = M·x (intra) + let t_bdt = std::cell::Cell::new(0.0f64); // BdT (elementwise) + let t_g3 = std::cell::Cell::new(0.0f64); // S_chunk = BdT·x (state) + let t_recur = std::cell::Cell::new(0.0f64); // serial inter-chunk recurrence + let t_g4 = std::cell::Cell::new(0.0f64); // CS = C·S_in (state) + let t_comb = std::cell::Cell::new(0.0f64); // combine (elementwise) + macro_rules! phase { + ($cell:expr, $body:expr) => {{ + if phase_prof { + dev.synchronize()?; + let t0 = std::time::Instant::now(); + let r = $body; + dev.synchronize()?; + $cell.set($cell.get() + t0.elapsed().as_secs_f64() * 1e6); + r + } else { + $body + } + }}; + } + + // 1. Lcs cumsum: one thread per (chunk, head). + phase!(t_lcs, elem( + "ssd_lcs", + &[dt, a_log, &lcs], + &[u(tt), u(hh), u(ll), u(ncn)], + bhc, + )?); + // 2. (gather_bc REMOVED — fused into G1/G4 as broadcast tile-loads.) + // 3. Transpose x → xt [nc*H, dh, L]. + phase!(t_xt, elem( + "ssd_xt", + &[x, &xt], + &[u(tt), u(hh), u(dhd), u(ll), u(ncn)], + bhc * dhu * l, + )?); + + // ── G1+mmask FUSED: M = (C·Bᵀ) ⊙ decay-mask [L,L]. ────────────────── + // ssd_g1_cb reads B/C straight from [T,G,ds] (8× head broadcast folded + // into the tile load → no b_g/c_g), computes CB[i,j]=Σ_s C[t_i,g,s]· + // B[t_j,g,s], and applies the causal decay·dt mask in the epilogue, + // writing M directly (no CB scratch, no separate mmask round-trip). + phase!(t_g1, gemm_fused( + "ssd_g1_cb", + vec![ + Binding::Buffer(b_mat.buffer.clone()), + Binding::Buffer(c_mat.buffer.clone()), + Binding::Buffer(lcs.buffer.clone()), + Binding::Buffer(dt.buffer.clone()), + Binding::Buffer(mmask.buffer.clone()), + u(tt), u(hh), u(gg), u(hpgn), u(ll), u(dsd), + ], + l, // out_dim = L + l, // n_rows = L + )?); + + // ── G2: y_intra = M · x [L,dh]. out[i,p]=Σ_j mmask[i,j]·xt[p,j], + // weight=xt[dh,L], input=mmask[L,L], k=L. ───────────────────────── + phase!(t_g2, gemm_b(&xt, &mmask, &y_intra, l, dhu, l, dhu * l, l * l, l * dhu)?); + + // 5. BdT[s,j] = decay·dt·B [ds,L]. + phase!(t_bdt, elem( + "ssd_bdt", + &[b_mat, &lcs, dt, &bdt], + &[u(tt), u(hh), u(gg), u(hpgn), u(ll), u(dsd), u(ncn)], + bhc * dsu * l, + )?); + + // ── G3: S_chunk = BdT · xt-form [ds,dh]. out[s,p]=Σ_j bdt[s,j]·xt[p,j], + // weight=xt[dh,L], input=bdt[ds,L], k=L. ───────────────────────── + phase!(t_g3, gemm_b(&xt, &bdt, &s_chunk, dsu, dhu, l, dhu * l, dsu * l, dsu * dhu)?); + + // 6. Serial inter-chunk recurrence → SinT [nc*H, dh, ds] + state_out. + phase!(t_recur, elem( + "ssd_recur", + &[&s_chunk, &lcs, state_in, &sin_t, &state_out], + &[u(hh), u(dhd), u(dsd), u(ll), u(ncn)], + h * dsu * dhu, + )?); + + // ── G4 FUSED: CS = C · S_in [L,dh]. out[i,p]=Σ_s C[t_i,g,s]·sin_t[p,s]. + // ssd_g4_cs reads C straight from [T,G,ds] (broadcast tile-load → no c_g); + // sin_t is already per-batch [nc*H,dh,ds]. weight=sin_t, input=C, k=ds. + phase!(t_g4, gemm_fused( + "ssd_g4_cs", + vec![ + Binding::Buffer(sin_t.buffer.clone()), + Binding::Buffer(c_mat.buffer.clone()), + Binding::Buffer(cs.buffer.clone()), + u(tt), u(hh), u(gg), u(hpgn), u(ll), u(dsd), u(dhd), + ], + dhu, // out_dim = dh + l, // n_rows = L + )?); + + // 7. Combine → y [T,H,dh] f32. + phase!(t_comb, elem( + "ssd_combine", + &[&y_intra, &cs, &lcs, x, d_skip, &y], + &[u(tt), u(hh), u(dhd), u(ll), u(ncn)], + bhc * l * dhu, + )?); + + if phase_prof { + eprintln!(" [SSD pre-sub L={l}] lcs={:.1} gather_bc={:.1}(fused) xt={:.1}", t_lcs.get(), t_gbc.get(), t_xt.get()); + t_pre.set(t_lcs.get() + t_gbc.get() + t_xt.get()); + let intra = t_g1.get() + t_g2.get(); + let state = t_g3.get() + t_g4.get(); + let elemw = t_pre.get() + t_mmask.get() + t_bdt.get() + t_comb.get(); + let tot = intra + state + elemw + t_recur.get(); + eprintln!( + " [SSD phase L={l} nc={nc}] intra-GEMM(G1+G2)={:.1}us({:.0}%) state-GEMM(G3+G4)={:.1}us({:.0}%) elementwise={:.1}us({:.0}%) inter-recur={:.1}us({:.0}%) | G1(CB)={:.1} G2(y)={:.1} G3(Sc)={:.1} G4(CS)={:.1} pre={:.1} mmask={:.1} bdt={:.1} comb={:.1}", + intra, intra / tot * 100.0, state, state / tot * 100.0, + elemw, elemw / tot * 100.0, t_recur.get(), t_recur.get() / tot * 100.0, + t_g1.get(), t_g2.get(), t_g3.get(), t_g4.get(), + t_pre.get(), t_mmask.get(), t_bdt.get(), t_comb.get(), + ); + } + + Ok((state_out, y)) +} + +/// Resolve one of the `ssd_*` portable elementwise kernels by name. They are +/// f32-fixed (`kernel_ir_for()` with no dtype arg), Grid3D-dispatched. +fn build_ssd_ir(name: &str) -> ffai_core::Kernel { + use metaltile_std::ffai::ssm; + match name { + "ssd_lcs" => ssm::ssd_lcs::kernel_ir_for(), + "ssd_gather_bc" => ssm::ssd_gather_bc::kernel_ir_for(), + "ssd_xt" => ssm::ssd_xt::kernel_ir_for(), + "ssd_mmask" => ssm::ssd_mmask::kernel_ir_for(), + "ssd_bdt" => ssm::ssd_bdt::kernel_ir_for(), + "ssd_recur" => ssm::ssd_recur::kernel_ir_for(), + "ssd_combine" => ssm::ssd_combine::kernel_ir_for(), + "ssd_g1_cb" => ssm::ssd_g1_cb::kernel_ir_for(), + "ssd_g4_cs" => ssm::ssd_g4_cs::kernel_ir_for(), + other => panic!("build_ssd_ir: unknown ssd kernel {other}"), + } +} diff --git a/rust/crates/ffai-runtime/Cargo.toml b/rust/crates/ffai-runtime/Cargo.toml new file mode 100644 index 00000000..78a67288 --- /dev/null +++ b/rust/crates/ffai-runtime/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ffai-runtime" +description = "FFAI generation runtime: KV cache, sampler, decode loop. Backend-neutral." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] diff --git a/rust/crates/ffai-runtime/src/lib.rs b/rust/crates/ffai-runtime/src/lib.rs new file mode 100644 index 00000000..8b463e19 --- /dev/null +++ b/rust/crates/ffai-runtime/src/lib.rs @@ -0,0 +1,190 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # ffai-runtime +//! +//! Generation orchestration: logits selection, sampling, and the +//! prefill→decode loop. **Pure CPU logic, zero GPU/backend dependency** — the +//! model passes a `step(token, pos) -> logits` closure (which owns its KV cache +//! and runs on whatever [`ffai_core::Device`](../ffai_core) it likes), and this +//! drives it. So the generation runtime is written ONCE and shared across every +//! backend, every model, and (via FFI) the Swift host. + +/// Index of the max logit. +pub fn argmax(logits: &[f32]) -> usize { + (0..logits.len()).max_by(|&a, &b| logits[a].total_cmp(&logits[b])).unwrap_or(0) +} + +/// The `k` highest-logit indices, descending. +pub fn topk(logits: &[f32], k: usize) -> Vec { + let mut idx: Vec = (0..logits.len()).collect(); + idx.sort_unstable_by(|&a, &b| logits[b].total_cmp(&logits[a])); + idx.truncate(k); + idx +} + +/// How to turn logits into the next token. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Sampling { + /// argmax — deterministic. + Greedy, + /// softmax(logits / temperature), sample. + Temperature(f32), + /// temperature + keep only the top-`k` logits. + TopK(f32, usize), + /// temperature + nucleus: smallest set whose cumulative prob ≥ `p`. + TopP(f32, f32), +} + +/// Tiny dep-free PRNG (SplitMix64) so sampling is reproducible without pulling +/// in `rand`. `next_f32` yields a uniform value in `[0, 1)`. +pub struct Rng(u64); +impl Rng { + pub fn new(seed: u64) -> Self { + Rng(seed) + } + pub fn next_f32(&mut self) -> f32 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + ((z >> 40) as f32) / (1u32 << 24) as f32 + } +} + +/// Pick the next token id from `logits` under `s`. +pub fn sample(logits: &[f32], s: &Sampling, rng: &mut Rng) -> usize { + let temp = match *s { + Sampling::Greedy => return argmax(logits), + Sampling::Temperature(t) | Sampling::TopK(t, _) | Sampling::TopP(t, _) => t, + }; + if temp <= 0.0 { + return argmax(logits); + } + let mut cand: Vec<(usize, f32)> = logits.iter().copied().enumerate().collect(); + cand.sort_unstable_by(|a, b| b.1.total_cmp(&a.1)); + match *s { + Sampling::TopK(_, k) => cand.truncate(k.max(1)), + Sampling::TopP(_, p) => { + let mx = cand[0].1; + let exps: Vec = cand.iter().map(|&(_, l)| ((l - mx) / temp).exp()).collect(); + let sum: f32 = exps.iter().sum(); + let mut cum = 0.0; + let mut keep = 0; + for (i, &e) in exps.iter().enumerate() { + cum += e / sum; + keep = i + 1; + if cum >= p { + break; + } + } + cand.truncate(keep.max(1)); + } + _ => {} + } + let mx = cand[0].1; + let exps: Vec = cand.iter().map(|&(_, l)| ((l - mx) / temp).exp()).collect(); + let sum: f32 = exps.iter().sum(); + let r = rng.next_f32() * sum; + let mut cum = 0.0; + for (k, &e) in exps.iter().enumerate() { + cum += e; + if r < cum { + return cand[k].0; + } + } + cand[cand.len() - 1].0 +} + +/// When to stop decoding. +#[derive(Debug, Clone)] +pub struct StopOn { + pub max_new: usize, + pub eos: Option, +} + +/// Drive a model: prefill the prompt, then sample-and-step until `stop`. +/// +/// `step(token, pos) -> logits` runs one model step (the model owns its KV +/// cache + device). Returns the generated token ids (prompt not included). +/// Backend- and model-agnostic — the whole generation loop, shared. +pub fn generate( + prompt: &[u32], + stop: &StopOn, + sampling: &Sampling, + seed: u64, + mut step: impl FnMut(u32, usize) -> Vec, +) -> Vec { + assert!(!prompt.is_empty(), "generate: empty prompt"); + let mut logits = Vec::new(); + for (pos, &tok) in prompt.iter().enumerate() { + logits = step(tok, pos); + } + let mut rng = Rng::new(seed); + let mut out = Vec::with_capacity(stop.max_new); + let mut pos = prompt.len(); + for _ in 0..stop.max_new { + let next = sample(&logits, sampling, &mut rng) as u32; + out.push(next); + if Some(next) == stop.eos { + break; + } + logits = step(next, pos); + pos += 1; + } + out +} + +/// Legacy decoding params kept for the FFI/skeleton surface. +#[derive(Debug, Clone)] +pub struct SampleParams { + pub temperature: f32, + pub top_p: f32, + pub max_tokens: usize, +} +impl Default for SampleParams { + fn default() -> Self { + SampleParams { temperature: 0.7, top_p: 0.95, max_tokens: 256 } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn argmax_topk() { + let l = [0.1, 0.5, 0.2, 0.9, 0.3]; + assert_eq!(argmax(&l), 3); + assert_eq!(topk(&l, 3), vec![3, 1, 4]); + } + + #[test] + fn greedy_is_argmax() { + let l = [1.0, 3.0, 2.0]; + let mut rng = Rng::new(1); + assert_eq!(sample(&l, &Sampling::Greedy, &mut rng), 1); + } + + #[test] + fn generate_greedy_drives_a_stepper() { + // toy "model": logits peak at (pos+1) % vocab ⇒ greedy emits a ramp. + let vocab = 8; + let out = generate(&[0], &StopOn { max_new: 4, eos: None }, &Sampling::Greedy, 0, |_t, pos| { + let mut l = vec![0.0f32; vocab]; + l[(pos + 1) % vocab] = 1.0; + l + }); + assert_eq!(out, vec![1, 2, 3, 4]); + } + + #[test] + fn topp_collapses_to_dominant_logit() { + let l = [10.0, 0.0, 0.0, 0.0]; + let mut rng = Rng::new(42); + for _ in 0..20 { + assert_eq!(sample(&l, &Sampling::TopP(1.0, 0.9), &mut rng), 0); + } + } +} diff --git a/rust/crates/ffai/Cargo.toml b/rust/crates/ffai/Cargo.toml new file mode 100644 index 00000000..c2e36f97 --- /dev/null +++ b/rust/crates/ffai/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ffai" +description = "FFAI — F*cking Fast AI. Modular, multi-backend inference engine (Rust core). The Apple/iPhone production path is the Swift sibling under swift/." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +ffai-core.workspace = true +ffai-ops.workspace = true +ffai-models.workspace = true +ffai-runtime.workspace = true +ffai-loader.workspace = true +ffai-metal = { workspace = true, optional = true } +ffai-cuda = { workspace = true, optional = true } +ffai-vulkan = { workspace = true, optional = true } + +[features] +# Pick backends at build time. Default = metal for Mac dev boxes; build a +# CUDA host with `--no-default-features --features cuda`. +default = ["metal"] +metal = ["dep:ffai-metal"] +cuda = ["dep:ffai-cuda", "ffai-cuda/cuda"] +vulkan = ["dep:ffai-vulkan", "ffai-vulkan/vulkan"] +all-backends = ["metal", "cuda", "vulkan"] diff --git a/rust/crates/ffai/src/lib.rs b/rust/crates/ffai/src/lib.rs new file mode 100644 index 00000000..515a103c --- /dev/null +++ b/rust/crates/ffai/src/lib.rs @@ -0,0 +1,60 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 + +//! # FFAI — F*cking Fast AI +//! +//! Modular, multi-backend inference engine. One Rust core behind the +//! [`Device`] trait; backends (Metal, CUDA, Vulkan, ROCm) are independent +//! crates selected by cargo feature. Kernels are shared with the Swift +//! engine via the metaltile IR. +//! +//! ## Two engines, one product +//! +//! - **Swift FFAI** (`swift/`) — the primary Apple path. Native Metal, +//! ships to iPhone/iPad/Mac, fast. Maintained as a first-class engine. +//! - **Rust FFAI** (this) — the cross-platform path: CUDA / Vulkan / ROCm +//! (+ Metal for cross-validation). Same kernels, same model parity. +//! +//! Use [`devices`] to enumerate every backend compiled into this build. + +pub use ffai_core::{ + Backend, Binding, Device, DeviceBuffer, DType, Error, Grid, Kernel, Result, Tensor, +}; +pub use ffai_ops as ops; + +use std::sync::Arc; + +/// Enumerate every device from the backends compiled into this build. +/// Backends that are present-but-unimplemented (current skeleton) probe to +/// `None`, so this returns empty until a backend's `create()` goes live. +pub fn devices() -> Vec> { + let mut out: Vec> = Vec::new(); + + #[cfg(feature = "cuda")] + if let Ok(Some(d)) = ffai_cuda::CudaDevice::create() { + out.push(d); + } + #[cfg(feature = "metal")] + if let Ok(Some(d)) = ffai_metal::MetalDevice::create() { + out.push(d); + } + #[cfg(feature = "vulkan")] + if let Ok(Some(d)) = ffai_vulkan::VulkanDevice::create() { + out.push(d); + } + + out +} + +/// Names of the backends compiled into this build (regardless of whether a +/// matching device was found). +pub fn compiled_backends() -> &'static [&'static str] { + &[ + #[cfg(feature = "metal")] + "metal", + #[cfg(feature = "cuda")] + "cuda", + #[cfg(feature = "vulkan")] + "vulkan", + ] +} diff --git a/rust/nvfp4_reference/README.md b/rust/nvfp4_reference/README.md new file mode 100644 index 00000000..88529a4b --- /dev/null +++ b/rust/nvfp4_reference/README.md @@ -0,0 +1,36 @@ +# NVFP4 (mxf4nvf4) reference harnesses — GB10 / sm_121a + +Standalone CUDA probes that reverse-engineered + validated the native Blackwell +**NVFP4 block-scaled tensor-core mma** for the Nemotron-H prefill MoE GEMM +(closing the gap to vLLM's 6395 tok/s @S2048; the Q4 grouped path is ~10 TFLOP/s, +NVFP4 mma peaks ~386 TFLOP/s throttled on GB10). + +The instruction (validated computing bit-exact): +``` +mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 +``` +Build (accelerated-arch-only — base sm_121 PTX is rejected by ptxas): +``` +nvcc -gencode arch=compute_121a,code=sm_121a .cu -o +``` + +## Files (build order = discovery order) +| file | what it establishes | +|------|---------------------| +| `fp4_probe.cu` / `nvfp4_fix.cu` | the mxf4nvf4 mma **assembles** on sm_121a; the scale token is `ue4m3` (unsigned), not `e4m3` | +| `fp4_compute.cu` | mma **computes correctly** (all-ones → D=64); C/D accumulator layout (c0,c1→row gid,col tig*2+{0,1}; c2,c3→row gid+8); e2m1 1.0=0x2, ue4m3 1.0=0x38 | +| `fp4_derive.cu` / `fp4_deriveB.cu` | empirical A/B fragment **row-map** (one-hot sweep): A reg0,2→gid / reg1,3→gid+8; B reg0,1→gid | +| `fp4_validate.cu` | full random-tile **data layout** bit-exact vs f32 (scales=1) | +| `fp4_scale.cu` / `fp4_scaleB.cu` / `fp4_byteblk.cu` / `fp4_perm.cu` | derive the **scale operand** layout (scale_vec::4X, byte→block, lane mapping) | +| **`fp4_aligned.cu`** | **THE reference loader** — full layout + random ue4m3 scales bit-exact vs f32 (bad=0/128). Mirror this in the kernel. | +| `qnvfp4_test.cpp` | weight quant accuracy: NVFP4 ≈ Q4 rel-rmse (host) | +| `nvfp4_gemm.cu` | multi-K-tile accumulation + per-tensor global-scale epilogue | +| `nvfp4_peak.cu` | raw mma throughput microbench (386 TFLOP/s throttled @539MHz) | +| `nvfp4_tiled.cu` | tiled GEMM at real expert dims [96×1856×2688] = 58 TFLOP/s (~6× Q4) | +| **`nvfp4_packed.cu`** | **in-kernel gather** from the `quantize_nvfp4` packed `[N, codes(K/8 u32)+scales(K/16 u8)]` layout — the core of the production kernel | +| `nvfp4_actq.cu` | device activation quantizer: f16 `[rows,K]` → NVFP4 codes+scales+global | + +These are dev/reference, NOT part of the cargo build. The production path is +`quantize_nvfp4` (ffai-ops, committed) + the forthcoming `MOE_FP4_GROUPED_MMA_SRC`. +Full state + the exact fragment/scale layout recipe: memory note +`nemotron-grouped-moe-gemm.md` (2026-06-08 sections). diff --git a/rust/nvfp4_reference/fp4_aligned.cu b/rust/nvfp4_reference/fp4_aligned.cu new file mode 100644 index 00000000..3ab5b752 --- /dev/null +++ b/rust/nvfp4_reference/fp4_aligned.cu @@ -0,0 +1,39 @@ +#include +#include +__device__ __host__ float dec4(int nib){int s=(nib>>3)&1,e=(nib>>1)&3,m=nib&1;float v;if(e==0)v=0.5f*m;else v=(1.0f+0.5f*m)*(1<<(e-1));return s?-v:v;} +__device__ __host__ float decS(unsigned char b){int e=(b>>3)&0xF,m=b&7;if(e==0)return m/8.0f*0.0078125f;return (1.0f+m/8.0f)*exp2f((float)(e-7));} +__constant__ unsigned char Ac[16*64],Bc[8*64],SAc[16*4],SBc[8*4]; +__device__ int byteForBlock(int blk){int t[4]={0,2,1,3};return t[blk];} +__global__ void kr(float* D){ + int lane=threadIdx.x,gid=lane>>2,tig=lane&3; + unsigned a[4]={0,0,0,0},b[2]={0,0},sA=0x38383838,sB=0x38383838; + // ALIGNED load: for each weight k, compute its fragment slot from (block,w) + // B row = gid (this lane). place B[gid][K] + for(int K=0;K<64;K++){int blk=K>>4,w=K&15; int ttig=(blk>=2?2:0)+(w>=8?1:0); if(ttig!=tig)continue; int reg=blk&1; int nib=w&7; b[reg]|=(unsigned)Bc[gid*64+K]<<(nib*4);} + // A rows gid (regs0/2) and gid+8 (regs1/3) + for(int half=0;half<2;half++){int row=gid+half*8; + for(int K=0;K<64;K++){int blk=K>>4,w=K&15;int ttig=(blk>=2?2:0)+(w>=8?1:0);if(ttig!=tig)continue;int nib=w&7; + int reg = half==0 ? (blk&1?2:0) : (blk&1?3:1); + a[reg]|=(unsigned)Ac[row*64+K]<<(nib*4);}} + // scales: A scale lane tig0->row gid, tig1->row gid+8; byte=byteForBlock(blk) + if(tig==0||tig==1){int row=gid+(tig==1?8:0);sA=0;for(int blk=0;blk<4;blk++)sA|=(unsigned)SAc[row*4+blk]<<(byteForBlock(blk)*8);} + if(tig==0){sB=0;for(int blk=0;blk<4;blk++)sB|=(unsigned)SBc[gid*4+blk]<<(byteForBlock(blk)*8);} + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a[0]),"r"(a[1]),"r"(a[2]),"r"(a[3]),"r"(b[0]),"r"(b[1]),"r"(sA),"r"(sB)); + D[gid*8+tig*2]=c0;D[gid*8+tig*2+1]=c1;D[(gid+8)*8+tig*2]=c2;D[(gid+8)*8+tig*2+1]=c3; +} +int main(){ + unsigned char A[16*64],B[8*64],SA[16*4],SB[8*4];srand(11); + for(int i=0;i<16*64;i++)A[i]=rand()&15;for(int i=0;i<8*64;i++)B[i]=rand()&15; + for(int i=0;i<16*4;i++)SA[i]=((6+(rand()%3))<<3)|(rand()&7);for(int i=0;i<8*4;i++)SB[i]=((6+(rand()%3))<<3)|(rand()&7); + cudaMemcpyToSymbol(Ac,A,sizeof A);cudaMemcpyToSymbol(Bc,B,sizeof B);cudaMemcpyToSymbol(SAc,SA,sizeof SA);cudaMemcpyToSymbol(SBc,SB,sizeof SB); + float* d;cudaMalloc(&d,128*4);cudaMemset(d,0,128*4);kr<<<1,32>>>(d); + if(cudaDeviceSynchronize()){printf("err\n");return 1;} + float h[128];cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + int bad=0;float mre=0; + for(int m=0;m<16;m++)for(int n=0;n<8;n++){float ref=0;for(int blk=0;blk<4;blk++){float s=0;for(int kk=0;kk<16;kk++){int K=blk*16+kk;s+=dec4(A[m*64+K])*dec4(B[n*64+K]);}ref+=s*decS(SA[m*4+blk])*decS(SB[n*4+blk]);}float re=ref!=0?(ref-h[m*8+n])/ref:ref-h[m*8+n];if(re<0)re=-re;if(re>mre)mre=re;if(re>1e-2f){bad++;if(bad<=3)printf("MISS[%d][%d] ref=%.3f got=%.3f\n",m,n,ref,h[m*8+n]);}} + printf("bad=%d/128 mre=%.5f %s\n",bad,mre,bad?"WRONG":"<<<< NVFP4 mma FULLY SOLVED (data+scale)"); + return 0; +} diff --git a/rust/nvfp4_reference/fp4_byteblk.cu b/rust/nvfp4_reference/fp4_byteblk.cu new file mode 100644 index 00000000..92a51f51 --- /dev/null +++ b/rust/nvfp4_reference/fp4_byteblk.cu @@ -0,0 +1,25 @@ +#include +// A=1.0 everywhere; B=1.0 ONLY in data k-block bd (k in [bd*16,bd*16+16)), else 0. +// sB=1; sA: lane(gid0,tig0) byte TB=2.0, rest 1.0. D[0][0]=16*sA[block of bd]. If byte TB scales block bd -> 32 else 16. +__global__ void k(float* D,int bd,int TB){ + int lane=threadIdx.x,gid=lane>>2,tig=lane&3; + unsigned a0=0x22222222,a1=a0,a2=a0,a3=a0; // A all 1.0 + // B = 1.0 only where data k-block==bd. lane tig holds k=tig*16..+15 = block tig. regs b0(k tig*16+0..7),b1(+8..15) + unsigned b0=(tig==bd)?0x22222222:0, b1=(tig==bd)?0x22222222:0; + unsigned sA=0x38383838,sB=0x38383838; + if(tig==0 && gid==0) sA=(sA&~(0xFFu<<(TB*8)))|(0x40u<<(TB*8)); + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a0),"r"(a1),"r"(a2),"r"(a3),"r"(b0),"r"(b1),"r"(sA),"r"(sB)); + D[gid*8+tig*2]=c0;D[gid*8+tig*2+1]=c1;D[(gid+8)*8+tig*2]=c2;D[(gid+8)*8+tig*2+1]=c3; +} +int main(){ + float* d;cudaMalloc(&d,128*4);float h[128]; + printf(" bd0 bd1 bd2 bd3 (D[0][0]; 32=byte scales that block)\n"); + for(int TB=0;TB<4;TB++){ printf("byte%d ",TB); + for(int bd=0;bd<4;bd++){cudaMemset(d,0,128*4);k<<<1,32>>>(d,bd,TB);cudaDeviceSynchronize();cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost);printf("%5.0f ",h[0]);} + printf("\n"); + } + return 0; +} diff --git a/rust/nvfp4_reference/fp4_compute.cu b/rust/nvfp4_reference/fp4_compute.cu new file mode 100644 index 00000000..ae530a11 --- /dev/null +++ b/rust/nvfp4_reference/fp4_compute.cu @@ -0,0 +1,33 @@ +#include +// Validate the NVFP4 mma COMPUTES (not just assembles) on GB10/sm_121a. +// A[16x64]=B[8x64]=e2m1(1.0)=0x2 nibble → 0x22222222/reg; scales=ue4m3(1.0)=0x38 → 0x38383838. +// D[i][j] = sum_{k0..63} 1*1 * sA*sB = 64. Layout-independent (all elems equal). +__global__ void k(float* D){ + unsigned a0=0x22222222,a1=0x22222222,a2=0x22222222,a3=0x22222222; + unsigned b0=0x22222222,b1=0x22222222; + unsigned sA=0x38383838, sB=0x38383838; + float c0=0,c1=0,c2=0,c3=0; + asm volatile( + "mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3}, {%4,%5,%6,%7}, {%8,%9}, {%0,%1,%2,%3}, {%10}, {0,0}, {%11}, {0,0};" + : "+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3) + : "r"(a0),"r"(a1),"r"(a2),"r"(a3),"r"(b0),"r"(b1),"r"(sA),"r"(sB)); + int lane=threadIdx.x; + // C/D layout: c0,c1 -> row=gid, col=tig*2+{0,1}; c2,c3 -> row=gid+8 + int gid=lane>>2, tig=lane&3; + D[(gid)*8 + tig*2+0]=c0; D[(gid)*8 + tig*2+1]=c1; + D[(gid+8)*8 + tig*2+0]=c2; D[(gid+8)*8 + tig*2+1]=c3; +} +int main(){ + float* d; cudaMalloc(&d,16*8*4); cudaMemset(d,0,16*8*4); + k<<<1,32>>>(d); + cudaError_t e=cudaDeviceSynchronize(); + if(e){printf("LAUNCH ERR: %s\n",cudaGetErrorString(e));return 1;} + float h[128]; cudaMemcpy(h,d,16*8*4,cudaMemcpyDeviceToHost); + int ok=0,bad=0; float mn=1e9,mx=-1e9; + for(int i=0;i<128;i++){ if(h[i]==64.0f)ok++; else bad++; if(h[i]mx)mx=h[i]; } + printf("D[0..7]: %.1f %.1f %.1f %.1f %.1f %.1f %.1f %.1f\n",h[0],h[1],h[2],h[3],h[4],h[5],h[6],h[7]); + printf("==64: %d/128 range[%.3f,%.3f]\n",ok,mn,mx); + printf(ok==128?"COMPUTE OK (all 64)\n":"MISMATCH (expected all 64)\n"); + return 0; +} diff --git a/rust/nvfp4_reference/fp4_derive.cu b/rust/nvfp4_reference/fp4_derive.cu new file mode 100644 index 00000000..04cd8438 --- /dev/null +++ b/rust/nvfp4_reference/fp4_derive.cu @@ -0,0 +1,26 @@ +#include +// Derive A fragment row-map: one-hot A at (target_lane tl, target_reg tr, nibble tn)=e2m1 1.0, +// B=all-ones, scales=1 → D[m][n]=sum_k A[m][k] = 1.0 only on A's row. Read which D row lights. +__global__ void k(float* D,int tl,int tr,int tn){ + unsigned a[4]={0,0,0,0}, b0=0x22222222,b1=0x22222222, sA=0x38383838,sB=0x38383838; + if((int)threadIdx.x==tl) a[tr] |= (0x2u<<(tn*4)); + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a[0]),"r"(a[1]),"r"(a[2]),"r"(a[3]),"r"(b0),"r"(b1),"r"(sA),"r"(sB)); + int g=threadIdx.x>>2, t=threadIdx.x&3; + D[g*8+t*2]=c0; D[g*8+t*2+1]=c1; D[(g+8)*8+t*2]=c2; D[(g+8)*8+t*2+1]=c3; +} +int main(){ + float* d; cudaMalloc(&d,128*4); float h[128]; + printf("lane reg nib -> rows_with_nonzero_D (and value)\n"); + for(int tl=0;tl<32;tl++) for(int tr=0;tr<4;tr++){ int tn=0; + cudaMemset(d,0,128*4); k<<<1,32>>>(d,tl,tr,tn); cudaDeviceSynchronize(); + cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + // find rows that are nonzero + char buf[128]=""; float val=0; + for(int r=0;r<16;r++){ int nz=0; for(int c=0;c<8;c++) if(h[r*8+c]!=0){nz++; val=h[r*8+c];} if(nz){ char t[16]; sprintf(t,"r%d(%.1f) ",r,val); strcat(buf,t);} } + if(tl<8 || tl==31) printf("L%-2d reg%d -> %s\n",tl,tr,buf[0]?buf:"(none)"); + } + return 0; +} diff --git a/rust/nvfp4_reference/fp4_deriveB.cu b/rust/nvfp4_reference/fp4_deriveB.cu new file mode 100644 index 00000000..a46fd588 --- /dev/null +++ b/rust/nvfp4_reference/fp4_deriveB.cu @@ -0,0 +1,25 @@ +#include +#include +// Derive B fragment row-map: one-hot B at (tl,tr,tn)=e2m1 1.0, A=all-ones, scales=1. +// D[m][n]=sum_k A[m][k]*B[n][k]=sum_k B[n][k] = 1.0 only at B's row n (all m). Read which D col lights. +__global__ void k(float* D,int tl,int tr,int tn){ + unsigned a0=0x22222222,a1=0x22222222,a2=0x22222222,a3=0x22222222, b[2]={0,0}, sA=0x38383838,sB=0x38383838; + if((int)threadIdx.x==tl) b[tr] |= (0x2u<<(tn*4)); + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a0),"r"(a1),"r"(a2),"r"(a3),"r"(b[0]),"r"(b[1]),"r"(sA),"r"(sB)); + int g=threadIdx.x>>2,t=threadIdx.x&3; + D[g*8+t*2]=c0; D[g*8+t*2+1]=c1; D[(g+8)*8+t*2]=c2; D[(g+8)*8+t*2+1]=c3; +} +int main(){ + float* d; cudaMalloc(&d,128*4); float h[128]; + printf("B: lane reg -> cols_with_nonzero_D\n"); + for(int tl=0;tl<32;tl++) for(int tr=0;tr<2;tr++){ + cudaMemset(d,0,128*4); k<<<1,32>>>(d,tl,tr,0); cudaDeviceSynchronize(); + cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + char buf[64]=""; for(int c=0;c<8;c++){int nz=0; for(int r=0;r<16;r++) if(h[r*8+c]!=0)nz++; if(nz){char t[8];sprintf(t,"c%d ",c);strcat(buf,t);}} + if(tl<8||tl==31) printf("L%-2d reg%d -> %s\n",tl,tr,buf[0]?buf:"(none)"); + } + return 0; +} diff --git a/rust/nvfp4_reference/fp4_full.cu b/rust/nvfp4_reference/fp4_full.cu new file mode 100644 index 00000000..2f671bd9 --- /dev/null +++ b/rust/nvfp4_reference/fp4_full.cu @@ -0,0 +1,45 @@ +#include +#include +__device__ __host__ float dec4(int nib){int s=(nib>>3)&1,e=(nib>>1)&3,m=nib&1;float v;if(e==0)v=0.5f*m;else v=(1.0f+0.5f*m)*(1<<(e-1));return s?-v:v;} +// ue4m3 decode (unsigned e4m3: 4 exp bias7, 3 mant) +__device__ __host__ float decS(unsigned char b){int e=(b>>3)&0xF,m=b&7;if(e==0)return m/8.0f*0.0078125f;return (1.0f+m/8.0f)*exp2f((float)(e-7));} +__constant__ unsigned char Ac[16*64],Bc[8*64],SAc[16*4],SBc[8*4]; +__global__ void k(float* D){ + int lane=threadIdx.x,gid=lane>>2,tig=lane&3; + unsigned a[4]={0,0,0,0},b[2]={0,0}; + for(int j=0;j<8;j++){ + a[0]|=(unsigned)Ac[gid*64+tig*16+0+j]<<(j*4); a[2]|=(unsigned)Ac[gid*64+tig*16+8+j]<<(j*4); + a[1]|=(unsigned)Ac[(gid+8)*64+tig*16+0+j]<<(j*4); a[3]|=(unsigned)Ac[(gid+8)*64+tig*16+8+j]<<(j*4); + b[0]|=(unsigned)Bc[gid*64+tig*16+0+j]<<(j*4); b[1]|=(unsigned)Bc[gid*64+tig*16+8+j]<<(j*4); + } + // scales: tig0 -> row gid (A) / row gid (B); tig1 -> row gid+8 (A); else 1.0(0x38) + unsigned sA=0x38383838,sB=0x38383838; + if(tig==0){ sA=0; for(int x=0;x<4;x++) sA|=(unsigned)SAc[gid*4+x]<<(x*8); } + else if(tig==1){ sA=0; for(int x=0;x<4;x++) sA|=(unsigned)SAc[(gid+8)*4+x]<<(x*8); } + if(tig==0){ sB=0; for(int x=0;x<4;x++) sB|=(unsigned)SBc[gid*4+x]<<(x*8); } + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a[0]),"r"(a[1]),"r"(a[2]),"r"(a[3]),"r"(b[0]),"r"(b[1]),"r"(sA),"r"(sB)); + D[gid*8+tig*2]=c0;D[gid*8+tig*2+1]=c1;D[(gid+8)*8+tig*2]=c2;D[(gid+8)*8+tig*2+1]=c3; +} +int main(){ + unsigned char A[16*64],B[8*64],SA[16*4],SB[8*4];srand(11); + for(int i=0;i<16*64;i++)A[i]=rand()&15; for(int i=0;i<8*64;i++)B[i]=rand()&15; + // scales: exp in [6,8], mant random -> decode 0.5..~3.75 + for(int i=0;i<16*4;i++)SA[i]=((6+(rand()%3))<<3)|(rand()&7); + for(int i=0;i<8*4;i++)SB[i]=((6+(rand()%3))<<3)|(rand()&7); + cudaMemcpyToSymbol(Ac,A,sizeof A);cudaMemcpyToSymbol(Bc,B,sizeof B); + cudaMemcpyToSymbol(SAc,SA,sizeof SA);cudaMemcpyToSymbol(SBc,SB,sizeof SB); + float* d;cudaMalloc(&d,128*4);cudaMemset(d,0,128*4);k<<<1,32>>>(d); + if(cudaDeviceSynchronize()){printf("launch err\n");return 1;} + float h[128];cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + int bad=0;float maxre=0; + for(int m=0;m<16;m++)for(int n=0;n<8;n++){ + float ref=0; for(int blk=0;blk<4;blk++){float sub=0;for(int kk=0;kk<16;kk++){int K=blk*16+kk;sub+=dec4(A[m*64+K])*dec4(B[n*64+K]);} ref+=sub*decS(SA[m*4+blk])*decS(SB[n*4+blk]);} + float got=h[m*8+n];float re=ref!=0?(ref-got)/ref:(ref-got);if(re<0)re=-re;if(re>maxre)maxre=re; + if(re>1e-2f){bad++;if(bad<=4)printf("MISS D[%d][%d] ref=%.4f got=%.4f\n",m,n,ref,got);} + } + printf("bad=%d/128 max_rel_err=%.5f %s\n",bad,maxre,bad?"SCALE/LAYOUT WRONG":"FULL LAYOUT+SCALE CONFIRMED"); + return 0; +} diff --git a/rust/nvfp4_reference/fp4_perm.cu b/rust/nvfp4_reference/fp4_perm.cu new file mode 100644 index 00000000..782f43e5 --- /dev/null +++ b/rust/nvfp4_reference/fp4_perm.cu @@ -0,0 +1,42 @@ +#include +#include +__device__ __host__ float dec4(int nib){int s=(nib>>3)&1,e=(nib>>1)&3,m=nib&1;float v;if(e==0)v=0.5f*m;else v=(1.0f+0.5f*m)*(1<<(e-1));return s?-v:v;} +__device__ __host__ float decS(unsigned char b){int e=(b>>3)&0xF,m=b&7;if(e==0)return m/8.0f*0.0078125f;return (1.0f+m/8.0f)*exp2f((float)(e-7));} +__constant__ unsigned char Ac[16*64],Bc[8*64],SAc[16*4],SBc[8*4]; +__constant__ int PERM[4]; +__global__ void k(float* D){ + int lane=threadIdx.x,gid=lane>>2,tig=lane&3; + unsigned a[4]={0,0,0,0},b[2]={0,0}; + for(int j=0;j<8;j++){ + a[0]|=(unsigned)Ac[gid*64+tig*16+j]<<(j*4); a[2]|=(unsigned)Ac[gid*64+tig*16+8+j]<<(j*4); + a[1]|=(unsigned)Ac[(gid+8)*64+tig*16+j]<<(j*4); a[3]|=(unsigned)Ac[(gid+8)*64+tig*16+8+j]<<(j*4); + b[0]|=(unsigned)Bc[gid*64+tig*16+j]<<(j*4); b[1]|=(unsigned)Bc[gid*64+tig*16+8+j]<<(j*4); + } + // pack scale: byte PERM[blk] gets block blk's scale + unsigned sA=0x38383838,sB=0x38383838; int row=-1; + if(tig==0)row=gid; else if(tig==1)row=gid+8; + if(row>=0){sA=0;for(int blk=0;blk<4;blk++)sA|=(unsigned)SAc[row*4+blk]<<(PERM[blk]*8);} + if(tig==0){sB=0;for(int blk=0;blk<4;blk++)sB|=(unsigned)SBc[gid*4+blk]<<(PERM[blk]*8);} + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a[0]),"r"(a[1]),"r"(a[2]),"r"(a[3]),"r"(b[0]),"r"(b[1]),"r"(sA),"r"(sB)); + D[gid*8+tig*2]=c0;D[gid*8+tig*2+1]=c1;D[(gid+8)*8+tig*2]=c2;D[(gid+8)*8+tig*2+1]=c3; +} +int main(){ + unsigned char A[16*64],B[8*64],SA[16*4],SB[8*4];srand(11); + for(int i=0;i<16*64;i++)A[i]=rand()&15;for(int i=0;i<8*64;i++)B[i]=rand()&15; + for(int i=0;i<16*4;i++)SA[i]=((6+(rand()%3))<<3)|(rand()&7);for(int i=0;i<8*4;i++)SB[i]=((6+(rand()%3))<<3)|(rand()&7); + cudaMemcpyToSymbol(Ac,A,sizeof A);cudaMemcpyToSymbol(Bc,B,sizeof B);cudaMemcpyToSymbol(SAc,SA,sizeof SA);cudaMemcpyToSymbol(SBc,SB,sizeof SB); + float ref[128]; + for(int m=0;m<16;m++)for(int n=0;n<8;n++){float r=0;for(int blk=0;blk<4;blk++){float s=0;for(int kk=0;kk<16;kk++){int K=blk*16+kk;s+=dec4(A[m*64+K])*dec4(B[n*64+K]);}r+=s*decS(SA[m*4+blk])*decS(SB[n*4+blk]);}ref[m*8+n]=r;} + int perms[24][4],np=0;int p[4]={0,1,2,3}; + // generate all 24 permutations + for(int a=0;a<4;a++)for(int bb=0;bb<4;bb++)for(int c=0;c<4;c++)for(int dd=0;dd<4;dd++){if(a!=bb&&a!=c&&a!=dd&&bb!=c&&bb!=dd&&c!=dd){perms[np][0]=a;perms[np][1]=bb;perms[np][2]=c;perms[np][3]=dd;np++;}} + float* d;cudaMalloc(&d,128*4);float h[128]; + for(int pi=0;pi>>(d);cudaDeviceSynchronize();cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + int bad=0;float mre=0;for(int i=0;i<128;i++){float re=ref[i]!=0?(ref[i]-h[i])/ref[i]:ref[i]-h[i];if(re<0)re=-re;if(re>mre)mre=re;if(re>1e-2f)bad++;} + if(bad<8)printf("PERM[%d %d %d %d] bad=%d mre=%.4f %s\n",perms[pi][0],perms[pi][1],perms[pi][2],perms[pi][3],bad,mre,bad==0?"<<< MATCH":""); + } + return 0; +} diff --git a/rust/nvfp4_reference/fp4_probe.cu b/rust/nvfp4_reference/fp4_probe.cu new file mode 100644 index 00000000..c44463a2 --- /dev/null +++ b/rust/nvfp4_reference/fp4_probe.cu @@ -0,0 +1,16 @@ +#include +#include +// Probe: does sm_121a assemble a block-scaled FP4 (e2m1 x mxfp4) mma.sync? +__global__ void probe(const unsigned* a,const unsigned* b,float* d,const unsigned* sfa,const unsigned* sfb){ + unsigned ra[4]={a[0],a[1],a[2],a[3]}; + unsigned rb[2]={b[0],b[1]}; + float rc[4]={0,0,0,0}; + // Blackwell block-scaled MXF4 mma (sm_120a/121a): m16n8k64, e2m1 x e2m1, ue8m0 scales + asm volatile( + "mma.sync.aligned.m16n8k64.row.col.kind::mxf4.block_scale.f32.e2m1.e2m1.f32.ue8m0 " + "{%0,%1,%2,%3}, {%4,%5,%6,%7}, {%8,%9}, {%0,%1,%2,%3}, {%10}, {0,0}, {%11}, {0,0};" + : "+f"(rc[0]),"+f"(rc[1]),"+f"(rc[2]),"+f"(rc[3]) + : "r"(ra[0]),"r"(ra[1]),"r"(ra[2]),"r"(ra[3]),"r"(rb[0]),"r"(rb[1]),"r"(sfa[0]),"r"(sfb[0])); + d[threadIdx.x]=rc[0]; +} +int main(){return 0;} diff --git a/rust/nvfp4_reference/fp4_scale.cu b/rust/nvfp4_reference/fp4_scale.cu new file mode 100644 index 00000000..61082284 --- /dev/null +++ b/rust/nvfp4_reference/fp4_scale.cu @@ -0,0 +1,26 @@ +#include +#include +// Derive scale-block map. A=B=e2m1 1.0. sB=all 1.0. sA: base all 1.0(0x38), except thread TL byte TB=2.0(0x40). +// D[m][n]=sum_{b=0..3} 16 * sA_eff[m][b] (A=B=1,sB=1). Baseline all = 64. The (m,b) that (TL,TB) drives -> D[m]+=16. +__global__ void k(float* D,int TL,int TB){ + unsigned a0=0x22222222,a1=a0,a2=a0,a3=a0,b0=a0,b1=a0; + unsigned sA=0x38383838, sB=0x38383838; + if((int)threadIdx.x==TL){ sA = (sA & ~(0xFFu<<(TB*8))) | (0x40u<<(TB*8)); } + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a0),"r"(a1),"r"(a2),"r"(a3),"r"(b0),"r"(b1),"r"(sA),"r"(sB)); + int g=threadIdx.x>>2,t=threadIdx.x&3; + D[g*8+t*2]=c0; D[g*8+t*2+1]=c1; D[(g+8)*8+t*2]=c2; D[(g+8)*8+t*2+1]=c3; +} +int main(){ + float* d; cudaMalloc(&d,128*4); float h[128]; + printf("scaleA: (thread TL, byte TB) -> rows where D jumped 64->80 (delta from 2.0x scale)\n"); + for(int TL=0;TL<32;TL++) for(int TB=0;TB<4;TB++){ + cudaMemset(d,0,128*4); k<<<1,32>>>(d,TL,TB); cudaDeviceSynchronize(); + cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + char buf[128]=""; for(int r=0;r<16;r++){ if(h[r*8]>64.5f){char t[24];sprintf(t,"r%d(%.0f) ",r,h[r*8]);strcat(buf,t);} } + if((TL<8)||TL==31) printf("TL%-2d B%d -> %s\n",TL,TB,buf[0]?buf:"(no change)"); + } + return 0; +} diff --git a/rust/nvfp4_reference/fp4_scaleB.cu b/rust/nvfp4_reference/fp4_scaleB.cu new file mode 100644 index 00000000..037d036f --- /dev/null +++ b/rust/nvfp4_reference/fp4_scaleB.cu @@ -0,0 +1,23 @@ +#include +#include +// Derive sB map: A=B=1, sA=1. one-hot sB byte TB of thread TL =2.0. D[m][n]=sum_blk 16*sB[n][blk]. find col n that jumps. +__global__ void k(float* D,int TL,int TB){ + unsigned a0=0x22222222,a1=a0,a2=a0,a3=a0,b0=a0,b1=a0,sA=0x38383838,sB=0x38383838; + if((int)threadIdx.x==TL) sB=(sB&~(0xFFu<<(TB*8)))|(0x40u<<(TB*8)); + float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a0),"r"(a1),"r"(a2),"r"(a3),"r"(b0),"r"(b1),"r"(sA),"r"(sB)); + int g=threadIdx.x>>2,t=threadIdx.x&3; + D[g*8+t*2]=c0;D[g*8+t*2+1]=c1;D[(g+8)*8+t*2]=c2;D[(g+8)*8+t*2+1]=c3; +} +int main(){ + float* d;cudaMalloc(&d,128*4);float h[128]; + printf("sB: (TL,TB) -> cols that jumped\n"); + for(int TL=0;TL<32;TL++)for(int TB=0;TB<4;TB++){ + cudaMemset(d,0,128*4);k<<<1,32>>>(d,TL,TB);cudaDeviceSynchronize();cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + char buf[64]="";for(int c=0;c<8;c++)if(h[c]>64.5f){char t[8];sprintf(t,"c%d ",c);strcat(buf,t);} + if(TL<12||TL==31)printf("TL%-2d B%d -> %s\n",TL,TB,buf[0]?buf:"-"); + } + return 0; +} diff --git a/rust/nvfp4_reference/fp4_validate.cu b/rust/nvfp4_reference/fp4_validate.cu new file mode 100644 index 00000000..1f749455 --- /dev/null +++ b/rust/nvfp4_reference/fp4_validate.cu @@ -0,0 +1,45 @@ +#include +#include +// Full random m16n8k64 tile validation. A[16][64],B[8][64] random e2m1 nibbles, scales=1. +// Hypothesis map: gid=lane>>2,tig=lane&3. +// A reg0:row gid,k=tig*16+0..7 ; reg2:row gid,k=tig*16+8..15 ; reg1:row gid+8,k=tig*16+0..7 ; reg3:row gid+8,k=tig*16+8..15 +// B reg0:row gid,k=tig*16+0..7 ; reg1:row gid,k=tig*16+8..15 +__device__ __host__ float dec(int nib){ + int s=(nib>>3)&1,e=(nib>>1)&3,m=nib&1; float v; + if(e==0) v=0.5f*m; else v=(1.0f+0.5f*m)*(1<<(e-1)); + return s? -v:v; +} +__constant__ unsigned char Ac[16*64], Bc[8*64]; +__global__ void k(float* D){ + int lane=threadIdx.x,gid=lane>>2,tig=lane&3; + unsigned a[4]={0,0,0,0}, b[2]={0,0}; + for(int j=0;j<8;j++){ + a[0]|=(unsigned)Ac[(gid)*64 + tig*16+0+j]<<(j*4); + a[2]|=(unsigned)Ac[(gid)*64 + tig*16+8+j]<<(j*4); + a[1]|=(unsigned)Ac[(gid+8)*64 + tig*16+0+j]<<(j*4); + a[3]|=(unsigned)Ac[(gid+8)*64 + tig*16+8+j]<<(j*4); + b[0]|=(unsigned)Bc[(gid)*64 + tig*16+0+j]<<(j*4); + b[1]|=(unsigned)Bc[(gid)*64 + tig*16+8+j]<<(j*4); + } + unsigned sA=0x38383838,sB=0x38383838; float c0=0,c1=0,c2=0,c3=0; + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a[0]),"r"(a[1]),"r"(a[2]),"r"(a[3]),"r"(b[0]),"r"(b[1]),"r"(sA),"r"(sB)); + D[gid*8+tig*2]=c0; D[gid*8+tig*2+1]=c1; D[(gid+8)*8+tig*2]=c2; D[(gid+8)*8+tig*2+1]=c3; +} +int main(){ + unsigned char A[16*64],B[8*64]; srand(7); + for(int i=0;i<16*64;i++)A[i]=rand()&15; for(int i=0;i<8*64;i++)B[i]=rand()&15; + cudaMemcpyToSymbol(Ac,A,sizeof(A)); cudaMemcpyToSymbol(Bc,B,sizeof(B)); + float* d; cudaMalloc(&d,128*4); cudaMemset(d,0,128*4); k<<<1,32>>>(d); + if(cudaDeviceSynchronize()){printf("launch err\n");return 1;} + float h[128]; cudaMemcpy(h,d,128*4,cudaMemcpyDeviceToHost); + int bad=0; float maxerr=0; + for(int m=0;m<16;m++)for(int n=0;n<8;n++){ + float ref=0; for(int kk=0;kk<64;kk++) ref+=dec(A[m*64+kk])*dec(B[n*64+kk]); + float got=h[m*8+n]; float e=ref-got; if(e<0)e=-e; if(e>maxerr)maxerr=e; + if(e>1e-2f){bad++; if(bad<=4)printf("MISS D[%d][%d] ref=%.3f got=%.3f\n",m,n,ref,got);} + } + printf("bad=%d/128 maxerr=%.5f %s\n",bad,maxerr,bad?"LAYOUT WRONG":"LAYOUT CONFIRMED"); + return 0; +} diff --git a/rust/nvfp4_reference/nvfp4_actq.cu b/rust/nvfp4_reference/nvfp4_actq.cu new file mode 100644 index 00000000..76842495 --- /dev/null +++ b/rust/nvfp4_reference/nvfp4_actq.cu @@ -0,0 +1,36 @@ +#include +#include +#include +#include +// Device activation quantizer: f16 act[rows,K] -> NVFP4 (e2m1 codes u32 8/word, ue4m3 scales[rows,K/16], global). +// global passed in (real model: a reduce kernel; here host-computed). One thread per block-16. +__device__ unsigned char encS_d(float s){if(s<=0)return 0;int be=0,bm=0;float bd=1e30f; + for(int e=0;e<16;e++)for(int m=0;m<8;m++){float v=(e==0)?(m/8.f)*0.0078125f:(1.f+m/8.f)*exp2f((float)(e-7));float d=fabsf(s-v);if(d>3)&0xF,m=b&7;return (e==0)?(m/8.f)*0.0078125f:(1.f+m/8.f)*exp2f((float)(e-7));} +__device__ int encE_d(float x,float s){float v=x/s;int sg=v<0;v=fabsf(v); + const float L[8]={0,.5f,1,1.5f,2,3,4,6};int b=0;float bd=1e30f;for(int i=0;i<8;i++){float d=fabsf(v-L[i]);if(d=nblk)return; + int r=blk/(K/16), b=blk%(K/16); const __half* ab=a+(size_t)r*K+b*16; + float v[16],amax=0; for(int i=0;i<16;i++){v[i]=__half2float(ab[i]);amax=fmaxf(amax,fabsf(v[i]));} + unsigned char sb=encS_d((amax/6.f)/global); scales[blk]=sb; float bs=decS_d(sb)*global; if(bs<=0)bs=1e-9f; + for(int i=0;i<16;i++){int elem=b*16+i,word=elem>>3,nib=elem&7;int code=encE_d(v[i],bs);atomicOr(&codes[(size_t)r*(K/8)+word],(unsigned)code<<(nib*4));} +} +int main(){ + int rows=512,K=2688; std::vector A((size_t)rows*K);srand(7); + for(auto&x:A){float u1=(rand()+1.)/(RAND_MAX+2.),u2=(rand()+1.)/(RAND_MAX+2.);x=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.05f;} + std::vector<__half> Ah(A.size()); for(size_t i=0;i>>(dA,dC,dS,rows,K,global); + if(cudaDeviceSynchronize()){printf("err\n");return 1;} + std::vector C((size_t)rows*(K/8));std::vector S((size_t)rows*(K/16)); + cudaMemcpy(C.data(),dC,C.size()*4,cudaMemcpyDeviceToHost);cudaMemcpy(S.data(),dS,S.size(),cudaMemcpyDeviceToHost); + // dequant + compare to original (f16-rounded) + const float L[8]={0,.5f,1,1.5f,2,3,4,6}; double sse=0,ss=0; + for(int r=0;r>3)&0xF,m=S[r*(K/16)+b]&7;float bs=((e==0)?(m/8.f)*0.0078125f:(1.f+m/8.f)*exp2f((float)(e-7)))*global; + for(int i=0;i<16;i++){int elem=b*16+i,word=elem>>3,nib=elem&7;unsigned cd=(C[(size_t)r*(K/8)+word]>>(nib*4))&0xf;float sign=(cd&8)?-1:1;float rec=sign*L[cd&7]*bs;float orig=__half2float(Ah[(size_t)r*K+elem]);double d=rec-orig;sse+=d*d;ss+=(double)orig*orig;}} + printf("device act-quant [%dx%d]: rel_rmse=%.3f%% %s\n",rows,K,100*sqrt(sse/ss),sqrt(sse/ss)<0.15?"<<< OK (matches quantize_nvfp4)":"CHECK"); + return 0; +} diff --git a/rust/nvfp4_reference/nvfp4_fix.cu b/rust/nvfp4_reference/nvfp4_fix.cu new file mode 100644 index 00000000..eeefebf3 --- /dev/null +++ b/rust/nvfp4_reference/nvfp4_fix.cu @@ -0,0 +1,12 @@ +#include +__global__ void probe(const unsigned* a,const unsigned* b,float* d,const unsigned* sa,const unsigned* sb){ + unsigned ra[4]={a[0],a[1],a[2],a[3]}; unsigned rb[2]={b[0],b[1]}; + float rc[4]={0,0,0,0}; + asm volatile( + "mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3}, {%4,%5,%6,%7}, {%8,%9}, {%0,%1,%2,%3}, {%10}, {0,0}, {%11}, {0,0};" + : "+f"(rc[0]),"+f"(rc[1]),"+f"(rc[2]),"+f"(rc[3]) + : "r"(ra[0]),"r"(ra[1]),"r"(ra[2]),"r"(ra[3]),"r"(rb[0]),"r"(rb[1]),"r"(sa[0]),"r"(sb[0])); + d[threadIdx.x]=rc[0]; +} +int main(){return 0;} diff --git a/rust/nvfp4_reference/nvfp4_gemm.cu b/rust/nvfp4_reference/nvfp4_gemm.cu new file mode 100644 index 00000000..60e7c035 --- /dev/null +++ b/rust/nvfp4_reference/nvfp4_gemm.cu @@ -0,0 +1,57 @@ +#include +#include +#include +#include +static const float E2M1[8]={0,.5f,1,1.5f,2,3,4,6}; +__host__ int enc(float x,float s){float v=x/s;int sg=v<0;v=fabsf(v);int b=0;float bd=1e30f;for(int i=0;i<8;i++){float d=fabsf(v-E2M1[i]);if(d>2,tig=lane&3; float c0=0,c1=0,c2=0,c3=0; + for(int t=0;t>4,w=K&15,ttig=(blk>=2?2:0)+(w>=8?1:0);if(ttig!=tig)continue;int nib=w&7,reg=blk&1; + b[reg]|=(unsigned)W[gid*64+K]<<(nib*4); + a[(blk&1?2:0)]|=(unsigned)A[gid*64+K]<<(nib*4); // row gid -> regs0/2 + a[(blk&1?3:1)]|=(unsigned)A[(gid+8)*64+K]<<(nib*4); // row gid+8 -> regs1/3 + } + if(tig==0||tig==1){int row=gid+(tig==1?8:0);for(int blk=0;blk<4;blk++)sA|=(unsigned)SA[row*4+blk]<<(b4b(blk)*8);} + if(tig==0){for(int blk=0;blk<4;blk++)sB|=(unsigned)SW[gid*4+blk]<<(b4b(blk)*8);} + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a[0]),"r"(a[1]),"r"(a[2]),"r"(a[3]),"r"(b[0]),"r"(b[1]),"r"(sA),"r"(sB)); + } + float g=gA*gW; + C[gid*8+tig*2]=c0*g;C[gid*8+tig*2+1]=c1*g;C[(gid+8)*8+tig*2]=c2*g;C[(gid+8)*8+tig*2+1]=c3*g; +} +int main(){ + int M=16,N=8,K=NT*64; std::vector A(M*K),W(N*K); + srand(3);for(auto&x:A){float u1=(rand()+1.)/(RAND_MAX+2.),u2=(rand()+1.)/(RAND_MAX+2.);x=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.05f;} + for(auto&x:W){float u1=(rand()+1.)/(RAND_MAX+2.),u2=(rand()+1.)/(RAND_MAX+2.);x=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.05f;} + // quantize A,W -> nibbles + ue4m3 block16 scales + global, ALIGNED per ktile + float gAmax=0,gWmax=0;for(float x:A)gAmax=std::max(gAmax,fabsf(x));for(float x:W)gWmax=std::max(gWmax,fabsf(x)); + float gA=gAmax/6.f/448.f, gW=gWmax/6.f/448.f; + std::vector An(M*64*NT),Wn(N*64*NT),SAb(M*4*NT),SWb(N*4*NT); + auto Q=[&](std::vector&Wm,int rows,std::vector&nib,std::vector&sb,float glob){ + for(int r=0;r>>(d,gA,gW); + if(cudaDeviceSynchronize()){printf("err\n");return 1;} + std::vector h(M*N);cudaMemcpy(h.data(),d,M*N*4,cudaMemcpyDeviceToHost); + double sse=0,ss=0;for(int m=0;m +#include +#include +#include +#include +static const float E2M1[8]={0,.5f,1,1.5f,2,3,4,6}; +int enc(float x,float s){float v=x/s;int sg=v<0;v=fabsf(v);int b=0;float bd=1e30f;for(int i=0;i<8;i++){float d=fabsf(v-E2M1[i]);if(d&W,int rows,int K,std::vector&codes,std::vector&scal,float glob){ + codes.assign((size_t)rows*(K/8),0); scal.assign((size_t)rows*(K/16),0); + for(int r=0;r>3)&0xF,m=sb&7; float bs=((e==0)?(m/8.f)*0.0078125f:(1.f+m/8.f)*exp2f((float)(e-7)))*glob; if(bs<=0)bs=1e-9f; + for(int j=0;j<16;j++){int elem=b*16+j;int code=enc(W[base+j],bs);codes[(size_t)r*(K/8)+elem/8]|=(unsigned)code<<((elem%8)*4);}} +} +__device__ int b4b(int blk){int t[4]={0,2,1,3};return t[blk];} +// In-kernel gather from packed quantize_nvfp4 layout. One warp -> C[16x8] over K. +__global__ void gemm(const unsigned* Acode,const unsigned char* Asc,const unsigned* Wcode,const unsigned char* Wsc, + float* C,int K,float g){ + int lane=threadIdx.x,gid=lane>>2,tig=lane&3; int KT=K/64; float c0=0,c1=0,c2=0,c3=0; + for(int t=0;t>4,w=kk&15,ttig=(blk>=2?2:0)+(w>=8?1:0);if(ttig!=tig)continue;int nib=w&7;int gK=t*64+kk; + // W row gid + {int e=gK,word=e>>3,sh=(e&7)*4;unsigned code=(Wcode[(size_t)gid*(K/8)+word]>>sh)&0xf; b[blk&1]|=code<<(nib*4);} + // A rows gid (regs0/2) and gid+8 (regs1/3) + {int e=gK,word=e>>3,sh=(e&7)*4; + unsigned ca=(Acode[(size_t)gid*(K/8)+word]>>sh)&0xf; a[(blk&1?2:0)]|=ca<<(nib*4); + unsigned cb=(Acode[(size_t)(gid+8)*(K/8)+word]>>sh)&0xf; a[(blk&1?3:1)]|=cb<<(nib*4);} + } + // scales: 4 blocks of this tile = global blocks t*4..t*4+3 + if(tig==0||tig==1){int row=gid+(tig==1?8:0);for(int blk=0;blk<4;blk++)sA|=(unsigned)Asc[(size_t)row*(K/16)+t*4+blk]<<(b4b(blk)*8);} + if(tig==0){for(int blk=0;blk<4;blk++)sB|=(unsigned)Wsc[(size_t)gid*(K/16)+t*4+blk]<<(b4b(blk)*8);} + asm volatile("mma.sync.aligned.m16n8k64.row.col.kind::mxf4nvf4.block_scale.scale_vec::4X.f32.e2m1.e2m1.f32.ue4m3 " + "{%0,%1,%2,%3},{%4,%5,%6,%7},{%8,%9},{%0,%1,%2,%3},{%10},{0,0},{%11},{0,0};" + :"+f"(c0),"+f"(c1),"+f"(c2),"+f"(c3):"r"(a[0]),"r"(a[1]),"r"(a[2]),"r"(a[3]),"r"(b[0]),"r"(b[1]),"r"(sA),"r"(sB)); + } + C[gid*8+tig*2]=c0*g;C[gid*8+tig*2+1]=c1*g;C[(gid+8)*8+tig*2]=c2*g;C[(gid+8)*8+tig*2+1]=c3*g; +} +int main(){ + int K=2688; std::vector A(16*K),W(8*K);srand(5); + for(auto&x:A){float u1=(rand()+1.)/(RAND_MAX+2.),u2=(rand()+1.)/(RAND_MAX+2.);x=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.05f;} + for(auto&x:W){float u1=(rand()+1.)/(RAND_MAX+2.),u2=(rand()+1.)/(RAND_MAX+2.);x=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.05f;} + float gam=0,gwm=0;for(float x:A)gam=std::max(gam,fabsf(x));for(float x:W)gwm=std::max(gwm,fabsf(x)); + float gA=gam/6.f/448.f,gW=gwm/6.f/448.f; + std::vector Ac,Wc;std::vector As,Ws; qnv(A,16,K,Ac,As,gA); qnv(W,8,K,Wc,Ws,gW); + unsigned *dAc,*dWc;unsigned char *dAs,*dWs;float* dC; + cudaMalloc(&dAc,Ac.size()*4);cudaMalloc(&dWc,Wc.size()*4);cudaMalloc(&dAs,As.size());cudaMalloc(&dWs,Ws.size());cudaMalloc(&dC,16*8*4); + cudaMemcpy(dAc,Ac.data(),Ac.size()*4,cudaMemcpyHostToDevice);cudaMemcpy(dWc,Wc.data(),Wc.size()*4,cudaMemcpyHostToDevice); + cudaMemcpy(dAs,As.data(),As.size(),cudaMemcpyHostToDevice);cudaMemcpy(dWs,Ws.data(),Ws.size(),cudaMemcpyHostToDevice); + gemm<<<1,32>>>(dAc,dAs,dWc,dWs,dC,K,gA*gW); + if(cudaDeviceSynchronize()){printf("err\n");return 1;} + float h[128];cudaMemcpy(h,dC,16*8*4,cudaMemcpyDeviceToHost); + double sse=0,ss=0;for(int m=0;m<16;m++)for(int n=0;n<8;n++){double ref=0;for(int k=0;k +__global__ void peak(float* out,int iters){ + unsigned a0=0x24242424,a1=a0,a2=a0,a3=a0,b0=a0,b1=a0,sA=0x38383838,sB=0x38383838; + float c0=0,c1=0,c2=0,c3=0; + for(int i=0;i1e30f?1:0; // fake dep to prevent hoist, never taken + } + if(c0==1234.5f)out[threadIdx.x]=c0+c1+c2+c3; +} +int main(){ + int blocks=264, tpb=256, iters=20000; // 264*8 warps + float* d;cudaMalloc(&d,4096); + peak<<>>(d,5); cudaDeviceSynchronize(); // warmup + cudaEvent_t s,e;cudaEventCreate(&s);cudaEventCreate(&e); + cudaEventRecord(s); peak<<>>(d,iters); cudaEventRecord(e);cudaEventSynchronize(e); + float ms=0;cudaEventElapsedTime(&ms,s,e); + double warps=(double)blocks*(tpb/32); + double flop=warps*(double)iters*16*8*64*2; + printf("mxf4nvf4 m16n8k64 peak: %.1f TFLOP/s (%.2f ms, %.0f warps x %d iters)\n",flop/(ms*1e-3)/1e12,ms,warps,iters); + // also int4 m16n8k64 for comparison (s32 acc) + return 0; +} diff --git a/rust/nvfp4_reference/nvfp4_tiled.cu b/rust/nvfp4_reference/nvfp4_tiled.cu new file mode 100644 index 00000000..90954faf --- /dev/null +++ b/rust/nvfp4_reference/nvfp4_tiled.cu @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include +static const float E2M1[8]={0,.5f,1,1.5f,2,3,4,6}; +int enc(float x,float s){float v=x/s;int sg=v<0;v=fabsf(v);int b=0;float bd=1e30f;for(int i=0;i<8;i++){float d=fabsf(v-E2M1[i]);if(d>5; int lane=threadIdx.x&31; + if(warp>=MT*NT)return; int mt=warp/NT, nt=warp%NT; int gid=lane>>2,tig=lane&3; + float c0=0,c1=0,c2=0,c3=0; + for(int kt=0;kt A((size_t)M*K),W((size_t)N*K);srand(3); + for(auto&x:A){float u1=(rand()+1.)/(RAND_MAX+2.),u2=(rand()+1.)/(RAND_MAX+2.);x=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.05f;} + for(auto&x:W){float u1=(rand()+1.)/(RAND_MAX+2.),u2=(rand()+1.)/(RAND_MAX+2.);x=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.05f;} + float gam=0,gwm=0;for(float x:A)gam=std::max(gam,fabsf(x));for(float x:W)gwm=std::max(gwm,fabsf(x)); + float gA=gam/6.f/448.f,gW=gwm/6.f/448.f; + std::vector Ap((size_t)MT*KT*32*4,0),Wp((size_t)NT*KT*32*2,0),Asc((size_t)MT*KT*32,0),Wsc((size_t)NT*KT*8,0); + // pack A + for(int mt=0;mt>2,tig=lane&3;unsigned a[4]={0,0,0,0},sA=0; + for(int half=0;half<2;half++){int row=mt*16+gid+half*8;for(int Kk=0;Kk<64;Kk++){int blk=Kk>>4,w=Kk&15,tt=(blk>=2?2:0)+(w>=8?1:0);if(tt!=tig)continue;int nib=w&7;int gK=kt*64+Kk; + // block scale (block16) for this element + int k0=gK-(w);/*block start*/ int bstart=(gK/16)*16; float amax=0;for(int j=0;j<16;j++)amax=std::max(amax,fabsf(A[(size_t)row*K+bstart+j])); + float bs=qS((amax/6.f)/gA)*gA;if(bs<=0)bs=1e-9f; a[(blk&1?(half?3:2):(half?1:0))]|=(unsigned)enc(A[(size_t)row*K+gK],bs)<<(nib*4);}} + if(tig==0||tig==1){int row=mt*16+gid+(tig==1?8:0);for(int blk=0;blk<4;blk++){int bstart=(kt*64+blk*16);float amax=0;for(int j=0;j<16;j++)amax=std::max(amax,fabsf(A[(size_t)row*K+bstart+j]));sA|=(unsigned)encS((amax/6.f)/gA)<<(b4b(blk)*8);}} + Ap[((size_t)(mt*KT+kt)*32+lane)*4+0]=a[0];Ap[((size_t)(mt*KT+kt)*32+lane)*4+1]=a[1];Ap[((size_t)(mt*KT+kt)*32+lane)*4+2]=a[2];Ap[((size_t)(mt*KT+kt)*32+lane)*4+3]=a[3];Asc[(size_t)(mt*KT+kt)*32+lane]=sA;} + // pack W + for(int nt=0;nt>2,tig=lane&3;unsigned b[2]={0,0};int row=nt*8+gid; + for(int Kk=0;Kk<64;Kk++){int blk=Kk>>4,w=Kk&15,tt=(blk>=2?2:0)+(w>=8?1:0);if(tt!=tig)continue;int nib=w&7,reg=blk&1;int gK=kt*64+Kk;int bstart=(gK/16)*16;float amax=0;for(int j=0;j<16;j++)amax=std::max(amax,fabsf(W[(size_t)row*K+bstart+j]));float bs=qS((amax/6.f)/gW)*gW;if(bs<=0)bs=1e-9f;b[reg]|=(unsigned)enc(W[(size_t)row*K+gK],bs)<<(nib*4);} + Wp[((size_t)(nt*KT+kt)*32+lane)*2+0]=b[0];Wp[((size_t)(nt*KT+kt)*32+lane)*2+1]=b[1]; + if(tig==0){unsigned sW=0;for(int blk=0;blk<4;blk++){int bstart=(kt*64+blk*16);float amax=0;for(int j=0;j<16;j++)amax=std::max(amax,fabsf(W[(size_t)row*K+bstart+j]));sW|=(unsigned)encS((amax/6.f)/gW)<<(b4b(blk)*8);}Wsc[(size_t)(nt*KT+kt)*8+gid]=sW;}} + unsigned *dAp,*dWp,*dAsc,*dWsc;float* dC; + cudaMalloc(&dAp,Ap.size()*4);cudaMalloc(&dWp,Wp.size()*4);cudaMalloc(&dAsc,Asc.size()*4);cudaMalloc(&dWsc,Wsc.size()*4);cudaMalloc(&dC,(size_t)M*N*4); + cudaMemcpy(dAp,Ap.data(),Ap.size()*4,cudaMemcpyHostToDevice);cudaMemcpy(dWp,Wp.data(),Wp.size()*4,cudaMemcpyHostToDevice); + cudaMemcpy(dAsc,Asc.data(),Asc.size()*4,cudaMemcpyHostToDevice);cudaMemcpy(dWsc,Wsc.data(),Wsc.size()*4,cudaMemcpyHostToDevice); + int warps=MT*NT,tpb=256,blocks=(warps*32+tpb-1)/tpb; + float g=gA*gW; + gemm<<>>(dAp,dWp,dAsc,dWsc,dC,MT,NT,KT,N,g);cudaDeviceSynchronize(); + cudaEvent_t s,e;cudaEventCreate(&s);cudaEventCreate(&e);int rep=50; + cudaEventRecord(s);for(int i=0;i>>(dAp,dWp,dAsc,dWsc,dC,MT,NT,KT,N,g);cudaEventRecord(e);cudaEventSynchronize(e); + float ms=0;cudaEventElapsedTime(&ms,s,e);ms/=rep; + std::vector h((size_t)M*N);cudaMemcpy(h.data(),dC,(size_t)M*N*4,cudaMemcpyDeviceToHost); + double sse=0,ss=0;for(int m=0;m +#include +#include +#include +#include +// e2m1 codebook (magnitudes), index 0..7 +static const float E2M1[8]={0,0.5f,1,1.5f,2,3,4,6}; +float enc_e2m1(float x,float s,int&code){ // quantize x with scale s -> nearest e2m1*s + float v=x/s; int sign=v<0; v=fabsf(v); int best=0; float bd=1e30f; + for(int i=0;i<8;i++){float d=fabsf(v-E2M1[i]); if(d nearest representable (4exp bias7,3mant) +float quant_ue4m3(float s){ if(s<=0)return 1e-9f; float best=1e30f,bd=1e30f; + for(int e=0;e<16;e++)for(int m=0;m<8;m++){float v=(e==0)?(m/8.0f)*0.0078125f:(1.0f+m/8.0f)*exp2f((float)(e-7)); float d=fabsf(s-v); if(d W(N*K); double e=0; + // realistic-ish weights: gaussian * small + for(auto&w:W){ float u1=(rand()+1.0)/(RAND_MAX+2.0),u2=(rand()+1.0)/(RAND_MAX+2.0); w=sqrtf(-2*logf(u1))*cosf(6.2831853f*u2)*0.02f; } + // --- NVFP4: per-row block-16 ue4m3 scale, e2m1 codes, + per-tensor global --- + double sse_nv=0,ss=0; float gmax=0; for(float w:W)gmax=std::max(gmax,fabsf(w)); + float global=gmax/6.0f/448.0f; // global so per-block amax/6 fits ue4m3 range (~448) + for(int n=0;n argmax should hold\n":"NVFP4 noticeably worse -> argmax RISK, validate carefully\n"); + return 0; +} diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml new file mode 100644 index 00000000..98324632 --- /dev/null +++ b/rust/rust-toolchain.toml @@ -0,0 +1,9 @@ +# Pinned to a dated nightly so rustfmt/clippy are reproducible across +# every clone and CI run. A bare `channel = "nightly"` floats: each +# machine resolves it to whatever nightly is latest that day, so clippy +# silently drifts and `make clippy` starts flagging files nobody +# touched. Bump this date deliberately (its own commit) when you want +# the newer lints — never let it float. +[toolchain] +channel = "nightly-2026-05-15" +components = ["rustfmt", "clippy"] diff --git a/swift/ffi-demo/ffai-ffi-demo b/swift/ffi-demo/ffai-ffi-demo new file mode 100755 index 00000000..e0686e2b Binary files /dev/null and b/swift/ffi-demo/ffai-ffi-demo differ diff --git a/swift/ffi-demo/main.swift b/swift/ffi-demo/main.swift new file mode 100644 index 00000000..dabd9779 --- /dev/null +++ b/swift/ffi-demo/main.swift @@ -0,0 +1,26 @@ +// Copyright 2026 Eric Kryski (@ekryski) and Tom Turney (@TheTom) +// SPDX-License-Identifier: Apache-2.0 +// +// Proof that Swift consumes the shared FFAI Rust engine: this links the +// libffai_ffi static library and calls the C-ABI bridge in +// rust/crates/ffai-ffi. Build + run with swift/ffi-demo/run.sh. +import Foundation + +func take(_ p: UnsafeMutablePointer?) -> String { + guard let p else { return "" } + defer { ffai_string_free(p) } + return String(cString: p) +} + +print("FFAI Rust engine, called from Swift") +print(" version: \(take(ffai_version()))") +print(" compiled backends: \(take(ffai_compiled_backends()))") + +if let dev = ffai_open_device() { + print(" live device: [\(take(ffai_device_backend(dev)))] \(take(ffai_device_name(dev)))") + ffai_close_device(dev) +} else { + print(" live device: none on this host") + print(" (expected on Apple — Swift's native Metal engine is primary here;") + print(" the same shared layer runs natively on CUDA via ffai-cuda.)") +} diff --git a/swift/ffi-demo/run.sh b/swift/ffi-demo/run.sh new file mode 100755 index 00000000..a6968621 --- /dev/null +++ b/swift/ffi-demo/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build the Rust FFI staticlib + compile/link/run the Swift driver. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +RUST="$HERE/../../rust" +HDR="$RUST/crates/ffai-ffi/include" +LIB="$RUST/target/debug" + +( cd "$RUST" && cargo build -p ffai-ffi ) + +swiftc "$HERE/main.swift" \ + -import-objc-header "$HDR/ffai.h" \ + -L "$LIB" -lffai_ffi \ + -o "$HERE/ffai-ffi-demo" + +exec "$HERE/ffai-ffi-demo"