Skip to content

Commit 57f0b9d

Browse files
gdevenyiclaude
andcommitted
feat(qwen4_exp): image input (vision tower + mRoPE) for Qwen3.8-Flash-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
1 parent 9e73f2c commit 57f0b9d

26 files changed

Lines changed: 805 additions & 39 deletions

python/freetoken/attention/qsa_sparse.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ class QSASparseMetadata(BaseAttnMetadata):
8888
cmp_rows: torch.Tensor | None = None # [T] int32, compressed slab destination
8989
ring_rows: torch.Tensor | None = None # [T] int32, flat ring row or -1
9090
positions: torch.Tensor | None = None # [T] int32, logical query positions
91+
# mRoPE: rope position per token (positions + Req.mrope_delta), or -- when mrope_cos_sin is
92+
# set (a prefill batch with image tokens) -- the token's row in that per-token cos|sin table.
93+
rope_positions: torch.Tensor | None = None # [T] int32
94+
mrope_cos_sin: torch.Tensor | None = None # [T, rotary_dim] fp32 or None
9195
# fmt: on
9296

9397
def get_last_indices(self, bs: int) -> torch.Tensor:
@@ -296,6 +300,10 @@ def _plan_index_writes(self, md: QSASparseMetadata, batch: Batch) -> None:
296300
"""Per-token slab row and ring row for this forward; the other QSA layers reuse it
297301
(it is layer-invariant). Pure device arithmetic: no host sync, graph-capturable."""
298302
md.positions = batch.positions
303+
md.rope_positions = (
304+
batch.positions if batch.rope_positions is None else batch.rope_positions
305+
)
306+
md.mrope_cos_sin = batch.mrope_cos_sin
299307
out_loc = batch.out_loc.to(torch.int64)
300308
positions = batch.positions.to(torch.int64)
301309
rows = torch.arange(out_loc.numel(), device=self.device)
@@ -338,10 +346,16 @@ def _update_index_cache(self, index, md: QSASparseMetadata, slot: int) -> None:
338346
pooled,
339347
first,
340348
)
349+
if md.rope_positions is not md.positions:
350+
# Rope the pooled key at its group's FIRST token (HF: cos/sin indexed by group start).
351+
# That token's rope position / table row sits at the same offset from this token's as
352+
# the token indices do. Decode groups that close after an image are text (the chat
353+
# template puts >= 4 tokens after <|vision_end|>), so ``first + delta`` is exact.
354+
first = first + (md.rope_positions - md.positions)
341355
qsa_index_norm_rope(
342356
pooled,
343357
first,
344-
self._index_rope_cache(),
358+
self._index_rope_cache() if md.mrope_cos_sin is None else md.mrope_cos_sin,
345359
index.k_norm_weight,
346360
index.eps,
347361
self.kvcache.cmp_k_cache(slot),
@@ -366,8 +380,8 @@ def _select(self, index, md: QSASparseMetadata, slot: int) -> torch.Tensor:
366380
)
367381
qsa_index_norm_rope(
368382
index.q.view(-1, self.index_head_dim),
369-
positions,
370-
self._index_rope_cache(),
383+
md.rope_positions,
384+
self._index_rope_cache() if md.mrope_cos_sin is None else md.mrope_cos_sin,
371385
index.q_norm_weight,
372386
index.eps,
373387
q_index.view(-1, self.index_head_dim),

python/freetoken/core.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ class Req:
4343
# Optional precomputed multimodal soft-token embeddings (GPU, [num_image_tokens,
4444
# hidden]) scattered at image-token positions during this request's prefill.
4545
mm_embeds: torch.Tensor | None = None
46+
# mRoPE (qwen4_exp image prompts): ``[3, prompt_len]`` T/H/W rope positions of the prompt
47+
# tokens (CPU) and the offset decode adds to a token index (``max_pos + 1 - prompt_len``,
48+
# <= 0). None / 0 for text-only prompts, where rope position == token index.
49+
mrope_positions: torch.Tensor | None = None
50+
mrope_delta: int = 0
4651

4752
# --- hybrid-radix (GDN linear-state) per-request slots; None for non-hybrid models or
4853
# until allocated from LinearStatePool. Set by the scheduler (P2). ---
@@ -135,6 +140,12 @@ class Batch:
135140
attn_metadata: BaseAttnMetadata = field(init=False)
136141
# concatenated multimodal soft-token embeddings for a prefill batch (or None)
137142
mm_embeds: torch.Tensor | None = field(default=None, init=False)
143+
# Rope positions per token (``positions + Req.mrope_delta``); the same tensor as ``positions``
144+
# when no request in the batch carries an image. Set by the scheduler / graph buffer.
145+
rope_positions: torch.Tensor | None = field(default=None, init=False)
146+
# Prefill batches with image tokens: per-token mRoPE cos|sin rows ``[T, rotary_dim]`` (fp32)
147+
# that the attention layers use as the rope cache with ``positions = arange(T)``.
148+
mrope_cos_sin: torch.Tensor | None = field(default=None, init=False)
138149
# Prefill log stats snapshotted at schedule time (before forward's complete_one()
139150
# advances cached_len), so the prefill log reports the tokens actually forwarded and
140151
# the prefix-cache hit -- matching SGLang's #new-token / #cached-token. Set by the

python/freetoken/engine/graph.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class GraphCaptureBuffer:
2424
input_ids: torch.Tensor
2525
out_loc: torch.Tensor
2626
positions: torch.Tensor
27+
rope_positions: torch.Tensor # positions + per-request mRoPE delta (== positions, text-only)
2728
logits: torch.Tensor
2829
table_idx: torch.Tensor # per-request slot id for GatedDeltaNet state gather/scatter
2930
# Decode GDN query indptr = arange(bs+1); a constant per captured bs, filled once.
@@ -35,6 +36,7 @@ def init(cls, bs: int, vocab_size: int, device: torch.device) -> GraphCaptureBuf
3536
input_ids=torch.zeros(bs, dtype=torch.int32, device=device),
3637
out_loc=torch.zeros(bs, dtype=torch.int32, device=device),
3738
positions=torch.zeros(bs, dtype=torch.int32, device=device),
39+
rope_positions=torch.zeros(bs, dtype=torch.int32, device=device),
3840
logits=torch.empty(bs, vocab_size, dtype=torch.float32, device=device),
3941
table_idx=torch.zeros(bs, dtype=torch.int32, device=device),
4042
fla_cu_seqlens=torch.arange(bs + 1, dtype=torch.int32, device=device),
@@ -48,6 +50,7 @@ def set_batch(self, batch: Batch) -> None:
4850
batch.input_ids = self.input_ids[_slice]
4951
batch.out_loc = self.out_loc[_slice]
5052
batch.positions = self.positions[_slice]
53+
batch.rope_positions = self.rope_positions[_slice]
5154
batch.linear_table_idx = self.table_idx[_slice]
5255
# Decode GDN metadata reads the persistent cu_seqlens (constant arange) and the
5356
# persistent table_idx slot map, so the captured kernels see stable addresses.
@@ -61,6 +64,8 @@ def copy_from(self, batch: Batch) -> None:
6164
if batch.out_loc is not None:
6265
self.out_loc[_slice] = batch.out_loc
6366
self.positions[_slice] = batch.positions
67+
rope = batch.positions if batch.rope_positions is None else batch.rope_positions
68+
self.rope_positions[_slice] = rope
6469
if batch.linear_table_idx is not None:
6570
self.table_idx[_slice] = batch.linear_table_idx
6671

python/freetoken/layers/rotary.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,16 @@ def forward(
7373
positions: torch.Tensor,
7474
query: torch.Tensor,
7575
key: torch.Tensor,
76+
cos_sin_cache: torch.Tensor | None = None,
7677
) -> Tuple[torch.Tensor, torch.Tensor]:
78+
# ``cos_sin_cache`` overrides the position-indexed cache with a per-row table (mRoPE:
79+
# row i holds token i's cos|sin, positions = arange), same [rows, rotary_dim] layout.
7780
self.apply_rope_with_cos_sin_cache_inplace(
7881
positions=positions,
7982
query=query,
8083
key=key,
8184
head_size=self.head_size,
82-
cos_sin_cache=self._cos_sin_cache,
85+
cos_sin_cache=self._cos_sin_cache if cos_sin_cache is None else cos_sin_cache,
8386
is_neox=self.is_neox,
8487
)
8588
return query, key

python/freetoken/message/backend.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ class UserMsg(BaseBackendMsg):
3737
# Optional precomputed multimodal soft-token embeddings (GPU tensor). Only used by
3838
# the in-process offline path; remains None for the (serialized) online path.
3939
mm_embeds: torch.Tensor | None = None
40+
# Online image path: processor outputs from the tokenizer worker (CPU fp32 ``pixel_values``
41+
# [patches, C*T*P*P] + ``image_grid_thw`` [N, 3]); the scheduler encodes them on its rank
42+
# into mm_embeds / mrope_positions / mrope_delta (see Req) before admission.
43+
mm_inputs: dict | None = None
44+
mrope_positions: torch.Tensor | None = None
45+
mrope_delta: int = 0
4046

4147

4248
@dataclass

python/freetoken/message/tokenizer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ class TokenizeMsg(BaseTokenizerMsg):
7272
sampling_params: SamplingParams
7373
chat_template_kwargs: Dict[str, Any] | None = None
7474
tools: List[Dict[str, Any]] | None = None
75+
# Encoded image files, in the order their ``{"type": "image"}`` parts appear in ``text``.
76+
images: List[bytes] | None = None
7577

7678

7779
@dataclass

python/freetoken/message/utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ def serialize_type(self) -> Dict:
3232
serialized = {}
3333

3434
if isinstance(self, torch.Tensor):
35-
assert self.dim() == 1, "we can only serialize 1D tensor for now"
3635
serialized["__type__"] = "Tensor"
37-
serialized["buffer"] = self.numpy().tobytes()
36+
serialized["buffer"] = self.contiguous().numpy().tobytes()
3837
serialized["dtype"] = str(self.dtype)
38+
if self.dim() != 1: # 1-D stays shape-less (wire compatible); N-D carries its shape
39+
serialized["shape"] = list(self.shape)
3940
return serialized
4041

4142
# normal type
@@ -64,14 +65,13 @@ def _deserialize_any(cls_map: Dict[str, Type], data: Any) -> Any:
6465

6566
def deserialize_type(cls_map: Dict[str, Type], data: Dict) -> Any:
6667
type_name = data["__type__"]
67-
# we can only serialize 1D tensor for now
6868
if type_name == "Tensor":
6969
buffer = data["buffer"]
7070
dtype_str = data["dtype"].replace("torch.", "")
7171
np_dtype = getattr(np, dtype_str)
7272
assert isinstance(buffer, bytes)
7373
np_tensor = np.frombuffer(buffer, dtype=np_dtype)
74-
return torch.from_numpy(np_tensor.copy())
74+
return torch.from_numpy(np_tensor.copy()).reshape(data.get("shape", (-1,)))
7575

7676
cls = cls_map.get(type_name)
7777
if cls is None:

python/freetoken/models/qwen4_exp/attention.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,14 @@ def forward(self, x: torch.Tensor, batch: Batch) -> torch.Tensor:
164164
v = v.contiguous()
165165
self.q_norm.forward_inplace(q)
166166
self.k_norm.forward_inplace(k)
167+
# mRoPE: rope_positions is positions + the request's delta (text after an image), or
168+
# row indices into batch.mrope_cos_sin for a prefill batch that carries image tokens.
169+
rope_pos = batch.positions if batch.rope_positions is None else batch.rope_positions
167170
q, k = self.rotary.forward(
168-
batch.positions, q.view(-1, self._local_qo_dim), k.view(-1, self._local_kv_dim)
171+
rope_pos,
172+
q.view(-1, self._local_qo_dim),
173+
k.view(-1, self._local_kv_dim),
174+
cos_sin_cache=batch.mrope_cos_sin,
169175
)
170176
index = self.indexer.forward(x)
171177
o = get_global_ctx().attn_backend.qsa_forward(

python/freetoken/models/qwen4_exp/config.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
ModelConfig,
1313
RotaryConfig,
1414
SlotStateSpec,
15+
vision_load_enabled,
1516
)
1617

1718

@@ -40,6 +41,10 @@ class Qwen4ExpArgs:
4041
index_head_dim: int
4142
index_budget: int
4243
index_ratio: int
44+
# mRoPE: which rotary frequencies follow the image H / W axes (HF mrope_section, interleaved).
45+
mrope_section: Tuple[int, ...] = (11, 11, 10)
46+
# Vision patch merge (2 -> one soft token per 2x2 patches); only read when vision is loaded.
47+
spatial_merge_size: int = 2
4348

4449
@property
4550
def index_topk_blocks(self) -> int:
@@ -233,6 +238,8 @@ def _quant(probe: str) -> str:
233238
if isinstance(eos_token_id, (list, tuple)):
234239
eos_token_id = eos_token_id[0]
235240

241+
# Vision is opt-in (FREETOKEN_LOAD_VISION=1): the tower is ~0.9 GiB bf16 per rank.
242+
vision_config = getattr(hf_config, "vision_config", None) if vision_load_enabled() else None
236243
qwen4_args = Qwen4ExpArgs(
237244
hidden_size=text.hidden_size,
238245
hc_count=int(text.hc_count),
@@ -251,6 +258,8 @@ def _quant(probe: str) -> str:
251258
index_head_dim=int(text.indexer_head_dim),
252259
index_budget=int(text.indexer_budget),
253260
index_ratio=int(text.indexer_compress_ratio),
261+
mrope_section=tuple(int(v) for v in rope_params.get("mrope_section", (11, 11, 10))),
262+
spatial_merge_size=int(getattr(vision_config, "spatial_merge_size", 2)),
254263
)
255264

256265
return ModelConfig(
@@ -278,7 +287,7 @@ def _quant(probe: str) -> str:
278287
use_qk_norm=True,
279288
model_type=getattr(hf_config, "model_type", "qwen4_exp"),
280289
architectures=getattr(hf_config, "architectures", ["Qwen4ExpForConditionalGeneration"]),
281-
vision_config=None, # served text-only
290+
vision_config=vision_config,
282291
image_token_id=getattr(hf_config, "image_token_id", None),
283292
attention_groups=groups,
284293
expert_quant=expert_quant,

python/freetoken/models/qwen4_exp/model.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Qwen3.8-Flash-Next decoder stack (text-only).
1+
"""Qwen3.8-Flash-Next decoder stack (text, plus images when the vision tower is loaded).
22
33
The residual state is ``R [T, hc_count*hidden]`` end to end: the embedding is repeated over the
44
``hc_count`` streams, every layer mixes them down to one ``[T, hidden]`` block input and injects
@@ -99,14 +99,21 @@ def __init__(self, config: ModelConfig) -> None:
9999
self.hyper_connection_mixer = GatedResidual(config, use_combine=False)
100100
# plain tuple (not an OP child), so it never shows up in the state dict
101101
self._ple = tuple(layer.ple for layer in self.layers.op_list if layer.ple is not None)
102+
self._image_token_id = config.image_token_id
102103

103104
@property
104105
def ple_layers(self) -> List[PLELayer]:
105106
"""The PLE layers in decoder order -- the seam the loader attaches table backends to."""
106107
return list(self._ple)
107108

108109
def forward(self, input_ids: torch.Tensor, batch: Batch) -> torch.Tensor:
109-
hidden = self.embed_tokens.forward(input_ids).repeat(1, self.hc_count)
110+
hidden = self.embed_tokens.forward(input_ids)
111+
if batch.mm_embeds is not None:
112+
# image soft tokens replace the placeholder embeddings (HF order: before the
113+
# hc_count repeat); the whole image run sits in this prefill chunk (prefill.py)
114+
mask = input_ids == self._image_token_id
115+
hidden = hidden.masked_scatter(mask.unsqueeze(-1), batch.mm_embeds.to(hidden.dtype))
116+
hidden = hidden.repeat(1, self.hc_count)
110117
meta = None
111118
if self._ple:
112119
from .ple import build_ple_metadata, commit_ngram_context
@@ -126,6 +133,7 @@ def forward(self, input_ids: torch.Tensor, batch: Batch) -> torch.Tensor:
126133
class Qwen4ExpForCausalLM(BaseLLMModel):
127134
def __init__(self, config: ModelConfig) -> None:
128135
self._config = config
136+
self._inv_freq: torch.Tensor | None = None
129137
self.model = Qwen4ExpModel(config)
130138
if getattr(config, "lm_head_quant", "none") == "nvfp4":
131139
from freetoken.kernel.triton.nvfp4_linear import Nvfp4LMHead
@@ -141,8 +149,72 @@ def __init__(self, config: ModelConfig) -> None:
141149
tie_word_embeddings=config.tie_word_embeddings,
142150
tied_embedding=self.model.embed_tokens if config.tie_word_embeddings else None,
143151
)
152+
if config.vision_config is not None: # FREETOKEN_LOAD_VISION=1
153+
from .vision import Qwen4ExpVisionTower
154+
155+
self.visual = Qwen4ExpVisionTower(config.vision_config)
144156
super().__init__()
145157

158+
@property
159+
def has_vision(self) -> bool:
160+
return hasattr(self, "visual")
161+
162+
@torch.inference_mode()
163+
def encode_images(self, pixel_values: torch.Tensor, grid_thw: torch.Tensor) -> torch.Tensor:
164+
"""Vision tower + merger on processor outputs: ``[num_image_tokens, hidden]`` (device)."""
165+
if not self.has_vision:
166+
raise RuntimeError("image inputs need the vision tower: start with FREETOKEN_LOAD_VISION=1")
167+
return self.visual.forward(pixel_values, grid_thw)
168+
169+
def prepare_mm_inputs(
170+
self, input_ids: torch.Tensor, mm_inputs: dict
171+
) -> tuple[torch.Tensor, torch.Tensor, int]:
172+
"""One prompt's ``(mm_embeds [n, hidden] on device, mrope_positions [3, L] CPU, mrope_delta)``."""
173+
from .mrope import rope_index
174+
175+
grid = mm_inputs["image_grid_thw"]
176+
embeds = self.encode_images(mm_inputs["pixel_values"], grid)
177+
image_token_id = self._config.image_token_id
178+
n = int((input_ids == image_token_id).sum())
179+
if n != embeds.shape[0]:
180+
raise ValueError(f"{n} image placeholder tokens for {embeds.shape[0]} image features")
181+
pos, delta = rope_index(
182+
input_ids, grid, image_token_id, self._config.qwen4_args.spatial_merge_size
183+
)
184+
return embeds, pos, delta
185+
186+
def mrope_table(self, reqs, device: torch.device) -> tuple[torch.Tensor, torch.Tensor]:
187+
"""``(rope_positions, mrope_cos_sin)`` for a prefill batch with image tokens; see
188+
:func:`mrope.mrope_table`."""
189+
from .mrope import mrope_table
190+
191+
return mrope_table(
192+
reqs,
193+
self._config.qwen4_args.index_ratio,
194+
self._inv_freq_on(device),
195+
self._config.qwen4_args.mrope_section,
196+
device,
197+
)
198+
199+
def _inv_freq_on(self, device: torch.device) -> torch.Tensor:
200+
"""Attention rope frequencies (fp32, the RotaryEmbedding formula) on ``device``."""
201+
if self._inv_freq is None or self._inv_freq.device != device:
202+
rc = self._config.rotary_config
203+
self._inv_freq = 1.0 / (
204+
rc.base
205+
** (torch.arange(0, rc.rotary_dim, 2, dtype=torch.float, device=device) / rc.rotary_dim)
206+
)
207+
return self._inv_freq
208+
209+
def mrope_cos_sin(self, positions: torch.Tensor) -> torch.Tensor:
210+
"""``[T, rotary_dim]`` fp32 cos|sin rows for 3-D ``positions [3, T]`` (same frequencies as
211+
the attention rope cache, so text rows equal the cache rows bit for bit)."""
212+
from .mrope import mrope_cos_sin
213+
214+
return mrope_cos_sin(
215+
positions, self._inv_freq_on(positions.device), self._config.qwen4_args.mrope_section
216+
)
217+
146218
def load_host_tables(self, engine_config) -> int:
147219
"""Attach the PLE n-gram table (pinned checkpoint bank, or zeros for dummy weights); returns the pinned host bytes the engine reserves from its pin budget."""
148220
ple_layers = self.model.ple_layers

0 commit comments

Comments
 (0)