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 5 commits into
Conversation
…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
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 |
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
|
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 |
…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
|
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.
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