feat(qwen4_exp): image input (vision tower + mRoPE) for Qwen3.8-Flash-Next - #386
feat(qwen4_exp): image input (vision tower + mRoPE) for Qwen3.8-Flash-Next#386gdevenyi wants to merge 10 commits into
Conversation
57f0b9d to
70d9e8d
Compare
|
GPU results, 2 x RTX 6000 Ada at TP=2 (#385), RadixArk NVFP4, Image probe (rendered 640x480 PNGs as
The follow-up exercises decode after an image (rope at Cost of the loaded tower, same flags, same tree with vision off vs on:
So the 0.86 GiB tower per rank costs 2.6 points of expert residency and nothing else here. Greedy 256-token continuations of three text prompts: the code prompt and the ~1k-token prompt are word-for-word identical between the two runs (and to production without this branch); the short essay prompt diverges after 84 words, which is the same short-prompt run-to-run noise seen between two passes of the same TP=2 server (bf16 atomics in the expert kernels), not a rope difference: the 1k prompt is the one that exercises the QSA blocks.
|
|
GPU test pass of |
|
Pushed 28fd56d: chunked prefill for image prompts. The first version refused image prompts longer than one prefill chunk ( |
|
GPU end-to-end for the chunked case (TP=2, production flags, 2026-09-04 18:45): a 23,504-token prompt (20k-token text preamble, then the image, then the question) prefilled in three chunks (8192 / 8192 / 7120) and answered |
|
Ran this branch at TP=1 on one RTX 4090, 24 GiB, in a 61 GiB box. It Both image cases answered correctly at
The second is the case Getting there needs #337 as well, on a box this size. Without the disk tier the expert They merge almost cleanly, so the recipe is short. One conflict, Tests on the merged tree, against plain Same seven failures either way -- the Measured on an RTX 4090 (24 GiB, sm_89) / i9-14900KF / 61 GiB box, driver 595.84, CUDA Run inside a systemd scope with The only edit anywhere was that one merge conflict in #337's Assisted-by: Claude Opus 5 |
…ike text 037f102 narrowed the rule from "the whole prompt in one chunk" to "the image span in one chunk", which is what a 196-token sprite in a 166k-token turn needs. The span is [first image token, last+1) because ``mm_embeds`` is one concatenated tensor scattered in one forward -- so it grows with the TEXT between two screenshots, not just with the pictures. An agent conversation reaches the limit by talking: 400 prompt with images needs 10392 contiguous tokens in one prefill chunk (the image tokens span [160334, 170726) and cannot be split) Nothing configurable moves that. Cheaper images (~490 tokens each after the clamp) only buy more turns before the gap between the first and last one exceeds a chunk, and raising --max-prefill-length OOMs long before it helps: a 32k chunk's activations do not fit beside a 5 GiB KV pool on a 24 GiB card (measured -- it took the worker down twice today). So the concatenated tensor stops being scattered whole. ``_merge_multimodal`` takes the rows belonging to the placeholders inside ITS OWN forward -- the ones an earlier chunk or a prefix-cache hit already consumed sit in front of the window -- and the adder chunks an image prompt exactly like a text one. ``Req.mm_scatter`` and the whole pull-back / reject path go away with it, ~90 lines. Both families that carry a tower here are converted; the approach is gdevenyi's, from FlashML-org#386 (28fd56d). The span cap 09ea814 put in ``match_req`` goes too. It existed because a hit landing inside a placeholder run left half the run cached and half to forward, which the all-in-one-forward scatter could not represent; the window skips the cached half instead. Without the cap a prompt that ends with its image keeps its prefix -- 20,800 of 20,840 tokens on the repeat here, 6.0 s -> 1.2 s, and a different image at the same position still misses (answered "Green" where the cached one answers "Blue"). Measured on Ornith-1.5-35B-A3B-NVFP4, one 4090, --max-prefill-length left at its 8192 default: 2 images with 9k of text between span ~19k 10,186 tokens, 3.2 s (was a 400) 6 images with 9k between each span ~50k 55,360 tokens, 18.1 s (was a 400) A(blue) 9k B(green), and reversed "Blue, Green" / "green blue" -- read across the boundary, in order tests/tokenizer 58, tests/scheduler 90, tests/kvcache/radix 142: all passed. Twelve tests pinning the removed rule are gone and three cover the window (a span wider than a chunk now admits; a chunk scatters only its own rows; a chunk holding no placeholder scatters nothing). The ``_NoSwa`` stub gained the ``page_size`` the reservation math has been reading, which is what had six of these failing on this branch already. A cold system-test run is character-identical to the same branch without this commit, all seven cases. Assisted-by: Claude Opus 5 Re-verified on this branch (no FlashML-org#337/FlashML-org#354/FlashML-org#287 under it): tests/tokenizer 58, tests/scheduler 88, tests/kvcache/radix 142 all passed; a cold system-test run is character-identical to the same change on the daily branch, all seven cases; the two shapes that used to 400 (spans of ~19k and ~50k tokens) answer at the 8192 default.
Verified on Ubuntu 26.04 — image input works, with two notesEnvironment: Ubuntu 26.04, Python 3.14 (editable install from source), torch 2.11.0+cu130, Image input works: a 256x256 solid-red PNG answers 红, a solid-green one 绿, 1. Two undeclared dependencies
Pin Also, the 2. The prefix-cache key only carries 30 bits of the 64-bit hashIn mask = _IMAGE_KEY_BASE - 1 # 2**30 - 1
run = (torch.arange(n, dtype=torch.int64) + (h & mask)) & mask
The id field can't simply be widened: One fix that needs no tree changes — spread the whole hash across the run's n placeholders: # low 29 bits: (hash + offset), as today; bit 29: one more hash bit, a different one per position
run = torch.tensor([
(((h & low_mask) + j) & low_mask)
| (((h >> (SPAN_BITS + (j % HIGH_BITS))) & 1) << SPAN_BITS)
for j in range(n)
], dtype=torch.int64)With I have this plus the pillow/torchvision handling running locally (14 unit tests, including one that Thanks for the PR — the mRoPE port and the state-dict contract for the tower are clean, and the (Review text drafted with AI assistance; all findings reproduced on real hardware.) |
|
Read this PR alongside #385 because we shipped the same two features independently and have been Everything below is full-box on 2 × R9700 unless said otherwise, measured on our own tree 1.
|
…ackend) Shard the dense weights per rank at load (attention qkv by head, GDN in_proj as its six parts with the matching conv1d channels and A_log/dt_bias, shared-expert gate_up per part; o_proj/out_proj/down_proj row-parallel; embed/lm_head by vocab rows) and the NVFP4 expert banks along the intermediate axis, so every rank holds half the experts and each MoE layer needs one all-reduce (routed + gate * shared are combined before the reduce). Router, QSA indexer, norms, hyper-connections and PLE stay replicated so all ranks select the same blocks and n-gram rows. Also: LinearColParallelMerged(local_output_sizes=) for the kv-replicated case and distributed_timeout 60 -> 1800 s (ranks reach their first collective minutes apart behind a 100+ GiB load). Limits: offload backend with bf16 dense projections; fp8_block / nvfp4 dense checkpoints raise under TP. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
tests/models/qwen4_exp/test_weight.py feeds iter_weights a synthetic checkpoint whose config.json has no model_type; at TP=1 nothing is sharded, so do not touch the config. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
…-Next Serve images through the OpenAI chat endpoint. Opt-in with FREETOKEN_LOAD_VISION=1. Model side: the HF Qwen4ExpVisionModel runs inside a BaseOP whose tensors load as ``visual.*`` with the dense weights (meta build, assign-on-load, rotary buffer rebuilt on the device), so the expert-cache planner counts them and --dummy-weight works. Soft tokens replace the image placeholders before the hyper-connection repeat. mRoPE: ``mrope.py`` ports HF get_rope_index (3-D T/H/W positions, decode delta) and the interleaved cos|sin rows. A prefill batch with image tokens gets a per-token cos|sin table that the existing rope kernels index by row (attention and the QSA indexer); decode reads the normal cache at position + delta. The table carries index_ratio - 1 lead rows per request so a straddling indexer group can be roped at its first token. Text-only batches alias positions and run the same kernels as before. Request path: image_url parts (inline data: URLs only, 16 MiB cap) are decoded in the API server; the tokenizer worker runs the checkpoint's image processor (FREETOKEN_IMAGE_MAX_PIXELS, default 1280*28*28) and expands each <|image_pad|>; the scheduler encodes the images on every TP rank and computes the rope positions before admission. Image prompts must fit one prefill chunk (rejected with an error otherwise). The wire encoder now carries N-D tensors. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
Image prompts no longer have to fit in one prefill chunk (they were refused above --max-extend-tokens, 8192 by default, which a long agent context hits at once). prefill.py chunks them like text; the scheduler scatters, per chunk, only the soft-token rows whose placeholders fall inside that chunk, skipping the rows earlier chunks consumed. The mRoPE table already windows per chunk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
…nt hash Image placeholders share one token id, so multimodal requests were kept out of the shared prefix cache and every turn of a conversation holding an image re-prefilled the whole context (measured: 20 turns of 109k-122k tokens, ~35 s each at TP=2). The tokenizer worker now emits cache_ids next to the expanded input_ids: the same tokens, with each image's placeholder run replaced by ids derived from a blake2b hash of the image bytes (>= 2**30, above any vocabulary, hash + offset within the run). The cache manager keys match/insert on cache_ids when present, else on input_ids; the model still reads input_ids, and the per-chunk placeholder window already skips cached placeholders. The multimodal exclusions in match_req and the three cache_req paths are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
ed0982a to
b79d7b9
Compare
|
Rebased onto Full 🤖 Generated with Claude Code |
|
Re-tested against current main ( Method. This PR's head merged onto main, then the full Result: 1226 passed, 351 skipped, no new failures. 1 more tests skip here than on main: this PR adds that many CUDA-only tests the hidden-GPU run does not exercise. So the result above says it merges, imports and leaves every CPU-reachable path intact — it says nothing about the kernels themselves. I am running those on free cards in a maintenance window and will post the numbers here. 🤖 Generated with Claude Code |
…build
Qwen4ExpDecoderLayer builds its MoE as `Qwen4ExpMoE(config, layer_id, prefix=...)`,
but this PR's override of __init__ (added to hold the TP communicator) took only
(config, layer_id), so a server boot died with
TypeError: Qwen4ExpMoE.__init__() got an unexpected keyword argument 'prefix'
The whole CPU test suite was green with that bug in place, because every test that
builds a decoder layer is behind requires_cuda -- nothing without a GPU ever
constructed the model. tests/models/qwen4_exp/test_build_cpu.py closes that: it
builds the full model on the meta device (no GPU, no memory) and asserts the state
dict has both layer families, an lm_head, and MoE weights on more than one layer,
so a dropped or shared prefix fails too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
…el shard
With the expert piece stream sliced per rank and the banks sized from
MoEConfig.local_intermediate, a rank holds exactly its half of every expert, so this
kernel can serve TP>1 -- the routed output is a partial sum and the MoE layer already
reduces it (_maybe_all_reduce, or the single combined all-reduce in qwen4_exp's block).
Without this the whole selection table is empty under TP=2 on sm_89 and the server
refuses to start:
KernelSelectionError: no usable kernel in table;
triton: TP > 1 is not supported for this expert format;
marlin: vLLM is not installed;
b12x: b12x requires sm_120+, got sm_89
marlin and b12x keep tp_ok=False deliberately: their pack() repacks the native rows and
neither has been verified against a per-rank bank.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt
|
Status note, and an explicit statement of what tonight's testing does not cover for this PR. This branch is in our production deployment with But every one of those requests was text. I did not run an image through it tonight. So what this buys the PR is "the tower loads, coexists with TP=2 and the NVFP4 expert path, and costs what we thought" — not "image input is correct". The mRoPE and tower-output paths still rest on the earlier GPU testing and the CPU tests in the tree. Rebased onto I will post an image-path result when it next gets a window with a multimodal prompt set. 🤖 Generated with Claude Code |
What this adds
Image input for
qwen4_exp(Qwen3.8-Flash-Next) through/v1/chat/completions. FreeToken serves this model text-only today: the loader drops the 333model.visual.*tensors andrender_messagesrefuses every non-text part (#321, #348). Opt-in withFREETOKEN_LOAD_VISION=1(the tower is 0.86 GiB of bf16 per rank). Video is not covered.Stacked on #385 (the tensor-parallel PR): this branch contains that commit because the loader's
_shardand the attention layer's TP-aware forward are the seams it hooks into. Review that one first; the vision commit is the last of the three here (the middle one is #385's loader fix).How it works
models/qwen4_exp/vision.py). The HFQwen4ExpVisionModel(27 blocks) runs inside aBaseOPwhose tensors travel asvisual.*in the model state dict, so the engine loads them with the dense weights: the expert-cache planner counts them,--dummy-weightfills them, and every TP rank holds the same copy. The op is built on the meta device like the rest of the model;load_state_dictassigns the loaded tensors in place (no second GPU copy) and rebuilds the non-persistent rotary buffer on the device.models/qwen4_exp/mrope.py). Qwen3.8 ropes image tokens on three axes (T/H/W, interleaved sections 11/11/10) and text after an image continues frommax(position) + 1.rope_indexports HFget_rope_index;mrope_cos_sinbuilds the interleaved cos|sin rows. A prefill batch with image tokens gets a per-token table[T, rotary_dim]in the same layout asRotaryEmbedding._cos_sin_cache, and the existing flashinfer / triton rope kernels index it with row numbers (Batch.rope_positions); decode reads the normal cache atposition + delta. The QSA indexer ropes each compressed key at its group's first token, so the table carriesindex_ratio - 1lead rows per request for groups that straddle a chunk boundary. No new kernels. Text-only batches aliaspositionsand run exactly the kernels they ran before.image_urlparts (inlinedata:URLs only, 16 MiB cap; remote URLs are refused rather than fetched on the client's behalf) are decoded in the API server and kept as{"type": "image"}parts for the chat template. The tokenizer worker runs the checkpoint's image processor (FREETOKEN_IMAGE_MAX_PIXELS, default1280*28*28, about 1,000 tokens per image) and expands each<|image_pad|>to its soft-token count; the scheduler encodes the images on its rank and computes the rope positions before admission. Image prompts chunk like text prompts: each chunk scatters only the soft-token rows whose placeholders fall inside it (the old path raisedNotImplementedErrorinside the scheduler). The wire encoder now carries N-D tensors (it asserted 1-D).Other adapters (Anthropic, Responses) keep refusing image parts with the existing error.
Status
Verified on the real checkpoint on CPU (tower load through the state-dict contract, rope positions and cos|sin rows equal to HF, tokenizer path, weight iteration, wire round trip, 60 tests in
tests/models/qwen4_expplus the scheduler / tokenizer / server suites) and end to end on 2 x RTX 6000 Ada at TP=2: see the results comment below (image answers correct for one and two images and for a text follow-up; the loaded tower costs 2.6 points of expert residency and no decode or TTFT). The three GPU test failures on this box are pre-existing onmain(see the comments). Draft only because of the open-PR cap; ready for review from my side.#cached-token: 0each, ~35 s per turn at TP=2). The tokenizer worker now emitscache_idsnext to the expandedinput_ids: the same tokens with each image's placeholder run replaced by ids derived from a blake2b hash of the image bytes (>= 2**30, above any vocabulary, hash + offset within the run). The cache manager keys match/insert oncache_idswhen present, else oninput_ids; the model still readsinput_ids, and the per-chunk placeholder window already skips cached placeholders. Verified on the TP=2 server: turn 2 after an image hits (cached=320of a 330-token prompt), an identical request hits fully, the same text with a different image matches nothing past the text (answer describes the new image), and a 26k-token prompt with the image after a 20k-token preamble prefills in 4 chunks then hitscached=26304on turn 2.tests/scheduler/test_mm_cache_key.py,test_mrope.py::test_image_cache_ids.Testing
tests/models/qwen4_exp/test_mrope.py(CPU; the HF comparisons skip without a transformers that shipsqwen4_exp).tests/models/qwen4_exp/test_mrope_gpu.py(GPU).🤖 Generated with Claude Code
https://claude.ai/code/session_0173pf9k9fSVtwbm3f898HDt