Skip to content

lfm3: add native Metal vision support above the text decoder - #1502

Open
BlivionIaG wants to merge 2 commits into
ashhart:mainfrom
BlivionIaG:feature/lfm3-vl-support
Open

BlivionIaG wants to merge 2 commits into
ashhart:mainfrom
BlivionIaG:feature/lfm3-vl-support

Conversation

@BlivionIaG

@BlivionIaG BlivionIaG commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Native Metal image preparation, vision encoder and projector for LFM3-VL-2B from the official MLX 4/6/8-bit checkpoints. Projected image rows feed the LFM3 decoder and the existing lane engine (image order and content handling, prompt caching, verification, snapshots, concurrent serving). Python is used only for development references and clients.

Refs #1491. Stacked on admission #1513 (merged) and text #1514 (vision-only diff: compare). The threaded-reference and GLM work that used to sit under #1514 lives in #1776.

This PR also carries the LFM3 prompt and decode speed work, because the VL prompt and the text prompt share it. The tensor-unit routes are opt-in: lfm3-nax-prompt, lfm3-tiled-attention and 2048-row chunks are off in every build, so a source build and a release serve a checkpoint with the same arithmetic. Every speed number below is for zig build native -Dcpu=apple_m1 -Dlfm3-nax-prompt=true -Dlfm3-tiled-attention=true; a default build keeps the row kernels and is as slow as the text slice (#1514 receipt).

  • lfm3-nax-prompt: every dense prompt projection (attention q/k/v/out, convolution in/out, FFN gate/up/down) runs on the tensor units through tf_qmmw_t_nax_win, Flash Next's tf_qmmw_t_nax tile with scales and biases loaded eight groups at a time (same output bytes, about 4% faster). Weights stay packed (4/6/8-bit, group 32 or 64) and are dequantized to BF16 per 64-wide K step in threadgroup memory, as MLX does; peak memory (text, 8k tokens) is the packed weights plus a width-independent 0.68 GB.
  • lfm3-tiled-attention: prompt segments of 9–2048 rows are one tiled launch (64-query/32-key tiles, FP32 online softmax, probabilities rounded once to BF16 for PV, as MLX does). Decode windows, shared decode rows and prompt tails of at most 8 rows use a key-parallel split kernel (≤64 parts of ≥128 keys, FP32 merge). Both replay through the exact path when a row is nonfinite.
  • Stepped prefill (prefill_step): the backend fills a prompt one chunk at a time and returns to the lane engine between chunks (prefill_step(ptr, stream, max_rows): 0 is the engine's own cut), so the engine's shortest-remaining-first policy and decode-between-chunks apply to LFM3. Cuts do not change state (tf-lfm3-nax-exact), so the backend declares chunk_invariant and --decode-share may cap chunks at 512 rows. A request cancelled between chunks releases its fill.
  • Release-version builds reject the two options. -Dlfm3-nax-prompt=true -Dlfm3-tiled-attention=true selects them; the residual-weight FFN experiment is removed.

Performance against mlx-vlm

One M5 Ultra, same checkpoints and prompts, --prompt-cache-gib 0, medians of 2–3 runs after a warm-up, opt-in build as above. Receipts: cold prefill, serving metrics, decode share.

Cold prefill TTFT, native / mlx-vlm (below 1 is faster):

2k 8k 32k 64k
text 4-bit 0.93 1.04 1.01 1.00
text 6-bit 0.88 1.00 0.99 0.98
text 8-bit 0.92 1.04 1.01 1.00
VL 4-bit 0.90 1.05 1.02 1.00
VL 6-bit 0.86 1.00 0.99 0.98
VL 8-bit 0.88 1.04 1.01 1.00

Serving, text 4-bit, native vs mlx-vlm (default scheduling, no flags):

context native mlx-vlm
decode tok/s, 1 stream short / 2k / 8k 433 / 426 / 381 393 / 334 / 318
ITL ms mean, 1 stream short / 2k / 8k 2.31 / 2.31 / 2.58 2.55 / 2.94 / 3.08
decode tok/s, 4 streams aggregate short / 2k / 8k 1069 / 1062 / 938 1151 / 902 / 753
ITL ms mean, 4 streams short / 2k / 8k 3.92 / 6.19 / 10.79 3.47 / 4.19 / 4.65
ITL ms p95, 4 streams short / 2k / 8k 3.63 / 10.76 / 45.83 3.56 / 4.51 / 5.25
prefill tok/s, 1 stream 2k / 8k 20465 / 22602 21636 / 23405
prefill tok/s, 4 streams aggregate 2k / 8k 19695 / 21941 22834 / 23388
TTFT s, 4 streams per request 2k / 8k 0.257 / 1.054 0.358 / 1.400

Across the six checkpoints: single-stream decode is 1.10–1.14× mlx-vlm at short context, 1.26–1.28× at 2k and 1.17–1.20× at 8k. Four-stream aggregate decode is 1.11–1.18× at 2k and 1.10–1.25× at 8k, and 0.93–1.00× at short context. Four-stream TTFT per request is 0.70–0.73× at 2k and 0.74–0.77× at 8k.

Open against mlx-vlm:

  1. Prefill throughput: single-stream 2k and 8k are 0.95–1.00× and 0.95–0.99× of mlx-vlm's tok/s. With four concurrent prompts the aggregate is 0.84–0.88× at 2k and 0.92–0.96× at 8k, because mlx-vlm batches the prompts and this route fills them one chunk at a time.
  2. Four-stream ITL at 2k and 8k is higher than mlx-vlm's (mean 5.5–6.2 ms against 4.2–4.6 at 2k, 7.9–10.8 against 4.7–5.3 at 8k) and p95 is far higher at 8k (13–46 ms against 5.3–5.6) because finished streams decode between chunks of the others' prompts.
  3. --decode-share closes the ITL gap at a cost. With chunks capped at 512 rows while replies decode (receipt, text 4-bit, four requests, 2k / 8k):
TTFT s / request mean ITL ms p95 ITL ms aggregate decode tok/s aggregate prefill tok/s
native, default 0.257 / 1.055 6.2 / 10.8 10.8 / 45.8 1059 / 936 19686 / 21946
native, --decode-share 0.5 0.467 / 1.246 4.4 / 5.1 22.1 / 26.8 1538 / 1523 10244 / 16030
native, --decode-share 0.75 0.520 / 1.346 3.1 / 3.7 2.7 / 2.6 1302 / 1530 11611 / 16378
mlx-vlm 0.358 / 1.401 4.2 / 4.6 4.5 / 5.2 901 / 755 22829 / 23377

With the flag, mean and p95 ITL and aggregate decode beat mlx-vlm at both lengths; the price is aggregate prefill and 2k TTFT. serve_check --cancel --sampled with --decode-share 0.5 passes 40 checks per text width and 61 per VL width, so the cap changes no tokens.

Fidelity (policy of #1491)

Independent FP32 forward and official MLX BF16 as references. Receipt. No pass rule is applied here; the differences are listed.

  • 237 prompt-boundary rows over 9 sweeps: RMS to FP32 of the native route over official BF16, median 0.99, mean 1.04, 8.9% of rows above 1.5×.
  • Top-1 differences from FP32 in the canonical phases (every one listed in the receipt with margins): VL 6-bit prompt row 0 (FP32 margin 0.046; official BF16 misses it too), and VL 4-bit teacher-forced continuation row 3, which only the native route misses: two tokens tie at the BF16 maximum (margin 0.0) and the first index wins, while FP32 prefers the other by 0.110. The sweeps add the VL 6-bit prompt row at length 90 (same row, BF16 also misses).
  • Prompt rows and decode rows are reported separately in the receipt.

Kernel contract

New kernels are registered in zig/src/core/contract/registry.zig and lfm3_records.zig: tf_qmmw_t_nax_win probes for q4g64, q6g64, q8g64, q4g32 (nonfinite, repeat, shapes, sums; planted bad-bias and bad-nonfinite controls red) and the split attention (full and sliding; bad-tail and bad-nonfinite controls red). The 11 image-tower kernels each have a planted control (metal_lfm3_vision_controls.zig), and so do the 16 base text probes (metal_lfm3_controls.zig, in #1514). Local --only LFM3 on an M4 Pro: SUMMARY 74 kernels: 74 pass gates 1-4, 0 fail, 0 known findings; 52 controls: 52 red as planted, 0 missed. Full M5 Ultra run on this head (tf-kernel-contract --rotary-accuracy rotary.json): SUMMARY 468 kernels: 468 pass gates 1-4, 0 fail, 3 known findings; 420 controls: 420 red as planted, 0 missed; 0 limit problems. The three known findings are two affine_mm infinity classes and the Nemotron relu2 NaN; none is an LFM3 kernel. Receipt.

Checks

zig build test test-golden dev-tools test-lfm3-host -Dcpu=apple_m1 passes; zig fmt --check build.zig zig passes; lean_check.py reports 425 problems, the same as main. Licence: the official LFM3 exports are license: other (lfm1.0); the recipe says so. Macs checked: an M5 Ultra only.

Gates from #1491

Receipts: exactness, serving, image rows, 32k.

  • Seams and chunk boundaries (tf-lfm3-nax-exact): 43 byte-equality cases pass at all six widths on this head: prompts cut at 1 to 2047 rows and at five cut sets equal 2048-row chunks, resuming from a saved prefix equals a fresh run, and verification windows of 2 to 32 rows equal one-row decode. The check found one bug, fixed in this PR: windows of 9 or more rows ran the tiled prompt attention, so they differed from decode (9 of 43 cases failed). Windows now use the decode kernel.
  • Cancellation and concurrency: serve_check.py --cancel --sampled on this head passes 40 checks per text width and 61 per VL width with 0 failures (drafted/plain, concurrent 1/2/4 vs solo, cache reuse, image order and content, cancel then recover). With 31k-token prompts at --context 40000, a request cancelled mid-prefill leaves the server answering the next request in 42–54 ms with the same output as fresh, and a short request sent while three 31k prompts prefill is answered in 0.67–0.74 s (3.3–3.5 s before prefill was stepped) while the three prompts still finish in 4.7–4.9 s.
  • Real-image projected rows: five images (photograph, wide banners, square logo), 70 rows each. Pooled over the images, native RMS to the independent FP64 graph is 0.95× official MLX BF16 at 4-bit and 0.94× at 6 and 8 bits (8-bit shares 6-bit's tower). Native is better on 4 of 5 images at 4-bit and 3 of 5 at 6-bit; the worst image is 1.23× and 1.18×. The canonical input is real-image-fp64-input.png.
  • 32k tier: 32768-token prompt boundary plus 16 teacher-forced rows per checkpoint, six checkpoints, 102 rows. Pooled RMS to FP32 is 0.96× official BF16; 6 rows exceed 1.5×. Top-1 differences: VL 4-bit prompt row (native and official BF16 tie at the BF16 maximum and both miss FP32 by 0.085), and VL 6-bit continuation row 0, native only: tie at the BF16 maximum, FP32 prefers the other token by 0.134 while official BF16 prefers it by 0.125.

Dense GEMM: what was measured and not adopted

Isolated 4-bit prompt GEMM at M=2048 on the M5 Ultra, TFLOPs (the new windowed-scale kernel against the previous one and MLX's qmm4; the outputs of the first two are byte-identical on four shapes):

shape (N x K) tf_qmmw_t_nax_win previous tf_qmmw_t_nax MLX qmm4
7680 x 2048 86.8 83.2 82.0
2048 x 7680 78.8 74.2 81.2
2048 x 2048 83.7 78.5 72.4
6144 x 2048 85.7 82.0 79.2

In the model the dense layers run at the same efficiency as this bench (24 layers; no unexplained gap), and a ring of cold weights shows no loss, so weight-from-DRAM latency is not the limit. Tried and rejected, each measured against the same baseline: 64x128 and BM 128/192/256 tiles (no better, or register spills); the SWZ swizzle (neutral once scales are windowed); a 128-deep K step (+4% on wide shapes, -7% on square ones); register prefetch of four weight steps (-2 to -7%); threadgroup staging of weights (-2 to -14%); a fused gate+up+SwiGLU GEMM (13% slower than two GEMMs); fusing SwiGLU and the paired q/gate projection into the decode dispatches (+1.1 to +1.6% on one stream, -1.6 to -2.0% on four; receipt).

Open

The prefill and four-stream ITL gaps against mlx-vlm described above. Fusing the FFN SwiGLU and the attention query/gate projections into one dispatch each (26 fewer of about 250 dispatches per decoded token) gained 1–2% on one stream and lost 2% on four; not adopted (receipt). The pass rule for making the tensor-unit route the default is on #1491.

@ashhart

ashhart commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG for a careful port and an honest receipt. The same-engine checks are thorough (231 served, with cancellation and recovery), and the rotary fix comes with a test that fails on the old formula.

The merge with main is clean. Here is our answer on the gates.

Fidelity:

  • Compare teacher-forced against an FP64 forward of the dequantized checkpoint. MLX is a second opinion, not the reference.
  • Compare prompt rows and one-row decode rows separately, on identical input rows, at 4, 6 and 8 bits.
  • List every top-1 difference with both tokens and both margins, and report the worst rows, not only an average.
  • A difference passes only as a near-tie that a reference rounding where the engine rounds reproduces, as lfm3: serve LFM3-1.7B on CUDA: NVFP4 and FP8 weight-only, MLX 4-bit (stacked on #1513, #1492 and #1503) #1508 does for the CUDA side. The 18 rows with disjoint top sets need tracing first.
  • For VL: the projected rows of real images against FP64, then teacher-forced text after them.

Performance, on the same Mac and checkpoint against mlx-lm (mlx-vlm for VL), at each width:

  • one-stream decode and the 4-stream aggregate, with outputs of equal length;
  • cold prefill at 2k, 8k, 32k and 64k. Decode should be at least the standard server's. Cold prefill should take the prompt tiles, as below; a remaining prefill gap is fine to track as its own issue, as we do for the 27B.

Before this leaves draft:

  1. Prompt rows go through the row kernels and the decode attention (zig/src/families/lfm3/forward.zig:39-49, :196-216). That is why cold prefill is 7 to 18 times slower than mlx-lm's. LFM2's Metal family takes tile_qmm's prompt tiles with a decode twin of the same bits (zig/src/families/lfm2_moe/forward.zig:30), and the tensor-unit prompt attention on an M5 (:244). Please build on those, and on LFM2: shared-round decode #1377's shared rounds for the 4-stream aggregate.
  2. Please split the PR: the admission and the text decoder first, with VL stacked on them. lfm3: serve LFM3-1.7B on CUDA: NVFP4 and FP8 weight-only, MLX 4-bit (stacked on #1513, #1492 and #1503) #1508 already builds on the admission.
  3. Please state the weights' licence, and the Macs it was checked on, in the recipe.

Once the gates hold, we'll run the full Metal contract and the served checks on our M5 Ultra.

@jschmied

Copy link
Copy Markdown
Contributor

@BlivionIaG, one small pixel-parity note on families/lfm3/image.zig's patchify, from the LFM3-VL processor work on CUDA.

transformers' Lfm3VlImageProcessor (5.19, torchvision backend) folds the rescale into the normalization. It computes (x - mean / rescale) / (std / rescale) in float32, which for LFM3 is (x - 127.5) / 127.5. The lookup table here computes x * f32(1/255) first and then (v - 0.5) / 0.5. The two disagree on 207 of the 256 entries, by at most 1.2e-7 (one ulp near ±1). So the patches can't be bit-equal to the processor's pixel_values.

The resize (.torch) and the geometry already match. Fixing it is one line in the table:

value.* = (@as(f32, @floatFromInt(i)) - 127.5) / 127.5;

With that, the processor's output should be reproduced bit for bit.

@cwschroeder

Copy link
Copy Markdown
Contributor

The Linux CI failure at 484a96f68c26befe7ae0a8755ce2a680b9afda27 is in test_every_declared_option_is_accepted. It passes -Dlfm3-prompt-rows=value, but build.zig:83 declares that option as u32 and accepts only 128, 256 or 2048. tools/zig/build_options_test.py has no entry for it in VALUES, so it falls back to the string placeholder.

Adding "lfm3-prompt-rows": "128" to that test mapping should address this argument mismatch; the full test still needs rerunning. This is from the source and CI log, not a local reproduction.

jschmied added a commit to jschmied/TensorFold that referenced this pull request Oct 31, 2026
's image tests from a build file of ours

C11 put test-lfm3-image-host and a sys import into zig/build/lfm3_host.zig, a file ashhart#1502 edits too.
That file is back to its version below C11, so ashhart#1502's own edit of it applies. zig/build/lfm3_xpu_host.zig
declares the step instead, and only while no lfm3_host.zig declares it; build/sys.zig's walk gives the
test root its sys import, so none is named here.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Oct 31, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Oct 31, 2026
…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).
@cwschroeder

Copy link
Copy Markdown
Contributor

@BlivionIaG, the macOS dev-tools job on 493311113f86169ddf9f167260e4e39e8ae3de67 now fails compiling tf-lfm3-deferred-head: zig/src/families/lfm3/backend.zig:104 assigns prefillStepFn with two parameters to a callback requiring three (*anyopaque, *stream.Stream, u32). The stepped-prefill callback needs to match that interface, then the dev-tools build needs rerunning.

This is from the CI compiler log, without a local build. The other failed jobs still report sources.zig:232's trailing blank line and the new over-600-line finding in zig/tests/contract/metal_main.zig.

@ashhart

ashhart commented Nov 1, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG. The new description is what we asked for, and stepped prefill is a real gain: a short request
answered in under 0.8 s while three 31k prompts fill, with tf-lfm3-nax-exact covering the cut points. Thanks also
for taking @cwschroeder's build-options fix. Three things:

  1. The macOS dev-tools build still fails as @cwschroeder found: prefillStepFn in families/lfm3/backend.zig
    takes (ptr, stream), and main's prefill_step callback also passes max_rows. zig/kernels/metal/sources.zig
    also still ends with a blank line.
  2. One default for source builds and releases. build.zig turns the NAX prompt, the tiled attention and
    2,048-row chunks on in unversioned builds and refuses them in releases. Then a source build and a release serve
    the same checkpoint with different arithmetic (the tiled attention rounds probabilities to bf16). Could all three
    default off everywhere for now? Once the tiled route meets the Add native Metal support for LFM3-1.7B and LFM3-VL-2B MLX 4/6/8-bit checkpoints #1491 bar, it can become the default for releases
    too, in its own change.
  3. Planted controls for the vision kernels in metal_lfm3_vision.zig (all 11 kinds), as for the base text
    kernels in lfm3: serve the MLX text decoder on Metal #1514.

On fidelity, we'll judge the two rows only the native route misses (VL 4-bit row 3, and VL 6-bit at 32K) against
the bar on #1491. This lands after #1514, squashed, and then we'll run the VL cells on our M5 Ultra and M3 Ultra.

@jschmied

jschmied commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

@BlivionIaG, a small request for a like-for-like check. I'm porting LFM3-VL's vision path to Intel's XPU backend, taking image.zig and your receipts' tools unchanged (fp64_reference.py, vision_fp64_reference.py, compare_logits.py at 484a96f68).

Could you attach the canonical PNG from docs/receipts/lfm3/real-image-fp64.json (sha256 005500792ee8…, the sips conversion of the placecats JPEG) to this thread? placecats.com/300/200 now serves a different picture (12cd0df3…, not d1b324f5…), so the image-context logits can't be rerun on your input. With the PNG, run (a), same projected rows into float64, and run (b), float64's own vision, become directly comparable to your 1.30e-2 and 2.22e-2.

No change to #1502 is needed for this.

@BlivionIaG

BlivionIaG commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. The three options are off in every build in this PR (pushing now), and the speed numbers will be labelled as the opt-in build. For your call on the two rows, here is what the receipts say about the tiled route against the two references on #1491 (independent FP32 forward, official MLX BF16). No pass rule is applied; every difference is listed in the receipts.

RMS to FP32, native route over official BF16 (below 1 is closer to FP32):

  • 237 prompt-boundary rows over 9 sweeps: median 0.99, mean 1.04, 8.9% of rows above 1.5x (route fidelity).
  • Real-image projected rows, five images, pooled: 0.95x at 4-bit, 0.94x at 6 and 8 bits. Native is closer on 4 of 5 images at 4-bit and 3 of 5 at 6-bit; the worst image is 1.23x and 1.18x (image rows).
  • 32k tier, six checkpoints, 102 rows: 0.96x pooled, 6 rows above 1.5x (32k).

The two rows only the native route misses:

  • VL 4-bit teacher-forced continuation row 3: two tokens tie at the BF16 maximum (margin 0.0) and the first index wins; FP32 prefers the other token by 0.110.
  • VL 6-bit at 32K, continuation row 0: the same kind of tie at the BF16 maximum; FP32 prefers the other token by 0.134 while official BF16 prefers the FP32 token by 0.125.

A tie at the BF16 maximum is decided by index, not by the arithmetic. The receipts also list the rows both routes miss, for example the VL 4-bit 32K prompt row where native and official BF16 tie and both miss FP32 by 0.085.

@BlivionIaG

Copy link
Copy Markdown
Contributor Author

@jschmied here is the canonical PNG, committed at docs/receipts/lfm3/real-image-fp64-input.png: 300x200 RGB, sha256 005500792ee8ea6f35ac369d900fc788cd77667f145e7100c2cff57fae5355d9, the value real-image-fp64.json records as canonical_png_sha256. It is the sips conversion of the placecats JPEG that the receipt's native and float64 runs read, so (a) and (b) can be rerun on the same input.

jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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).
@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. All three are done on this head.

  1. macOS dev-tools. prefillStepFn now takes (ptr, stream, max_rows): 0 means the engine's own cut, otherwise the chunk is at most max_rows. The backend declares chunk_invariant (cut-invariance is what tf-lfm3-nax-exact proves), and the lane host receives --decode-share. sources.zig no longer ends with a blank line, and metal_main.zig is 458 lines (the LFM3 probes register through metal_lfm3_suite.zig). zig build test test-golden dev-tools test-lfm3-host passes locally.
  2. One default. lfm3-nax-prompt, lfm3-tiled-attention and 2048-row chunks default off in every build. The recipe now calls them opt-in, and the PR body labels the speed numbers as the opt-in build (-Dlfm3-nax-prompt=true -Dlfm3-tiled-attention=true).
  3. Planted controls for the vision kernels. All 11 kinds in metal_lfm3_vision.zig have one (metal_lfm3_vision_controls.zig), each red at its named gate with the real probe still passing: --only "LFM3 vision" reports 11 kernels pass gates 1-4 and 11 controls red as planted, 0 missed.

With chunks capped at 512 rows the server's tokens are unchanged (serve_check --cancel --sampled with --decode-share 0.5: 40/40 per text width and 61/61 per VL width). Four concurrent text 4-bit requests, native with and without the flag against mlx-vlm (receipt):

TTFT s / request, 2k / 8k mean ITL ms, 2k / 8k p95 ITL ms, 2k / 8k aggregate decode tok/s, 2k / 8k
native, default 0.257 / 1.055 6.2 / 10.8 10.8 / 45.8 1059 / 936
native, --decode-share 0.75 0.520 / 1.346 3.1 / 3.7 2.7 / 2.6 1302 / 1530
mlx-vlm 0.358 / 1.401 4.2 / 4.6 4.5 / 5.2 901 / 755

The flag trades prompt throughput for inter-token latency (aggregate prefill 11.6k and 16.4k tok/s at 2k and 8k, against 22.8k and 23.4k for mlx-vlm). Without it, stepped prefill leaves p95 ITL at 45.8 ms at 8k, which is the cost of finishing prompts first.

@jschmied

jschmied commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

Thanks @BlivionIaG, the PNG was exactly what was needed. Here is the rerun of real-image-fp64.json on the Intel XPU port (Iris Xe). The decoder is unchanged, the vision tower and projector keep fp32 between kernels, and only the final rows are rounded to bf16. Your three tools ran unchanged at 484a96f68.

The receipt's patches_sha256 (f1c55a0dd28b…) comes from the normalization before 50b4e0d. So I fed those exact patches to both sides, and the prompt's token ids equal 54c970db8c6b…:

XPU this PR
(a) same projected rows, all 90 rows, rel 1.33e-2 1.30e-2
(b) float64's own vision, all 90 rows rel 2.09e-2, 1 disjoint 2.22e-2, 7 disjoint
(b), the 16-token suffix 0 disjoint 2 disjoint

There is no top-1 difference at a text row in either run. They are all at image positions. With the current, corrected patches (9d39c11551f1…, your corrected-pixel-vision.json input), (a) is 1.49e-2 and (b) is 2.01e-2, again with 0 disjoint in the suffix.

So real-image-fp64.json predates the pixel fix. That changes nothing here, but a regenerated receipt would let the next port compare against today's patches directly. Your call; no change to #1502 is needed for ours.

@BlivionIaG
BlivionIaG marked this pull request as ready for review November 1, 2026 11:05
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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).
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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).
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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).
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 1, 2026
…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).
@BlivionIaG
BlivionIaG force-pushed the feature/lfm3-vl-support branch from b32835e to f965114 Compare November 1, 2026 16:41
@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Rebuilt on the new #1514 commit (fd281ee8f): this branch is now that commit plus one commit with the vision, tensor-unit and stepped-prefill work, over today's main. The tree is byte-identical to the previous head.

@BlivionIaG

Copy link
Copy Markdown
Contributor Author

@jschmied thank you for running it on the XPU port; the (a) and (b) rows line up with ours. You are right that real-image-fp64.json predates the pixel fix. Its input is the PNG now in the tree and its patches were produced before the fused normalization; the comparison on the corrected patches is in corrected-pixel-vision.json, so I am leaving the old receipt as the record of the earlier run rather than regenerating it without a new reason.

@BlivionIaG
BlivionIaG force-pushed the feature/lfm3-vl-support branch from f965114 to 88dd7e6 Compare November 2, 2026 17:44
@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Rebuilt on the rebased #1514 commit over main 933b969fa. Two conflicts, both additive: metal_limits.zig (kept both blocks) and the --decode-share paragraph in docs/api.md (main's new TENSORFOLD_PREFILL_ORDER text plus the LFM3 wording). zig fmt and lean_check (424) are clean; the native, kernel-contract and test-lfm3-host builds pass.

@ashhart

ashhart commented Nov 2, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG: all three asks from our 1 Nov review are answered in the 10:34 push (prefillStepFn passes max_rows, sources.zig tidied, metal_main.zig at 483 lines, and the NAX prompt, tiled attention and 2,048-row chunk points). Nothing more is owed from you. It lands squashed after #1514, once our M5 Ultra and M3 Ultra VL cells are in.

jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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).
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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).
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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.
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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).
jschmied added a commit to jschmied/TensorFold that referenced this pull request Nov 3, 2026
…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).
@BlivionIaG
BlivionIaG force-pushed the feature/lfm3-vl-support branch from 88dd7e6 to cdc606b Compare November 3, 2026 19:49
@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Rebased onto the new #1514 commit over main 37c224142 (the same one-file conflict and config_test change as there; the vision and speed commit applied cleanly). zig build test test-golden dev-tools test-lfm3-host passes, the opt-in build (-Dlfm3-nax-prompt=true -Dlfm3-tiled-attention=true) of native, tf-lfm3-nax-exact and the contract tool builds, zig fmt is clean and lean_check.py reports 424 problems.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @BlivionIaG. The rebuild onto the new #1514 commit looks right, with the vision and speed commit applying cleanly. Nothing more is needed from you. As before, it lands squashed after #1514, once our M5 Ultra and M3 Ultra VL runs are in. On the M5 Ultra, those include tf-lfm3-nax-exact on the opt-in build, so the window fix is covered on our side too.

…ation, snapshots and contract probes with planted controls

Serves the official MLX 4-, 6- and 8-bit LFM3-1.7B checkpoints through the native lane engine. LFM3-VL checkpoints are refused here; vision and the tensor-unit prompt route stack on this change. Refs ashhart#1491.
…and stepped prefill

Adds the native image tower and projector for LFM3-VL-2B, the opt-in tensor-unit prompt route (tf_qmmw_t_nax_win), the tiled prompt attention and key-parallel decode attention, and stepped prefill that takes the lane host's row cap. The tensor-unit options are off in every build. Refs ashhart#1491.
@BlivionIaG
BlivionIaG force-pushed the feature/lfm3-vl-support branch from cdc606b to e1e8247 Compare November 4, 2026 09:56
@BlivionIaG

Copy link
Copy Markdown
Contributor Author

Rebuilt on the new #1514 commit over main 7eae7dc00; the vision and speed commit applied cleanly. zig build test test-golden dev-tools test-lfm3-host passes, the opt-in build (-Dlfm3-nax-prompt=true -Dlfm3-tiled-attention=true) of native, tf-lfm3-nax-exact and the contract tool builds, zig fmt is clean, and lean_check.py reports 425 problems, the same as main.

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.

4 participants