Skip to content

xpu: LFM3-1.7B on Intel GPUs, Iris Xe first (draft, stacked) - #1696

Open
jschmied wants to merge 12 commits into
ashhart:mainfrom
jschmied:lfm3-xpu
Open

jschmied wants to merge 12 commits into
ashhart:mainfrom
jschmied:lfm3-xpu

Conversation

@jschmied

@jschmied jschmied commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1666. Rebased onto main f23603801: #1692 and #1839 are merged now, so their three commits are gone from here; eleven commits remain. The only remaining stack item is ref64.py's numpy path (8bf9d952c here, also carried by #1634): the checkpoint test reads its float64 dumps.

Checks on this head (5943daef0): Spark (GB10): lean 425 on the base, 425 on the head, 0 new; zig build test, the CI set (test-kernel-contract test-golden test-server-security test-pull-security fuzz-cortheon) and git diff --check pass. The Iris Xe cells below are from bb38b814b; the rebased head gives the same tokens, float64 gaps and SPIR-V bytes on the Iris Xe (see the comment of 2026-11-03). 5943daef0 gives the XPU LFM3 module core's rope_freq, which main's lfm3 config now imports.

What it adds

  • Kernels without DPAS: the short conv, QK norm with partial RoPE, gated GQA attention over a ring, the sandwich norm and SwiGLU, each with an FP64 op test and a planted control.
  • The LFM3 forward on the Intel GPU:
    • prefill in windows, decode, and the conv state and KV rings;
    • weights loaded through names.zig, MLX 4-bit group 64 as stored;
    • a checkpoint test against ref64.py's float64 dumps.
  • Serving: xpu-native serves LFM3 through main's lane host (copy_only), with the contract Gemma 4 E4B's engine implements:
    • one sequence per stream;
    • the lane core's context copies verified in windows of up to 16 rows;
    • several streams' windows in one forward;
    • prompt states kept by the prompt cache.
  • One adapter line (xpu_nem.zig): the host takes a family's copy_match when its table row declares one. LFM3 declares 8. At the core's default of 4, copies cost a short chat 6 % on Iris Xe.
  • Exact speed work:
    • streams share a decode step through N-row matvecs that sum each row as the one-row kernels do (tf-xpu-lfm3_rows-test, bit for bit);
    • window costs are timed at load for 1, 2, 4, 8 and 16 rows and interpolated between (load 8 s instead of 21 s, with the same round plans);
    • decode fuses w1 + w3 + SwiGLU, the four attention matvecs, and RoPE with the KV store;
    • prompts run on nem_prow_gen12 in windows of 128 rows.
  • Prompt GEMM (bb38b814b, a different summation order): prompt windows take pgemm (lfm3_mv.cl), which tiles 64 × 64 in SLM as vit_gemm does and dequantizes MLX 4-bit as it loads a weight tile. Each output sums an fma chain per 64-wide k block and adds the blocks in order, so a row's sum doesn't depend on how the prompt is cut. Verified windows, shared rounds and decode keep pdense and qmv, so drafts stay bit-equal to one-row steps.
  • Docs: the Iris Xe row in docs/recipes/xpu-arc.md.

Checks (Iris Xe, WSL2)

After the rebase onto a068484a0: B2's 64 tokens on all three prompts, --parallel 1/2/4 equal to solo, cache resume equal to cold, tf-xpu-lfm3_rows-test 28/28, the tgllp images byte-equal to the previous head, and bmg's 14 images byte-equal to main's (lfm3, lfm3_mv and nem_prow_gen12 are the new ones).

  • Forward against float64: every layer of 3 prompts is at 0.95–1.05× the bf16 gap. The 64 greedy tokens match the CUDA engine (lfm3 CUDA: serve the block FP8 export weight-only and the MLX 4-bit export as stored (stacked on #1508) #1634, MLX 4-bit) on all 3 prompts, and match again after every speed change, at windows of 128 and 64, with each fusion on and off.

  • Streams: at --parallel 1, 2 and 4, 5 concurrent replies are byte-equal to solo, in two runs each. tf-xpu-lfm3_streams-test checks streams in shared forwards, streams freed and joined mid-way, and windows rolled back, each against the stream alone.

  • RoPE kernels: these now require sub-groups of 16. Before, the compiled width could change a reply from one process to the next.

  • Prompt cache: a resumed second turn is byte-equal to the cold one, at 1k and 8k tokens. TTFT is 0.56 s against 13.25 s at 1k, and 0.80 s against 122.6 s at 8k.

  • Speed, server:

    before the speed work after
    decode, 1 stream, --no-drafts 14.8–15.8 tok/s 15.6–16.8 tok/s
    decode, 1 stream, copy drafts – 15.9 (chat) to 26.3 (prose) tok/s
    decode, 4 streams, aggregate – 25–32 tok/s
    TTFT at 1k 13.1–14.2 s 11.7–12.2 s; with pgemm 4.6–4.7 s
    TTFT at 8k 130.8–131.3 s 116.3–117.7 s; with pgemm 55.4–55.6 s

    With copy drafts on, prose decodes 65 % and code 38 % faster than with --no-drafts. The two chats stay within 2 % of --no-drafts, which is within the session noise. Prompts are timed with --context 16384, decode with --context 4096.

  • pgemm against float64: lfm3_pgemm checks it in groups of 64 and 32 (the other size as the planted control) and every row alone equal to the row in a window. lfm3_chunks checks windows of 64, 128, 256 and a ragged cut bit for bit (rows, logits, the next step), with pdense's order as the control. Through the model, per layer against float64: the 750-row prompt, 1k and 8k are the same (≤ 1.002×) or closer (mean −0.3 to −0.7 %). The two short prompts are further: +5.3 % (20 rows) and +2.9 % (19 rows), single layers up to +8 %. At op level on real weights both orders have fp32 error 2e-7 and 0 bf16 flips, and three orders rank differently on each short prompt. B2's 64 tokens are unchanged; two chat replies changed at near-ties (one now float64's top-1, one now 0.075 behind it).

  • Spark (GB10), head bb38b814b: lean reports no new findings; zig build test, the contract, golden, security and fuzz steps pass; git diff --check is clean.

Overlaps

atc overlap reports file conflicts with:

I'll rebase onto whichever lands first.

Without torch (or with --numpy) the forward runs on numpy: MLX affine and bf16 weights are dequantized
exactly; NVFP4 and FP8 stay torch-only and are refused by name. --bf16-points rounds where the Intel GPU
engine stores bf16 (embedding rows and RoPE cos/sin too, attention P and logits never). dump writes the
float64 hidden state after every layer and check replays an engine's greedy replies teacher-forced. The
collect and miss-table CLI is unchanged.
…, gated GQA attention over a ring, sandwich norm, SwiGLU) and their FP64 op tests
…state and KV rings) loading by names.zig, its checkpoint test against ref64.py's float64 dumps
…pt projections on nem_prow_gen12 without DPAS, Iris Xe measurements
…pied on the device

LFM3's per-stream state (the conv rows of 13 layers, the KV slots of 11, the position) moves out of
the layers into a sequence the model binds; the engine makes, frees and binds sequences and saves and
restores prompt states (the conv rows, the full layers' slots up to the mark, the sliding rings whole).
The lane host serves LFM3 on one sequence until the engine verifies windows (a later commit).
… and RoPE with the KV store

lfm3_mv.cl takes attn.cl's qmv_row verbatim (same compile options, so the same contraction) and runs
w1 and w3 of a row with their SwiGLU in one launch, and q, k, v and the gate in one; lfm3_rope_store
normalises and rotates the q and k heads and stores k and v in one launch, through the same per-head
function lfm3_qk_norm_rope now calls. Both RoPE kernels require sub-groups of 16: their norm sums one
partial a sub-group into part[4], which a compiler-chosen width of 8 would overrun and 32 would sum in
another order. Every value is rounded as before: the greedy tokens of the three reference prompts are
unchanged. Decode on Iris Xe: 15.2 to 17.2 tok/s (LFM3_FUSE selects the fusions for A/B timing).
On Iris Xe a prompt window of 128 rows beats 32 and 64 with the 8 x 4 prompt tiles (1,024-token
prefill 84 against 78 tok/s at 64). The greedy tokens of the three reference prompts are unchanged at
windows of 128 and of 64.
…ream, verified windows, shared forwards

LFM3's engine takes the contract Gemma 4 E4B's takes on the XPU lane host (copy_only): one sequence a
stream in up to 8 slots, the lane core's context copies verified in windows of up to 16 rows
(verifyRows, keepRows), several streams' windows in one forward (verifyMulti, slotOf), window costs
timed at load (1, 2, 4, 8 and 16 rows, the widths between interpolated: 8 s to load instead of 21 s,
the same round plans), and prompt states kept anywhere (cache_rules). One line in the adapter
(xpu_nem.zig): the host takes a family's copy_match when its table row declares one, before
XPU_COPY_MATCH. LFM3's is 8: at the core's 4, copies cost a short chat 6% on Iris Xe; at 8, prose and
code decode 65% and 38% faster than with --no-drafts, and the two chats within 2% of it (session
noise).

A window's rows sum as the decode steps do: its projections and head run on pdense, whose bf16 and
fp32 outputs equal qmv4_bf and qmv4_f32 bit for bit (tf-xpu-lfm3_rows-test). Conv and attention run on
each stream's own state and position; lfm3_conv_hist also records each row's B * x, so keeping the
first c rows of a window restores the conv state those rows leave. Decode ids are kernel arguments,
so no host memory the caller may reuse is read later. tf-xpu-lfm3_streams-test checks streams in
shared forwards, freed and joined mid-way, and windows rolled back to their kept rows, against each
stream alone.
…les, with copy drafts, streams and the prompt cache
xpu_model.zig drops its own invFreq (theta^e in f32, 1-3 ulp from torch in 16 of the full layers'
32 frequencies and 28 of the sliding layers' 64) for Rotary.inverseFrequencies() (f64 pow, then
f32: torch's values), and sizes the sliding rings and windows by cfg.ring and LayerType.window,
as the CUDA backend does.
…loader (2.7x TTFT at 1k), verified windows keep pdense

pgemm (lfm3_mv.cl) tiles 64 x 64 in SLM as vit_gemm does and dequantizes q * scale + bias as it
loads a weight tile; each output sums an fma chain a 64-wide k block and adds the blocks in order,
so a row's sum does not depend on how the prompt is cut. Model.forwardPrompt routes every
projection of a prompt window to it (prefill, prefillSpan, prefillWindows); verified windows,
shared rounds and decode keep pdense and qmv, so drafts stay bit-equal to one-row steps.

lfm3_pgemm checks it against FP64 in groups of 64 and 32 (the other group size as the planted
control) and every row alone = in a window; lfm3_chunks checks the model's windows of 64, 128,
256 and a ragged cut bit for bit (rows, logits, the next step), pdense's order as the control.
On Iris Xe through the server, TTFT at 1,024 tokens goes from 12.3-12.4 s to 4.6-4.7 s and at
8,192 from 116.3-116.7 s to 55.4-55.6 s.
…apper, as main's lfm3 config now imports rope_freq
@jschmied

jschmied commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main f23603801, now at 5943daef0. One commit is new since the rebase: main's 66924b5e1 makes LFM3's config import rope_freq, which zig/build/xpu.zig didn't give the XPU LFM3 module, so -Dxpu stopped building. 5943daef0 wraps core's rope_freq for it, the same way lfm3Image wraps core's imports. A plain rope_freq module doesn't work, because tensorfold-native then has core/rope_freq.zig in two modules.

Iris Xe (Gen12LP, WSL2), on c0fae4fc9 with that same four-line change, against the previous head bb38b814b:

  • B2's 64 tokens are unchanged on all three prompts, and every layer's float64 gap is identical.
  • --parallel 1/2/4 equal solo and bb38b814b, and cache resume equals cold at 1k and 8k.
  • tf-xpu-lfm3_rows-test 28/28, and tf-xpu-lfm3_streams-test passes.
  • The tgllp SPIR-V images are byte-equal to bb38b814b, 17 of 17.

Spark (GB10), 5943daef0: lean 425 on the base, 425 on the head, 0 new; zig build test, the CI set and git diff --check pass.

Marking it ready for review.

@jschmied
jschmied marked this pull request as ready for review November 3, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LFM3-1.7B on the XPU backend (Intel GPUs, Iris Xe first)

1 participant