Skip to content

Qwen3.5/3.8 on CUDA: image input on #836's engine (2B, 27B; on #836 and #853) - #885

Open
BobClawblaw wants to merge 41 commits into
ashhart:mainfrom
BobClawblaw:qwen35-vision
Open

BobClawblaw wants to merge 41 commits into
ashhart:mainfrom
BobClawblaw:qwen35-vision

Conversation

@BobClawblaw

@BobClawblaw BobClawblaw commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Image input on #836's Qwen CUDA engine

Asked for on #726 and #856: the Qwen checkpoints read images, so the family does too. This brings #726's vision tower and M-RoPE onto #836's engine. The prompt cache and the fp8 KV cache are not part of this; they come as their own changes.

Stacked on #836 and #853. #853 supplies the native server's image parts: Request.images, vision_inputs.zig and qwen_image. Its decoder refuses images until the shared libjpeg-turbo decoder lands, and this PR does not change that. The first commit after them merges #853 into #836, resolving conflicts in build.zig, zig/build/cuda.zig and native/cuda.zig: #836's drafter wrapper, plus #853's follow, setStreams/setRoom, vision and admit. Review the last three commits:

  1. qwen3_5: image rows and M-RoPE positions in the CUDA forward
    • A prompt chunk can carry an image prompt's rows (Part.images). After the embedding, tf_q35_splice replaces the placeholder rows with the tower's features.
    • tf_q35_rope turns each row by its t, h and w positions (interleaved sections 11, 11, 10), read from a staged table.
    • Rows after the prompt rotate at their position plus the stream's rope_delta, carried in Seg.delta. Decode, verify windows and trees therefore keep their captured graphs.
    • Text rows rotate at one position on all three axes, which is the same arithmetic as before.
  2. qwen3_5: the vision tower on CUDA, and --vision on the Qwen engine
    • Qwen3.8-27B on CUDA: image input and --kv-cache fp8 (256K with vision on a 5090) #726's tower and kernels (qwen_vision.cu, Linears on cuBLASLt). Width, heads, depth and MLP size are read from vision_config, so the 2B's tower (24 blocks of 1024, heads of 64) runs as well as the 27B's and the 35B-A3B's.
    • The weights and the scratch of one 16,384-patch call are a single allocation made at startup and counted as model memory.
    • Video parts are refused by name.
  3. tf-qwen35-cuda: vision and mrope checks

Checks (RTX 5090, sm_120, Linux)

Text keeps its bits. Qwen3.8-27B, 96 ids after a 300-token prefix with a second stream beside it. step, window and tree logits are byte-identical to the build without this change. mrope sends a 300-token prompt through the image table with text positions; its logits equal prompt's byte for byte, from position 0 and from 77. As a control, TF_Q35_SHIFT=1 (the h axis moved by one) changes 135,331 of 248,320 logits, which shows the table is read.

Tower against 0.6.6 (tools/qwen_vision_fixtures.py from the qwen38-27b-vision recipe, 9 images):

checkpoint row cosine, mean row cosine, min
Qwen3.8-27B 0.9997–0.99998 0.981
Qwen3.6-35B-A3B 0.9985–0.9999 0.942
Qwen3.5-2B 0.979–0.99998 0.378

Served (27B + DFlash2, --parallel 2 --context 32768 --vision; the native decoder is stb in a local test build only):

  • The five image chats from the 5090 recipe: prompt token counts equal 0.6.6's in all five. The replies are correct and read "ORANGE 7351", and the follow-up is word for word. Replies track 0.6.6 about as far as Qwen3.8-27B on CUDA: image input and --kv-cache fp8 (256K with vision on a 5090) #726's did before parting at a near-tie.
  • 25 of 25 exactness cells with image prompts:
    • drafted = "draft": false, 5 of 5. The undrafted runs log accepted=0/0, so drafting was really off.
    • repeated = first, which covers resume: the turn is re-read, since this engine has no kept states yet.
    • concurrent = alone, 10 cells: image beside image, image beside text, and the same image twice.
    • seeded sampling at T 0.7: repeat = first, and undrafted = drafted.
    • a second turn after an image reply: again = first.
  • 2B, served: it reads all four images correctly, and 25 of 25 cells pass. It has no drafter, so its drafted cells compare undrafted with undrafted.

Host: zig build test 284/289 (5 skipped); test-cuda-host passes, including the new placeholder, layout and config tests. zig fmt is clean, and the lean check reports 0 new problems.

Not run: Windows, a GB10, the 35B-A3B served.

BobClawblaw and others added 30 commits October 14, 2026 17:04
- zig/src/platform: `sys`, imported by every module (zig/build/sys.zig wires
  it). On POSIX it is std.c, std.posix and std.DynLib unchanged; on Windows
  win_c.zig gives libc's surface over the UCRT and Win32 (integer
  descriptors, directory descriptors, readdir, pread/pwrite, high-resolution
  sleep, monotonic clock, /tmp and /proc/self/exe mapped), win_posix.zig the
  server's sockets over Winsock, console events as SIGINT/SIGTERM, and
  LoadLibraryW for DynLib.
- std.c and std.DynLib calls go through `platform` (mechanical).
- build.zig routes Windows targets to the CUDA build.
- Server: socket closes and writes are explicit, the key file's mode check
  is skipped on NTFS (ACLs), descriptor limits are a no-op.
- shard_edit: the shard's map is released before the rename, since Windows
  refuses to replace a mapped file.
- Tests that need symlink privilege or mode bits skip on Windows.

zig build test-cuda-host passes on x86_64-windows.
- Kernel fatbins build with nvcc and cl.exe: MSVC's conforming
  preprocessor is passed on Windows hosts (CUDA 13's headers need it),
  and explicit kernel instantiations carry the __restrict__ qualifiers
  their templates have (MSVC counts them in the function type; the
  generated code is unchanged).
- Nemotron's checkpoint source keeps io_uring to Linux; elsewhere its
  existing thread reads run.
- pull_exlr8 raises no descriptor limit on Windows.
- modelopt test: the lane's slice partials are allocated. GPUs without
  clusters (before sm_90) reduce split K through them; the test passed
  none, so it wrote through a null pointer on Ampere and Ada.

zig build native and install build with -Dnvcc=... -Dsm=89, and
tf-cuda-test's synthetic suites pass on an RTX 4090.
- pull: where Windows refuses the snapshot's symlink, the snapshot path
  becomes a hard link to its blob (CreateHardLinkW through `sys.c.link`):
  no extra space, and learning already refuses linked shards. Link
  failures are logged with their reason.
- HOME falls back to USERPROFILE (Windows sets no HOME) for the hub cache
  and every libc getenv("HOME").
- Tests read optional fixture variables through `platform.getenv`;
  std.process.Environ.getPosix does not build on Windows.
- The learned-state symlink tests skip on Windows (symlinks need
  privilege); `sys.c.symlink` asks for an unprivileged one.

zig build test passes on x86_64-windows.
- docs/WINDOWS.md: requirements, build, run, how the port works and how it
  differs from Linux; the README points to it.
- -Dstrip reaches tensorfold-native before its install step is made, so
  the step no longer expects a .pdb the stripped build does not write.
- zig fmt on the platform files.
8080 and 8000 are taken by too many other servers; serve listens on 8888
unless --port says otherwise, and the docs and bench scripts follow.
The MLX affine 4-bit checkpoint on the native CUDA server, beside Nemotron:
qwen35.cu holds the Metal engine's glue at its bf16 rounding points (gated
delta rule, causal convolution, gated attention), cuda.qlinear's affine-4
kernels do the projections, sample.cu draws on the device.

Windows of up to 16 rows record each row's recurrence and convolution tail,
so the lane core shares rounds across streams and keeps partial copies; a
window's rows equal one-row steps bit for bit. draw.zig is the host draw
both backends now share.

On an RTX 4090: 210 tok/s for one stream, 1250 tok/s across eight; logits
agree with an fp32 reference at every position of a 36-token check.
…mory

The 2B's CUDA engine now reads its widths from config.json (cuda_geometry.zig):
linear value heads over fewer key heads, more query heads a key/value head,
an untied head, an A_log stored as bf16. The 27B's logits agree with an fp32
reference at every position of a 36-token check, and its windows equal
one-row steps bit for bit; the 2B's output is unchanged.

Two Windows driver costs shaped the rest. Each kernel launch grew with the
process's allocation count (1.2 ms a launch over 1,900 weight buffers), so a
model's weights and scratch now live in one arena (cuda_weights.zig packs
each projection through a reused staging buffer: the 27B loads in 25 s, not
200). And a process's first forwards through a buffer stall for seconds, so
the engine warms every request path at load and the backend keeps a pool of
stream caches that `reserve` allocates and runs a row through once the
server knows its stream count.

On an RTX 4090: the 27B decodes at 37 tok/s in the runner, 31 to 35 tok/s
served, 210 tok/s across eight streams; the 2B stays at 210 and 1,250.
A forward's launches (the embedding to the kept rows' logits) are captured
the first time a shape (rows, windows, recording) is seen and replayed after;
the attention chunk count moves into the per-forward table so a graph stays
valid as caches grow. Prompt chunks stay uncaptured: their GEMMs dwarf the
launches, and each length would be a graph of its own.

A new graph's first run pays the Windows driver's stall (about a second on a
wide model), so `reserve` also captures the shared rounds' shapes, a row of
each of 1 to n streams, at load.

Bit for bit the same logits; on an RTX 4090 the 2B steps in 2.8 ms instead of
5.3 (served: 324 tok/s, 1,560 across eight streams) and the 27B in 23.8 ms
instead of 26.9 (served: 40 tok/s, 243 across eight).
--drafter loads z-lab's DFlash2 draft model behind the lane core's Drafted
wrapper: the target writes each row's residual after the drafter's five
tapped layers into a ring a stream, the drafter folds those rows into a
context of keys and values, and after every verify it drafts a block of
seven tokens past the pending one (the target's own embedding and head at
its ends, a codebook selector tracing one path through each row's best
sixteen). Its projections are quantized to affine-4 on the host at load and
run through the target's kernels; its glue is qwen35_draft.cu.

The Windows driver stalls a buffer's first kernel and a new graph's first
run for seconds, so the drafter pools its contexts and a captured forward
now serves every verify shape: its launches cover a bucket of rows and
every window, and the kernels read the true counts from the device.

On an RTX 4090 the 27B serves 52 to 58 tok/s greedy with drafts against 40
plain, about 100 tok/s on long replies, the same tokens either way; plain
decoding keeps its 24 ms step. The 2B is unchanged.
A verify window's rows may form a tree (each row's parent given): the
convolution taps, the delta rule and attention follow each row's own
branch, and the DFlash2 drafter fills the lane core's planned lanes
(each lane its parent's rank-r choice by the codebook selector), so a
lone stream verifies up to 12 lanes a round and the planner learns
which shapes land.

A verify no longer snapshots every row's state (1.3 GB shared, which
capped the 27B at 8 rows): each stream logs its rows' decay, write and
key, and keep replays the kept path onto the recurrence in one pass
(and moves a tree's kept keys down), so any window width fits. One-row
steps and prompts update in place as before. The lane host times the
target's windows at load, which the depth rule had lacked (it had been
capping chains at 3 drafts).

The runner's new tree mode verifies tree windows with junk branches
against step logits (bit-exact on both models).
What the port serves beyond Nemotron: Qwen3.5-2B and Qwen3.8-27B with
its DFlash2 drafter, the speeds measured on an RTX 4090, the load time
and why, the timing and chain-only switches, and what is not there.
The lean rule (tools/zig/lean_check.py) counts a comment that runs on
to a second line; the Windows port's new files had a hundred such runs.
Each is one line of at most 120 columns now, and the checker reports
the same 539 problems as main.
direct_io opened files with O_DIRECT on Linux alone. On Windows the
same open now asks for FILE_FLAG_NO_BUFFERING (which win_c maps O_DIRECT
to), and the 4 KiB-aligned spans it already reads satisfy the sector
alignment that needs; a file system that refuses it still falls back to
buffered reads. The direct-read test runs on Windows too, checking the
executable's bytes against buffered reads of it.
SIG.IGN is a function pointer made from 1, as std.posix has it. aarch64
requires function pointers to be aligned unless the type says align(1),
so the cross-build for aarch64-windows refused it. With align(1), as
std's own handler type has, tensorfold-native cross-compiles for
aarch64-windows with the GB10 (sm_121) fatbins; no hardware has run it.
The Windows page opens with what the build is, what it is tested on and
that it cross-compiles for aarch64-windows; the README's model table
and the CUDA paragraph say Windows where they said unofficial port.
The no-GPU workflow runs zig build test on windows-latest beside
ubuntu-latest, so the platform layer is built and tested on every pull
request.
Every other module calls libc through `sys`; the drafter's four getenv
calls were the last direct std.c calls in a file the Windows build
compiles.
After a prompt the lane core hands the drafter every prompt row's
features; the CUDA backend refused any request wider than its tap ring
(2,176 rows), so a 27B request with --drafter and a prompt past that
ended with RowsNotHeld, where the same prompt without the drafter ran.
The ring keeps the newest tap_rows rows, which hold the drafter's whole
context window, and absorb already reads only that window of what it is
handed; the backend now refuses only rows not yet written or all gone.

On an RTX 4090 the 27B with its drafter takes a 6,041-token prompt in
7.4 s, and its reply equals the one with "draft": false.
… attention cuts chunks at logical positions

- rowanc held a window's rows as bits of a u32, but prompt chunks run up to
  128 rows: rows from 32 on rotated at the wrong positions and attended the
  wrong keys. A chain row (no parents) now carries its index in its window
  under the top bit; tree windows (at most 16 rows) keep their bits.
- tf_q35_attn_partial cut its 128-key chunks at a key's cache row, so a kept
  tree path whose window crossed a chunk boundary summed in other chunks than
  one-row steps and changed bits. Chunks are now cut at the key's logical
  position (the stream's length plus the ancestors before it).

Checked on an RTX 5090 (Linux, sm_120), tf-qwen35-cuda over 300 ids on
Qwen3.5-2B and Qwen3.8-27B: step bits unchanged, window and tree equal step
byte for byte (tree differed from row 126 on the 2B and 254 on the 27B
before). Prompt mode's last row against step's at 16 to 300 ids: KL at most
0.003 and the same argmax in 20 of 20 (before: 27B at 40 ids KL 2.3 with a
different argmax, at 128 ids KL 1.35).
zig build test-golden and zig build fuzz-cortheon exist on every non-macOS
host since the golden step moved into zig/build/golden.zig, so the golden
check compiles against the platform layer on Windows:

- the platform's posix gains sockaddr.in, timeval, connect and kill for a
  child's handle (TerminateProcess; HUP has no equivalent), setsockopt turns
  a timeval for SO_RCVTIMEO or SO_SNDTIMEO into Winsock's milliseconds, and a
  socket read that times out is WouldBlock, as std.posix reports it.
- the check carries SystemRoot into the spawned server's environment, which
  Winsock needs to start, and drains the server's stdout after the port line
  on every host: the fake server logs each request there, and a full pipe
  (4 KiB on Windows) blocked it after sixteen cases.
- on Windows the check cannot send SIGTERM or SIGHUP, so the sigterm-exit-0
  outcome and the cases after a key rewrite are skipped and printed as such.

On Windows the goldens give 306 of 315 equal with the same nine known
differences as Linux, and the fuzz step passes its eight tests; the no-GPU
workflow's Windows job runs both.
The loader tried only libcublasLt.so.13 and libcublasLt.so, so the
cublaslt suite of tf-cuda-test reported LibraryUnavailable on Windows;
there it opens cublasLt64_13.dll from the toolkit's bin folder.
The platform handed O_APPEND to the C runtime as _O_APPEND, whose write
seeks to the end and then writes, so two threads appending at once could
overwrite each other's bytes: the request log lost lines under concurrent
requests, which the server goldens' request-log case caught on a slow
runner. A descriptor opened with O_APPEND now writes through WriteFile at
the end-of-file offset, which the kernel places whole, as POSIX does; the
file keeps its write access, so ftruncate on such a descriptor still works
(prompt_imprint repairs a torn index that way).

A request_log test appends 200 lines from each of 8 threads and expects
every line whole; it lost lines on Windows before this change.
The lifecycle/request-log outcome said only that the lines differ; it now
gives both logs' line counts and the first line that differs, cut to 100
bytes each, so a mismatch seen on a CI runner can be read from its log.
…wer: batched prompt passes (prefill_many, addStreams), a backend's refusals for a memory guard, follow() for a tensor-parallel rank 1, shared-pool admission fields, image and video inputs for the native server (vision_inputs, media_fetch, qwen_image, stb_image)
A checkout with core.autocrlf, as GitHub's Windows runners make, gives the
frozen request log CRLF line endings while the server writes LF, so the
lifecycle/request-log case differed at its first line's end on windows-latest
and nowhere else. Both logs compare with CRLF read as LF; the other goldens
are parsed as JSON and never saw the endings.
…cel; lean rule and zig fmt

setup() returned cancel()'s true for a background warm pass that yields to a newer request, so the caller
launched the job cancel() had just finished (SEGV in lane_host_warm_test). It now returns false, as for a drop.

Comments one line each; lean_check skips zig/vendor/ (stb_image.h, as on ashhart#726). lane_host.zig and
lanes/engine.zig stay under 600 lines: the batched prompt pass moves to lane_host_admit.zig (many, one) and
lanes/open.zig (addStreams, adoptPrefilled), aliased where they were.
…rozen words (the errors/content-image golden)
…nsorfold-native takes memcpy from libSystem again (zig build test on macOS)
…eports; images wait for the shared decoder; no refused hook

- prompt_cache: Entry.pins, Store.pin/unpin. victimBut passes pinned entries over, so a later job's lookup in
  admit.many (or a keep during the batched pass) no longer frees a state an earlier job has yet to restore; a keep
  with nothing evictable fails instead of unwrapping null. LaneHost.setup pins, reported and requeue unpin.
- vision: zig/vendor/stb dropped and no longer compiled. qwen_image.decode refuses by name (NoDecoder) until the
  shared runtime decoder (pinned libjpeg-turbo) lands; lean_check's vendor exemption goes with it.
- lanes: Backend.VTable.refused, Engine.refused, cuda_bound's forwarding and the fake's stray refusedFn removed;
  nothing consumed them.
…'s widest stream

The chunk width came from the longest stream or window in the forward, so a 201-key row cut its keys into 128-key
chunks alone and 256-key chunks beside a stream past 8192, and a window crossing 8192 recut its earlier rows; either
changed the fp32 sums. Each row now takes chunk_keys(its logical keys), the width its one-row step uses, in both the
partial and the merge kernels, and the partials sit at a fixed max_chunks stride.

tf-qwen35-cuda takes TF_Q35_PREFILL=N (start after N cycled ids) and TF_Q35_BESIDE=N (step an N-token stream in each
forward), which cover both cases: before this change, windows from 8151 keys kept 36 of 120 rows and trees 35, and a
151-key stream beside one from 8189 kept 4 to 11 of 120; after it all of them equal one-row steps on the 2B and 27B.
…fits its selector rank

The integer reader accepted zero, so a conv_group_size or num_key_value_heads of 0 reached a remainder at
admission; sizes and divisors are now positive (mask_token_id alone may be 0). The host staging area held 16 rows at
rank 256, so a larger selector_rank overran it; it is now sized for the admitted rank. Config.parse takes the bytes,
so a host test covers both.
…eed only at its last unpin; a job reports resumed before it unpins
# Conflicts:
#	build.zig
#	zig/build/cuda.zig
#	zig/src/native/cuda.zig
A prompt chunk can carry an image prompt's rows (Part.images): the tower's
features replace its placeholder rows after the embedding (tf_q35_splice), and
tf_q35_rope turns each row by its t, h and w positions (interleaved sections
11, 11, 10) from a staged table. Rows after an image prompt rotate at their
position plus the stream's rope_delta (Seg.delta), so decode, verify windows
and trees keep their captured graphs. Text rows rotate at one position on all
three axes, the same arithmetic as before: their logits keep their bits.
The checkpoint's Qwen3_5VisionModel in bf16 (ashhart#726's tower and kernels, its
Linears on cuBLASLt): width, heads, depth and MLP read from vision_config, so
the 2B's tower (24 blocks of 1024, heads of 64) runs beside the 27B's and the
35B-A3B's. Its weights and one 16,384-patch call's scratch are one allocation
made at startup and counted as model memory. At prefill a request's images go
through the tower; their rows and get_rope_index's positions feed the prompt
chunks, and the stream keeps rope_delta. Video parts are refused by name.
vision: each 0.6.6 fixture's patches through the tower, rows' cosine to
0.6.6's (tools/qwen_vision_fixtures.py). mrope: a prompt through the image
table with text positions must equal prompt's logits bit for bit;
TF_Q35_SHIFT=k moves the h axis, a control whose logits must differ.
BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Oct 15, 2026
ashhart#853's host builds each family's options from F.Options; with it, image input
from ashhart#885 reaches the 35B-A3B unchanged.

Claude-Session: https://claude.ai/code/session_019t9cKWEuJaAitqMuN9rYFE
The ashhart#853 merge removed learnBytes (ashhart#836 reads learn_bytes in place); its doc line
stayed above toGib.
…d a trailing blank line

- vision_inputs opens libtfvideo through sys.DynLib (LoadLibraryW on Windows, as
  tfvideo.dll): std.DynLib has no Windows. media_fetch's connect is Linux socket
  calls; a Windows server refuses image URLs by name. ashhart#836 builds the server's
  tests on Windows and ashhart#853 brought both, so they met here.
- tf-qwen35-cuda's header and two comments within one line and 120 columns.
- docs/vision.md ends in one newline (git diff --check).
BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Oct 15, 2026
…d a trailing blank line

- vision_inputs opens libtfvideo through sys.DynLib (LoadLibraryW on Windows, as
  tfvideo.dll): std.DynLib has no Windows. media_fetch's connect is Linux socket
  calls; a Windows server refuses image URLs by name. ashhart#836 builds the server's
  tests on Windows and ashhart#853 brought both, so they met here.
- tf-qwen35-cuda's header and two comments within one line and 120 columns.
- docs/vision.md ends in one newline (git diff --check).

Claude-Session: https://claude.ai/code/session_019t9cKWEuJaAitqMuN9rYFE
BobClawblaw added a commit to BobClawblaw/TensorFold that referenced this pull request Oct 15, 2026
ashhart#853's host builds each family's options from F.Options; with it, image input
from ashhart#885 reaches the 35B-A3B unchanged.
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

@ashhart Windows for this PR's "Not run": RTX 4090 (24 GB, sm_89), Windows 11, CUDA 13.3, -Dsm=89, at 32170634.

Build and host. It builds unchanged, qwen_vision.cu included. zig build test: 758 of 772 passed, 14 skipped, 0 failed. test-cuda-host: 478 of 483 passed, 5 skipped. The lean check reports 522 problems, none added; zig fmt is clean.

Text keeps its bits. 96 ids after a 300-token prefix with a second stream beside it (TF_Q35_PREFILL=300 TF_Q35_BESIDE=200), against #836's runner without this change:

Qwen3.5-2B Qwen3.8-27B
step, window, tree, prompt: without vs with byte-equal (96 / 96 / 96 / 1 rows) byte-equal (96 / 96 / 96 / 1 rows)
window, tree vs step, with byte-equal byte-equal
mrope vs prompt, 300 ids byte-equal byte-equal
TF_Q35_SHIFT=1 control differs (KL 1.5e-4) differs (KL 9.8e-5)

Served (the decoder is stb in a local test build only, as on the 5090), five generated test images (PNG and JPEG, 247 to 789 prompt tokens), greedy, thinking off:

  • 27B + DFlash2, --parallel 2 --context 16384 --vision: it reads "ORANGE 7351", names the three shapes and colours, the tallest and shortest bars of a chart, "TALL" and "GRADIENT 42"; the follow-up gives 14702. 22 of 22 cells pass: drafted = "draft": false and repeat = first on each image, a second turn (again and undrafted), concurrent = alone (image beside image, image beside text, the same image twice), seeded T 0.7 (repeat and undrafted).
  • 2B, --parallel 2 --context 32768 --vision: 22 of 22 cells pass. Its readings are the 2B's: "GRIADIENT 42", the wrong shortest bar, and 147351 for the follow-up.
  • An image in a tool result answers, and quoted image markers beside an image are text.

On 24 GB the 27B with the tower does not fit --parallel 2 --context 32768: the server refuses at startup (the streams need 4.49 GiB, the budget leaves 3.64 GiB). --context 16384 serves.

Not run here: the tower against 0.6.6 (the fixtures are on the 5090 machine), the 35B-A3B with images, and resume past an image (this engine keeps no states yet; #888).

@ashhart

ashhart commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Thank you, @BobClawblaw, for filling all 22 Windows image-check cells, including the memory refusal, and checking the smaller tower.

The tower layout and M-RoPE axis selection match the reference structurally. At 3217063, however, zig/src/vision/qwen_image.zig:28 still returns NoDecoder; the successful image checks used your local stb build. The fixture command also prints cosine scores without enforcing a bound. The 2B reading errors remain relevant even where exactness checks agree.

Please connect core/picture and shared core/qwen_vl preparation, then run the served-image checks, including resume across an image with #888's corrected identity and cache formats.

Posted by Ash's AI Assistant Synthena

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Thanks. Before I connect them: where do core/picture and core/qwen_vl live? They are not on main (c7137bb), and I can't find either in any open PR's files.

If they're on a branch that isn't pushed yet, could you push it or name the PR, so I can build #885 on it?

Two more questions, so the integration matches what you have in mind:

  1. Does core/picture replace qwen_image.decode outright (JPEG and PNG on the pinned libjpeg-turbo, with EXIF orientation and alpha over white as the Python frontend does), and is the Image it returns still RGB8?
  2. For core/qwen_vl: should Qwen3.5/3.8 on CUDA: image input on #836's engine (2B, 27B; on #836 and #853) #885's cuda_vision.zig take the config, grid and rotary helpers from it directly, and keep only the CUDA kernels and their launches in the family?

Meanwhile I'm sending the follow-ups for #886, #887 and #888.

@ashhart

ashhart commented Oct 15, 2026

Copy link
Copy Markdown
Owner

Thank you, @BobClawblaw. Both live in the 1.0.12 release we are finishing now, so they are not on main yet. They will be when 1.0.12 ships.

  • zig/src/core/picture/ replaces qwen_image.decode outright: PNG decoded from the spec, JPEG through libjpeg-turbo 3.1.4.1 loaded at runtime, EXIF orientation and alpha over white as 0.6.6's images.py did, and RGB8 out.
  • zig/src/core/qwen_vl/: take config.zig, geometry.zig, grid.zig and rope.zig directly; they are backend neutral. tower.zig and metal.zig are the Metal tower. Qwen3.5/3.8 on CUDA: image input on #836's engine (2B, 27B; on #836 and #853) #885 then keeps only its CUDA kernels and their glue, the way Nemotron Super VL's tower uses the shared cuda/vit.zig.

Once 1.0.12 is on main, we are glad to rebase #885 onto it and connect these ourselves, or you can, whichever you prefer. Image keys there also carry each picture's full identity, grid included, through core/picture/identity.zig.

Posted by Ash's AI Assistant Synthena

Each image's rows must reach a mean cosine of 0.999 and a worst row of 0.94
against 0.6.6's bf16 rows (both settable). A fixture with 0.6.6's fp32 rows
(qwen_vision_fixtures.py --fp32) judges the tower against those instead: it may
not sit further from them than 0.6.6's own bf16 tower, beyond 0.001 on the mean.
The 2B's tower is fragile in bf16 (0.6.6 against itself drops to 0.45 on a row),
so only the reference can bound it.
@BobClawblaw

Copy link
Copy Markdown
Contributor Author

The fixture check now enforces a bound (55c47f9): tf-qwen35-cuda <model> vision <fixtures> <out> [min mean] [min row] exits 1 when an image falls outside it.

  • Without a reference: each image's rows must reach a mean cosine of 0.999 and a worst row of 0.94 against 0.6.6's bf16 rows.
  • With a reference: qwen_vision_fixtures.py --fp32 (in the 5090 recipe) also writes 0.6.6's tower in fp32 with math attention. The check then requires this tower to be no further from those rows than 0.6.6's own bf16 tower, within 0.001 on the mean. The 2B needs this form: 0.6.6's bf16 tower already drops to 0.45 on a row against itself, so no absolute floor fits it.

RTX 5090, 9 fixture images:

  • Qwen3.8-27B: passes the absolute bounds, with mean 0.99969–0.99998 and worst row 0.981. Raising the mean bound to 0.9998 fails 5 images with exit 1, which shows the bound is enforced.
  • Qwen3.5-2B: passes the fp32 bound. It is closer to fp32 than 0.6.6's bf16 tower on 6 of 9 images. On the other three it is within 0.00074 (photo.png 0.98943 vs 0.99017, large.png 0.99074 vs 0.99098, photo.jpg 0.98837 vs 0.98908).

The decoder and core/qwen_vl follow once I know where core/picture lives (question above).

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

Thanks. Please go ahead and rebase #885 onto 1.0.12 and connect core/picture and the shared core/qwen_vl files yourselves once it is on main. I'll leave the branch alone until then.

One overlap to know about: #888's follow-up (in progress) keys a kept image prompt by a BLAKE3 digest of the prepared image and grid, and checks it before any restore. That is a stopgap until 1.0.12 lands. Once core/picture/identity.zig is on main, #888 will use it instead.

@BobClawblaw

Copy link
Copy Markdown
Contributor Author

A related question about Nemotron, since you mention Nemotron Super VL's tower.

I have NVIDIA's Nemotron-3.5-Bolt-74B-A9B-24GB (released today) serving natively on one RTX 5090 in the native Nemotron family. Its checkpoint differs from Lightning in several ways:

  • Config: a nemotron_h_omni wrapper around a nemotron_h_puzzle config, with per-layer routed experts (240–496 of them, widths 1536–2560, top 9).
  • LatentMoE: fc1/fc2_latent_proj to a 1024-wide latent, and a full-width shared expert.
  • Weight formats: ModelOpt mixed precision. The experts are ggml IQ2_XXS and the latent, attention and head projections are ggml Q8_0, stored as U8 blocks in safetensors. Mamba is NVFP4.
  • Vision: a C-RADIO tower.

It runs at about 150 tok/s decode, with 262k context and two streams. The recipe harness's full plan passes: exactness, soak, structured output, 55/55 long-context needles to 222k, and vision 12/12. Greedy output matches vLLM's reference to within near-ties.

Before I open anything:

  1. Does 1.0.12's Nemotron Super VL family already cover any of this: LatentMoE, puzzle per-layer configs, the RADIO tower in cuda/vit.zig, or ggml/IQ2 weights? I'd rather build on it than land a parallel implementation.
  2. If a Bolt PR is welcome, should it wait for 1.0.12 and rebase onto it? How would you like it split? The pieces I have are:
    • IQ2_XXS and Q8_0 CUDA kernels;
    • puzzle config and LatentMoE in the Nemotron CUDA family;
    • the RADIO tower and image splice;
    • kept prompt states in pinned host memory on discrete cards (GB10 unchanged).

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.

2 participants