Skip to content

xpu: LFM3-VL-2B on the Intel GPU (Iris Xe, no DPAS): pictures in, text out - #1838

Draft
jschmied wants to merge 26 commits into
ashhart:mainfrom
jschmied:lfm3-vl-xpu
Draft

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

Conversation

@jschmied

@jschmied jschmied commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1696 (LFM3-1.7B on XPU, with the family's shared rope frequencies, ring and window) and #1502 (LFM3 + LFM3-VL on Metal).

Shared vision spec. The tower loads through families/lfm3/vision_spec.zig (733e5c6c5): one backend-neutral table of the 138 vision tensors, plus the image-row layout (Span, overlap, place), checked against the real checkpoint index at c823fade98e3. The XPU loader, the picture placement and the splice use it (40b39b4d2). The tower rows, the cat logits and the three served replies are bit-identical to before. The same spec is ready for #1502's Metal loader and the CUDA tower.

What it adds

  • The VL checkpoint's text decoder on XPU: MLX 4-bit in groups of 32 and the 8-bit embedding (head tied).

    • The matvec sources take the group size as QG (64 by default). The _g32 images are the same sources with QG=32; lfm3_q8.cl holds the 8-bit embedding rows and head.
    • Every existing SPIR-V image is byte-equal, so LFM3-1.7B (xpu: LFM3-1.7B on Intel GPUs, Iris Xe first #1696) does not move.
  • The vision tower and projector without DPAS (lfm3_vit.cl, xpu_vision.zig): fp32 activations, bf16 weights as stored, fp32 sums. The kernels:

    • an SLM-tiled GEMM;
    • RMSNorm, LayerNorm, the residual step;
    • the gated 3×3 depthwise conv over merge-order rows;
    • q/k norm with axial RoPE;
    • unmasked GQA attention (online softmax, no score matrix);
    • SwiGLU and erf GELU.

    Only the image rows go to the decoder in bf16.

  • Serving pictures: lfm3_vl checkpoints get lfm3: add native Metal vision support above the text decoder #1502's image offer (its processor, part type "image", 256 image tokens unless --vision-image-tokens, up to 4096).

    • Before a stream's prompt windows, the tower writes the pictures' rows, and each window copies them over the image tokens' embeddings.
    • Decode, copy drafts, streams and the prompt cache are unchanged; kept states are keyed by the lane host's image hash.
  • Tests:

Checks (Intel Iris Xe, Gen12LP, WSL2)

Kernels. The fp32 vision kernels are within 1e-5 of float64; the worst is 1.6e-6 (attention over 1,156 patches). Every planted control fails:

  • the table transposed;
  • a centred or uncentred norm, or the norm before the add;
  • GELU's tanh form;
  • gate and up swapped;
  • the conv grid in raster order;
  • rows and columns swapped in RoPE;
  • a causal mask;
  • query head h on KV head h % 8.

The group-32 and 8-bit kernels match float64, with pdense g32 equal to qmv4_bf bit for bit; their controls fail too (groups of 64, the bytes of a word reversed).

Text decoder of the VL checkpoint against ref64.py's float64: float64's next token on 3 prompts, every layer at 0.94–1.05× the bf16 gap.

Tower and projector, image rows against float64. #1502's reference is run in plain float64; transformers' bf16 tower is estimated by the same reference's own bf16 points:

picture XPU (this PR) reference's bf16 points transposed-grid control
14×20 fixture (pixels 53153c22) rel 1.65e-3, cos 0.999999 rel 6.55e-2 rel 0.82, cos 0.67 (fails)
a 3000×4000 phone photo, 1,024 tokens rel 1.66e-3, cos 0.999999 rel 3.13e-2 rel 1.09, cos 0.41 (fails)

Against #1502's own figures. #1502 measures native rows against the reference's bf16-points graph, its "idealFP64":

#1502 Metal this PR
fixture rows vs the bf16-points reference 1.58e-2 6.56e-2 (it follows plain float64 instead)
fixture rows vs plain float64 — 1.65e-3

Logits with a picture in context: #1502's real-image fixture, the cat, 90 teacher-forced rows, its tools unchanged, its exact input (the receipt's patches f1c55a0dd28b):

run rows #1502 this PR
(a) same projected rows into float64 all 90 rel 1.30e-2, 0 disjoint rel 1.33e-2, 3 disjoint (image rows)
suffix 16 — 0 top-1 differences
(b) float64's own vision all 90 rel 2.22e-2, 7 disjoint rel 2.09e-2, 1 disjoint
suffix 16 2 disjoint 0 disjoint
  • No text row has a top-1 difference in any run.
  • On the corrected processor input (9d39c115, ≤ 1.2e-7 per value from the receipt's), the figures are (a) 1.49e-2 and (b) 2.01e-2, again with 0 in the suffix.

A receipt's first reply token. With the bf16 tower (C11) and with transformers' bf16, the receipt's reply starts with '1' ("1. Roggenbrot…").

Serving (--vision --vision-image-tokens 4096 --context 8192, greedy, thinking off): a vaccination-record photo, a receipt and an A4 page.

  • The same request twice is byte-equal; the second resumes from the prompt cache.
  • --parallel 2 is byte-equal to solo.
  • A receipt after another picture with the same text equals it on a cold server.
  • PNG and lossless WebP (libwebp 1.6.0) give the same pixels bit for bit, and so the same replies.
  • JPEG (libjpeg-turbo 3.1.4.1, quality 95) gives the PNG reply on the A4 page. On the vaccination record and the receipt it gives the other side of a near-tie: the English table header (Date / Vaccine / Batch Number) instead of the German one, and "Roggenbrot 750g: 3.40" instead of "1. Roggenbrot 750g - 3,40". These are the replies the PNG gave before xpu: LFM3-1.7B on Intel GPUs, Iris Xe first #1696's prompt GEMM changed the summation order.

LFM3-1.7B (#1696): B2's 64 tokens on 3 prompts, --parallel 1/2/4 equal solo, cache resume equals cold, rows 28/28, streams ok.

Lint: lean 0 new, zig fmt, git diff --check.

Numbers (Iris Xe, head c96e5a4f4, one session, 3 runs each)

receipt 450×800 screenshot 1280×800 A4 page 1240×1754
patches / image rows / prompt tokens 1,400 / 350 / 377 4,000 / 1,000 / 1,022 8,580 / 2,145 / 2,165
tower 792–870 ms 3.05–3.12 s 10.4–10.7 s
projector 24–29 ms 69–72 ms 150–168 ms
text prefill 1.90–2.00 s 5.13–5.18 s 11.5–12.5 s
TTFT (server, no cache) 2.85–3.17 s 8.45–8.54 s 22.4–23.5 s
decode 14.8–15.2 tok/s 15.0–15.1 tok/s 15.0–15.1 tok/s

Two speed changes since the first version, both measured against the old head in the same session (the laptop's GPU runs at a different clock from one session to the next: the old head's A4 tower took 11.0–11.3 s in one session and 15.3–15.5 s in another, so only same-session ratios compare):

  • Text prompts take xpu: LFM3-1.7B on Intel GPUs, Iris Xe first #1696's tiled prompt GEMM (bb38b814b, a different summation order; its float64 gaps are in xpu: LFM3-1.7B on Intel GPUs, Iris Xe first #1696). Text prefill on the A4 page 2.5× faster.

  • Tower attention takes vit_attn_qr (c96e5a4f4): q in registers, 4 rows a sub-group, the full-tile P·V unrolled; no spill on tgllp, where vit_attn spilled 896 B. Its output is byte-equal to vit_attn's on the GPU (tf-xpu-lfm3_vit-test at 1,156, 1,400 and 8,580 patches), and the tower's rows are byte-equal to before on all four pictures, every layer. 313–372 against 183–209 GFLOPS; the A4 tower 1.45×.

  • Together: A4 TTFT 42.8 s → 22.8 s (1.9×) in one session. The served replies are unchanged by c96e5a4f4.

  • Peak device memory on the server: 3.14 GB with tower scratch for 4,096 image tokens and an 8,192-token context.

  • The fp32 activations cost 3–6% tower time against bf16 activations.

@jschmied
jschmied force-pushed the lfm3-vl-xpu branch 2 times, most recently from 40b39b4 to 191d8b7 Compare November 1, 2026 16:06
@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied, this is a careful piece of work. The vision tower is checked kernel by kernel against float64, each
kernel with a control that fails, and the shared vision_spec.zig is something the Metal and CUDA towers can reuse.

Reading it on current main, the code holds up:

What keeps it a draft is the stack. It needs #1696 and #1502 first, and two of its first commits are now on main in
their landed form (#1839, #1692). Once #1696 is in, could you rebase onto main, dropping those? Then please post the
Intel GPU cells: Nemotron's tokens and SPIR-V hashes against main, the LFM3-VL kernel and tower tests, and the served
checks. We don't have an Intel GPU, so we'll rely on your runs. A second Intel GPU, an Arc for instance, would make
them stronger if you have access to one.

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

jschmied commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. Rebased as d42190dc7, onto main f23603801 on top of #1696's rebased head (c0fae4fc9); #1839's and #1692's commits are gone. The Iris Xe cells for this head are being re-run and follow here.

@Mirrdhyn, you run XPU on an Arc Pro B70. Would you have time for a second Intel GPU's run of this PR? It adds the LFM3-VL vision tower and LFM3's text decoder to the XPU backend; so far it has only run on an Iris Xe (Gen12LP, no DPAS), and a Xe2 run would show both that it works with DPAS and that nothing existing moves. On branch lfm3-vl-xpu of jschmied/TensorFold, with the default -Dxpu-device=bmg:

  1. Nothing existing moves: zig build -Dxpu xpu-spirv here and on main f23603801, then sha256sum of the installed SPIR-V images (every existing image should be byte-equal), and Nemotron's greedy tokens on your usual prompt on both.
  2. The new kernels: zig build -Dxpu xpu-tests, then tf-xpu-lfm3_q32-test, tf-xpu-lfm3_vit-test and tf-xpu-lfm3_rows-test. Each checks against float64 and has a planted control that must fail.
  3. If you have the time and about 2 GB of disk: one picture through the server: zig build -Dxpu xpu-native, then serve LiquidAI/LFM3-VL-2B-MLX-4bit from zig-out/xpu/bin/tensorfold-native with --vision (as in this PR's Serving checks) and ask about any picture, greedy. The reply text is enough.

Just the outputs pasted here would be enough. Thank you!

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied for the quick rebase. The three commits that landed as #1839 and #1692 are gone, and the series now applies cleanly on current main with nothing else changed. Thanks too for asking @Mirrdhyn for an Arc run; those three checks are exactly the ones we'd want.

So what's left is outside your commits: #1696 landing first (and #1502 for the two carried files), plus the Iris Xe cells for this head when they're ready. Once those are in, this can leave draft.

…apper, as main's lfm3 config now imports rope_freq
…embedding, group 64 unchanged

The matvec sources (attn.cl's qmv_row, lfm3_mv.cl, nem_prow.cl's pdense) take the MLX group size as
QG, 64 by default; attn_g32, lfm3_mv_g32, nem_prow_g32 and nem_prow_gen12_g32 are the same sources
with QG=32. Every existing SPIR-V image is byte-equal to before, so LFM3-1.7B's forward and tokens do
not move. lfm3_q8.cl reads the 8-bit embedding in groups of 32: embed8, embed8_tok and the tied head
qmv8_f32; a verified window's rows take qmv8_f32 one row at a time, as the decode step sums them.

The engine admits LFM3-1.7B (4-bit, group 64) and LFM3-VL-2B's text decoder (4-bit, group 32, the
embedding 8-bit). tf-xpu-lfm3_q32-test checks the group-32 kernels and the 8-bit ones against
float64, pdense against qmv4_bf bit for bit, and fails the planted controls (groups of 64, the bytes
of a word reversed). On the VL checkpoint, tf-xpu-lfm3_fwd-test against ref64.py's float64 dumps
gives float64's next token on capitals, chat and items, every layer at 0.94-1.05x the bf16 gap.
zig/src/families/lfm3/image.zig and its test root zig/src/lfm3_image_tests.zig byte-identical to
ashhart#1502 at f066644, so they drop out when ashhart#1502 lands.
…ild file of ours

zig/build/lfm3_xpu_host.zig declares the step only while no other build file has, so ashhart#1502's own
lfm3_host.zig step takes over when it lands; build/sys.zig's walk gives the test root its sys import.
…ted controls

lfm3_vit.cl rounds to bf16 where transformers' bf16 tower does and computes in fp32 between:
- vit_gemm: y = bf16(x . w^T + bias), 64 x 64 tiles in local memory, 4 x 4 a work-item (the
  patchifier, every projection and the projector's two layers);
- vit_rms (bf16(w * bf16(x * rsqrt))), vit_add_rms (the residual step and the next norm),
  vit_layernorm with bias, vit_gelu with erf, vit_swiglu;
- vit_dwconv: bf16(C * bf16(dwconv3x3(bf16(B * x)))) over the 2 x 2 merge-order rows;
- vit_qk_norm_rope: the q/k head RMSNorm at head dim 64 and axial RoPE (rows, columns);
- vit_attn: unmasked GQA over one picture's patches, online softmax by 16 keys, no score matrix.

tf-xpu-lfm3_vit-test checks each against float64 on random data; every planted control fails: the
table transposed, a centred (or uncentred) norm, the norm before the residual add, silu for GELU,
gate and up swapped, the conv grid fed in raster order, rows and columns swapped in RoPE, a causal
mask, and query head h on KV head h % 8.
…ainst ashhart#1502's float64 reference

xpu_vision.zig loads the 138 bf16 tensors by ashhart#1502's names and shapes and runs one picture's
merge-order patches to its gh * gw / 4 image rows: patchifier and embedding norm, the 14 layers
(gated depthwise conv or GQA attention with QK norm and axial RoPE, each with the SwiGLU FFN), then
the projector (LayerNorm a patch, 2 x 2 blocks as 4096-wide rows, linear_1, GELU, linear_2).
zig/src/lfm3_image.zig roots ashhart#1502's image.zig where its relative imports reach.

tf-xpu-lfm3_tower-test writes the patches, every layer's rows and the image rows, and the same
patches with the grid passed transposed. tools/lfm3/vision_check.py runs ashhart#1502's numpy reference
(vision_fp64_reference.py, unchanged) with its bf16 points and in plain float64:
- 14 x 20 synthetic picture: image rows rel 4.21e-2, cos 0.99912 against float64 (the reference's
  bf16 points: rel 6.55e-2); the transposed grid: rel 0.82, cos 0.67;
- a 3000 x 4000 photo of a form at 1,024 image tokens (72 x 54 patches): rel 2.84e-2, cos 0.99960
  (bf16 points: rel 3.13e-2); transposed: rel 1.09, cos 0.41.
The tower takes 164 ms on the synthetic picture and 2.97 s on the photo on Iris Xe.
…ed in place of the image tokens

xpu-native reads lfm3_vl checkpoints with the LFM3 family (vl_model_type) and offers image input as
request unless --vision-image-tokens names 64 to 4096; LFM3-1.7B still refuses --vision. The engine
loads the tower with scratch for that budget. Before a stream's prompt windows, the adapter hands its
pictures to the engine (pictures, when the engine has it): the tower writes their rows, and each
prompt window copies the rows over the embeddings of the image tokens it holds. Decode, copy drafts,
streams and the prompt cache are unchanged; a kept state after a picture is keyed by the lane host's
image hash.

The server's core module owns core/picture, so ashhart#1502's image.zig, which reaches it by relative path,
is compiled from a copy the build writes with those two imports read as core's; the file itself stays
byte-identical to ashhart#1502's.

tf-xpu-lfm3_vl-test runs one picture without the server (tower, projector, text prefill, decode).
Through the server on Iris Xe, greedy: a vaccination record photo, a receipt and an A4 page give the
same replies twice (the second from the prompt cache) and with --parallel 2 all at once; a receipt
after another picture with the same text equals it on a cold server. LFM3-1.7B's gates do not move:
B2's tokens, --parallel 1/2/4 against the pinned solo replies, the cache resume.
…s within 1.7e-3 of float64

Every activation of the tower and the projector stays fp32 (weights bf16 as stored, sums fp32 as
before); only the image rows go to the decoder in bf16, as ashhart#1502's rows buffer holds them. Each
kernel keeps a rounding flag for transformers' bf16 points (vision.Round, Tower.round; 0 by
default): with every bit set the forward is C11's bit for bit.

The ablation on ashhart#1502's 14 x 20 fixture against vision_fp64_reference.py in plain float64: no one
rounding point carries the gap (any one alone in fp32 leaves the rows at 2.7e-2 to 7.0e-2); taken
together they do: the linear outputs, the conv's inner points and the attention path to 7.5e-3,
the projector's three points to 3.3e-3, the pixels to 1.65e-3 (layer 13 at 4.3e-6).

- fixture: image rows rel 1.65e-3, cos 0.999999 (C11: 4.21e-2, 0.99912); transposed grid 0.82;
- the 3000 x 4000 form photo at 1,024 tokens: rel 1.66e-3, cos 0.999999; transposed 1.09.
tf-xpu-lfm3_vit-test now runs the kernels with fp32 in and out, within 1e-5 of float64 (at most
1.6e-6), and its controls still fail, GELU's tanh form among them. tf-xpu-lfm3_tower-test dumps
fp32 and the first layers' stages by ashhart#1502's trace names; LFM3_VIT_ROUND sets the rounding points.
…ed row for ashhart#1502's compare_logits.py

tf-xpu-lfm3_vl_logits-test runs ashhart#1502's real-image fixture (90 tokens, 70 image rows at 4, the
suffix "Describe this image." -> "The image contains a cat.") through the tower and the decoder in
windows of 16 rows, and writes each row's logits as bf16, the projected rows and the patches.

On a stand-in for ashhart#1502's cat (placecats.com/300/200 serves another JPEG now: 14 x 20 patches, 70
rows), against ashhart#1502's fp64_reference.py, compare_logits.py and vision_fp64_reference.py, unchanged:
- the XPU's own rows into float64 (decoder alone): rel 1.72e-2 over 90 rows (ashhart#1502: 1.30e-2),
  3 top-1 differences, 1 disjoint top set, all at image positions; the suffix: rel 1.17e-2, none;
- float64's own vision (whole pipeline): rel 3.74e-2, 3 disjoint top sets over 90 rows (ashhart#1502: 7),
  all at image positions; the suffix: rel 1.30e-2, no top-1 difference (ashhart#1502: 2 disjoint).
…hes file

An optional fifth argument, fp32 [patches][1536] for the picture's grid, replaces the decoded pixels:
a receipt's own patches go through the tower and the decoder unchanged, e.g. ashhart#1502's real-image fixture
made with the pixel normalization before 50b4e0d (patches sha f1c55a0dd28b as its .npy).
…row layout for every backend

vision_spec.zig imports only std and config.zig: vision_config's fields (read from config.json), the 138
vision tower and projector tensors' names, shapes and dtype built from them, and the image rows' Span
arithmetic (overlap, place). vision_spec_test.zig checks them against config_test's VL fixture, a real
export's model.safetensors.index.json when TF_LFM3_VL_DIR names one, and planted renames.
…ws with it

xpu_vision.zig takes its widths from vision_spec's lfm3_vl_2b and loads each tensor by
spec.tensor (name, shape and bf16 bytes; the attention layers by spec mixers, 138 by spec.count),
without a prefix buffer of its own. Engine.pictures packs a prompt's pictures with spec.place
(the over-budget and too-many checks included), and the prompt windows copy image rows by
spec.overlap. The tower's rows, the image-context logits and the served replies are bit-identical
to before.
…olled full-tile P.V, opt-in

Lane l holds dims 4l to 4l + 3 of each row's q, shuffled to every lane for its scores: no SLM, no spill on
tgllp. Offsets are 32-bit from the kernel arguments; a full tile's 16 keys unroll so each shuffle is a register
region. Every row's ops and order stay vit_attn's (bit-equal by construction).
…it_attn

Each against float64 and planted controls (a causal mask, KV head h % 8, and the new one: q's rows read
transposed), and each candidate's bytes against vit_attn's.
… 1.7x on Iris Xe

The tower launches vit_attn_qr (q in registers, no SLM, no spill on tgllp's IGC 1.0.15468) at local
size 128 in place of vit_attn; grid and arguments are unchanged. Each row's ops and order are
vit_attn's, and lfm3_vit checks the bytes on the device. On Iris Xe at 1,156 / 1,400 / 8,580 patches
it runs at 360 / 372 / 313 GFLOPS against 207 / 209 / 183, and every tower layer's rows and the
image rows of the receipt, the A4 page, the vaccination photo and the fixture are byte-equal.
@jschmied

jschmied commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

The head moved to a97fbcf90, on #1696's 5943daef0. #1696 now carries the fix that makes -Dxpu build again after main's rope_freq change, so the head I named earlier, d42190dc7, didn't build with -Dxpu. @Mirrdhyn, sorry about that: please use a97fbcf90, the current lfm3-vl-xpu.

Iris Xe (Gen12LP, WSL2), on d42190dc7 with #1696's four-line fix, against the previous head c96e5a4f4:

  • tf-xpu-lfm3_q32-test, _vit-test, _tower-test, _vl-test and _vl_logits-test pass. The tower's fixture rows and the cat's logits are byte-equal.
  • The VL text decoder's tokens are unchanged, and the served replies equal c96e5a4f4's: the receipt's reply still starts with '1'. Cache resume and --parallel 2 pass.
  • The SPIR-V images are byte-equal to c96e5a4f4, 23 of 23, and the Nemotron images to main f23603801.
  • Not run: Nemotron's tokens. The laptop's 16 GB can't hold Nemotron, which is one more reason the Arc run would help.

Spark (GB10), a97fbcf90: lean 0 new; zig build test, the CI set and git diff --check pass.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied. The rebase and the Iris Xe cells answer our points. This still waits on #1696, and on #1502 for the two carried files, which now conflicts with main. After those land, please rebase. The one cell still open is Nemotron's tokens against main on an Intel GPU. We don't have one, so @Mirrdhyn's Arc run, or any Intel GPU that can hold Nemotron, would close it.

@Mirrdhyn

Mirrdhyn commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@jschmied @ashhart Arc Pro B70 run of a97fbcf90 against main f23603801 (its merge base), default -Dxpu-device=bmg, Linux 7.0.0 (xe), compute-runtime 26.35.39758.10, Zig 0.17.0.

1. Nothing existing moves

  • zig build -Dxpu xpu-spirv on both: the 15 images of main are byte-equal on the PR head (attn, basic, glue, mamba, moe, nem_attn_dec, nem_prow, nem_prow_gen12, nem_rows, nem_rows_r1/r2/r4/r8, qmv4, vadd); the head adds 8 (attn_g32, lfm3, lfm3_mv, lfm3_mv_g32, lfm3_q8, lfm3_vit, nem_prow_g32, nem_prow_gen12_g32), 23 in all.
  • Nemotron 3.5 Lightning 30B-A3B MLX 4-bit through xpu-native serve --context 8192, greedy, thinking off, MTP drafts on, 3 prompts (prose, code, French), up to 128 tokens: same token_sha on main and on the head.
prompt tokens main head
prose 87 b4fe93ce5f7d b4fe93ce5f7d
code 128 1185585ab316 1185585ab316
French 60 79bed5f25940 79bed5f25940

Decode 147–180 tok/s on both, device memory 19.19 GB on both.

2. The new kernels (zig build -Dxpu xpu-tests)

  • tf-xpu-lfm3_q32-test: ok. 6 checks within tolerance of float64, 4 planted controls fail, pdense g32 bitwise identical to qmv4_bf at n = 1, 3, 8, 16 (in 2048 and 7680).
  • tf-xpu-lfm3_vit-test: ok. 17 checks within 1e-5 (attention over 1,156 patches 1.56e-6), 26 planted controls fail, vit_attn_q4 and vit_attn_qr byte-equal to vit_attn at 280 and 1,156 patches.
  • tf-xpu-lfm3_rows-test: ok. 28 of 28 bitwise identical.
  • Both q32 and rows print warning: pdense spills 2688 bytes of registers on Xe2.

3. One picture through the server
LiquidAI/LFM3-VL-2B-MLX-4bit, --vision --vision-image-tokens 4096 --context 8192, greedy, thinking off, a synthetic 450×800 PNG receipt, "List the items on this receipt with their prices, then the total.":

Items:
- Pain complet: 2,40
- Lait demi-ecreme: 1,15
- Pommes 1 kg: 3,20
- Cafe moulu 250 g: 4,90
- Beurre doux: 2,75
Total: 14,40

Every item and the total are read correctly. 377 prompt tokens, 69 generated, TTFT 0.26 s, 252 tok/s. The same request twice gives the same token_sha (cb84c1d10777), the second resuming 372 tokens from the prompt cache (TTFT 0.04 s). Device memory 3.14 GB (peak 3.15).

@jschmied

jschmied commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Thank you @Mirrdhyn, that's exactly the cell that was open. On the Arc Pro B70, Nemotron 3.5 Lightning gives the same token_sha on main and the head for all three prompts, the 15 existing SPIR-V images are byte-equal, and the new kernels' tests pass with their planted controls failing. The receipt also reads correctly through the server and resumes from the prompt cache.

@ashhart, that covers Nemotron against main on an Intel GPU. The pdense spills 2688 bytes of registers warning on Xe2 is new information. I'll look at it before the rebase. Once #1696 and #1502 land, I'll rebase this PR.

@Mirrdhyn

Mirrdhyn commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@jschmied A heads-up on what we'd like to do on top of this once it lands, so we don't step on your rebase.

On the B70 at a97fbcf90, the synthetic receipt (450×800, tf-xpu-lfm3_vl-test, max 256 image tokens): tower 31 ms, projector 1 ms, text prefill 149 ms, first token 182 ms, decode 254 tok/s. Prefill alone (tf-xpu-lfm3_bench-test, windows of 128) is 2,804 tok/s at 1,024 tokens, about 9 TFLOPS, so on Xe2 the first token is mostly the prompt GEMM without DPAS.

Decode with LFM3_PROF=1 (sync around every launch, 4.9 ms a token): qmv4_bf 26 %, qmv4_swiglu 23 %, lfm3_attn_partial 19 % (83 µs a call), lfm3_post_add_norm 13 % (48 calls a token), the 8-bit head 10 %.

What we'd take, Xe2 only, behind the device check so Gen12LP keeps your kernels:

  1. the prompt windows on the nGEN int4 GEMM, with groups of 32 added to the generator;
  2. a decode attention for Xe2;
  3. fusing the residual add with its norm;
  4. pdense without the 2,688-byte spill on Xe2.

Each against your float64 references and with your tests unchanged. We'll prototype locally and open nothing before #1838 is in. Does that split work for you, or is any of it already on your list?

@jschmied

jschmied commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@Mirrdhyn, yes, that split works for us. All four are on Xe2 behind the device check, so Gen12LP keeps its kernels, and they're checked against the float64 references with our tests unchanged. None of them was on our list except the spill, and we found that one's cause:

  • The pdense spill. It's reproduced offline from a97fbcf90's bmg SPIR-V (ocloc 26.35.1, -device bmg-g31 and bmg-g21).
    • Both pdense and pexpert in the DPAS prompt image (nem_prow, nem_prow_g32) spill 2,688 bytes at 128 GRF. The Gen12 images don't spill.
    • It isn't new in this PR: main's nem_prow.cl spills the same. Main's Nemotron loads that image with -cl-intel-256-GRF-per-thread (nemotron/xpu_win.zig:151), while LFM3's three load sites (lfm3/xpu_model.zig:174, lfm3_q32.zig:65, lfm3_rows.zig:32) use the plain module, so only LFM3 warns.
    • With the 256-GRF flag: 0 bytes. With a smaller tile or unroll: still 320 to 1,600.
    • The smallest fix is that same flag at those three sites when the kernels use DPAS. The source is the same and only register allocation changes, so results can't move; each output's sum order doesn't depend on the tiling.
    • The cost is half the threads per EU, which Nemotron already accepts for this kernel.
    • Since you have the B70, it's yours if you'd like it in your series. Otherwise I'll put it into this PR at its rebase. Either way it needs one B70 run: q32 and rows bitwise, plus the prefill timing.
  • The other three (the nGEN int4 prompt GEMM with groups of 32, an Xe2 decode attention, and add+norm fusion) are yours. Nothing of ours touches them.

@Mirrdhyn

Mirrdhyn commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

@jschmied Where we are on the Xe2 side, so the rebase stays easy. All of it sits on a local branch on top of a97fbcf90, behind the Xe2 device check. Nothing is pushed yet. I'll bring it as follow-ups once this PR lands.

pdense: we'll take it. On the B70 at 8 streams, the 256-GRF 8×8 tile gives 475 tok/s. A 4×4 tile at 128 GRF doesn't spill and gives 563 tok/s, with the same sums.

Decode on DPAS by default on Xe2. The g32 decode now runs on DPAS GEMMs. A decode round reads the weights once for every stream: up to 32 rows, shared conv/rope/attention launches, and a shared 8-bit head. Prompts that arrive together, up to 16, share their prefill. LFM3_NG_DECODE=0 keeps the current path.

Served, B70 in a PCIe 5.0 x16 slot, LFM3-VL-2B MLX 4-bit:

  • 1 / 8 / 16 / 32 streams: 298 / 2,138 / 3,908 / 6,246 tok/s (a97fbcf90 decodes about 254 tok/s for one stream)
  • one robot-style request (fresh image + short prompt, prefix cached): first token 37 ms, 94 ms per decision; 32 at once: 33.7 decisions/s

Bits. A stream gives the same bits alone or shared, and a prompt gives the same bits however it's cut. The next token still matches the float64 reference. Nemotron's token_sha and the existing SPIR-V images are unchanged.

Next: more than 32 streams, and register pressure in the decode GEMMs.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @jschmied, and thanks @Mirrdhyn for the B70 run: the same Nemotron tokens on main and the head, and main's images byte-equal, close the last open cell. Nothing more is owed here. Once #1696 and #1502 are in, please rebase and it's ready to land. The Xe2 follow-ups as you've split them, behind the device check, sound good to us.

@jschmied

jschmied commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

@Mirrdhyn thanks, that split works for me, and none of it is on my list. The pdense 4×4 tile, DPAS decode and shared prefill on Xe2 behind the device check stay clear of Gen12LP's kernels, so they won't touch my rebase. I'll rebase this PR once #1696 and #1502 are in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants