Skip to content

[hold until after rust transition] Gdn and qwen3 5 - #258

Open
NSagan271 wants to merge 32 commits into
mainfrom
gdn-and-qwen3-5
Open

NSagan271 wants to merge 32 commits into
mainfrom
gdn-and-qwen3-5

Conversation

@NSagan271

@NSagan271 NSagan271 commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Qwen3.5 Dense + recurrent state / linear attention resources

Adds the Qwen3.5 dense family and the two engine resources it needed: a pool of fixed-size recurrent state, and linear attention planned over it. This might overlap with #257; @merceod thoughts on how to resolve the two recurrent pool implementations?

MoE variants can be a follow-up PR.

1. Two new resources

Qwen3.5 is a hybrid: most layers are gated DeltaNet (a fixed-size recurrent state), and only a few are full attention over a KV cache.

resources/recurrent/

A slot is a fixed number of bytes per layer, held for as long as a request needs it. The pool allows arbitrary number recurrent state geometry.

Like in the KV cache, slot 0 is a sink slot; FlashInfer's bf16 GDN kernel redirects padding rows into slot 0, so that slot is by default not allocated to real requests.

resources/linear_attn/

Plans and runs linear attention against a pool. LinearAttnVariant is GDN (scalar decay per head — Qwen3.5, Qwen3-Next) or KDA (diagonal decay per K channel, e.g., Kimi Linear, GLM-5.3); only GDN is wired up.

Also relevant are: utils/causal_conv1d/ (the short conv that precedes the delta rule) and model/components/linear_attn.py with its TP variant.

2. The model

mstar/model/qwen3_5/ — config, weight loader, the hybrid LLM stack, a ViT tower, and the submodules that bind them to the engine. Five dense sizes registered (qwen3_5_{0.8,2,4,9,27}b); MoE is a follow-up. TP is wired (configs/qwen3_5_9b_tp2.yaml, qwen3_5_27b_tp4.yaml).

For improved I2T TTFT and throughput, the model performs one prefill walk per prompt: a multimodal prompt runs a single prefill_vision carrying every text span and image.

3. Performance

1×H100, closed loop, --ignore-eos, output 256–4096 tokens, 5 trials/cell, against vLLM 0.29.0 (--max-model-len 8192 --gpu-memory-utilization 0.85):

task conc mstar tok/s vLLM tok/s mstar TTFT p50 vLLM TTFT p50
text 1 254.3 227.3 0.019 0.033
text 4 787.2 782.1 0.021 0.039
text 16 2587.1 2469.5 0.022 0.040
image 1 252.8 227.1 0.046 0.041
image 4 769.7 777.5 0.146 0.050
image 16 2431.6 2413.8 0.163 0.062

We lose I2T TTFT, which appears to be an API server issue (I'll investigate further, maybe after this PR): recv → preprocess done from the --log-stats output is 51.8 ms at conc=1 and 107.8 ms at conc=4, and the entire engine path only takes 28.4 ms.

9B, TP2

2×H100, MSTAR_TP_ASYNC_SCHED=1, 3 trials/cell. Same work as the 4B rows
(2304.4 / 2564.9 / 2452.9 output tokens per request):

task conc mstar tok/s vLLM tok/s delta mstar TTFT p50 vLLM TTFT p50
text 1 223.9 ±0.2 241.1 ±0.1 −7.2% 0.019 0.036
text 4 718.2 ±0.2 839.2 ±0.3 −14.4% 0.023 0.043
text 16 2460.5 ±12.9 2752.8 ±1.5 −10.6% 0.024 0.044
image 1 221.8 ±0.3 240.3 ±0.7 −7.7% 0.056 0.050
image 4 699.1 ±2.1 835.9 ±1.9 −16.4% 0.181 0.056
image 16 2289.2 ±5.8 2687.7 ±12.9 −14.8% 0.193 0.072

Same 9B on one GPU (t2t, 512-token outputs), where we win as the 4B does:

conc ours vLLM delta
1 156.8 141.2 +11.1%
16 1971.1 1951.6 +1.0%

So it seems like the issue is a TP problem and not a model problem; that'll be a separate investigation from this PR.

4. Engine-Level Performance Changes

  • The RoPE resource now allows staging of position IDs through a pinned buffer (double-buffered for the async worker)
  • Adds a variant of the sampler kernel that splits the vocabulary across thread blocks, allowing for higher SM utilization for large-vocabulary models.
  • Refactors the output of forward_batched to be a new dataclass, BatchedModelOutput (with backward compatibility maintained for other models). This dataclass allows the output of forward_batched, and the buffer that is D2H'd for check_stop to be batched across requests (instead of cloning or transferring tiny per-request buffers). This usually doesn't matter for performance, but Qwen 3.5-4B decode steps only take 4-5ms, so they are close to becoming host-bound.

I (+Claude) also added some more timing instrumentation to worker.py; it can be removed if it's too much clutter.

Checklist

  • ruff check . passes
  • Added or updated tests / docs where relevant

# Conflicts:
#	mstar/engine/resources/position/manager.py
#
# TODO: derive this from (backend, dtype, ...) once there is more than one
# backend to ask, and drop the knob.
disable_sink_slot: bool = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is ever True, padding rows are given NO_SLOT=-1. Decode and convolutional kernel are okay with this, but GDNPrefillWrapper::run calls torch.index_select(state, 0, slots) (resources/linear_attn/wrappers.py:310) and state.index_copy_(0, slots, ...) (:341) which both raise index out of range when given -1.

We should either get rid of this flag as suggested by the comment, or have the prefill wrapper mask out -1.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added functionality for the prefill wrapper to mask out -1

self._cached_plan_output = self._current
return self._current

def clear_preplan(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we fork in preplan, here we do not clear state, only set dst.has_state = False. This slot may then be reused in a subsequent step. GDNDecodeWrapper or the convolutional block only check has_state (they trust that a slot with has_state=False is all zeroes) when planning, then end up with bad values when they actually run.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no model that forks on this resource, so this is not a problem now. We can either zero in the undo loop, or defer copying.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, this is handled by not actually initiating the copy in preplan (the fix to the race condition in #258 (comment))

"should have allocated it"
)
for tensor in self._blocks.values():
tensor[:, dst.index].copy_(tensor[:, src.index])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this race?

Worker preplans N+1 after N commit_done (before N's kernels are finished). pre_plan_for_batch runs plan in dedicated stream; the synchronization between that stream and the default stream is on run_forward which has the default stream wait. The planning stream then does not have to wait for step N.

This copy can then be issued while step N replays; however, tensor[:, dst.index] is written in-place by default-stream kernels e.g. GDN index_copy_ and convolutional window shift. There is then a race between the default and planning stream.

This would actually also be possible on the KVManager, although Bagel does not run into this because any pre_forks are in prefill_text which is a PackedCudaGraphConfig which cannot get preplanned.

@NSagan271 NSagan271 Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes this can race; I think the solution is to exempt forks from the pre-plan (and always run them in the actual plan, even if a cached preplan is available). We have a similar policy in the sampler for staging the repetition penalty masks. In that case, the copy would always happen on the same stream. @stephen-dwq thoughts?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this makes sense. I think it is the only way to make this work without races. @NSagan271

@merceod

merceod commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

@NSagan271 I will adopt your pool in #257 and port KDA onto it. I think your implementation is better and more consistent architecturally.

A few things that my implementation has that yours doesn;t:

  • Host offload of slots (cpu_offload_slots, offload() mirroring KVManager).
  • Commit modes: CHECKPOINT (prefix-cache snapshots) and DEFERRED with commit_deferred (speculative decoding rollback). Yours can only emulate rollback with fork copies, which I think at 54 MBs of KDA state per request per rank is too expensive for DSpark; a commit-mode hook has to be added to your pool in my PR.
  • One packed H2D copy per step (slot_ids | has_state | cu_seqlens) versus your two copies per label. This is immaterial though as both are microseconds of host bookkeeping, no decode-step difference either way.

As a side, just noticed that the PRs also collide outside the pool (worker.py, submodule_base.py, engine.py, communication/tensors.py, registry.py, docs). Your BatchedModelOutput overlaps my inline-results and coalescing changes, so one side (likely me) has to rebase regardless of the pool decision.

@stephen-dwq stephen-dwq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may have to change the flash-infer floor to 0.6.8 because of the _GDN_DECODE_BF16_STATE_AVAILABLE. I don't think <0.6.8 has a 128x128 that meets this.

Resources, components and model LGTM.

@vasilevklart
vasilevklart self-requested a review September 21, 2026 02:27
# they already are.
v = v.contiguous()
a = a.contiguous()
b = b.contiguous()

@vasilevklart vasilevklart Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v, a, b are made contiguous but q and k are not. On the prefill path F.normalize happens to return contiguous tensors, but on decode plan.qk_l2norm_in_kernel is True so normalize is skipped and strided views from torch.split go straight into gated_delta_rule_decode_pretranspose, wrong inputs?

Not sure if https://github.com/flashinfer-ai/flashinfer/blob/v0.6.8/flashinfer/gdn_kernels/gdn_decode_bf16_state.py#L2546 would handle in this case so either for all or none..


def _split_count(batch: int, vocab: int, device: torch.device) -> int:
"""How many chunks to cut the vocab into, or 1 to keep the fused kernel."""
del batch, device

@vasilevklart vasilevklart Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_split_count deletes batch and device so every vocab over 16k takes the split path (every model vocab >16k). So the fused kernel is not reachable, Fix: use batch in the decision

"""
dummy_rids = self.slot_for(lease).dummy_rids
self._dummy_rows.reset(dummy_rids[real_bs:lease.bucket.bs])
self._dummy_rows.reset(dummy_rids[real_bs:lease.bucket.bs], free=True)

@vasilevklart vasilevklart Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

free=True makes every step re-acquire KV pages for padding rows. The piecewise runner still uses free=False. So when on a near full arena the allocation can fail, maybe we can free per resource?


self._cu_buffer[: len(cu)].copy_(
torch.tensor(
cu, dtype=torch.int32, pin_memory=torch.cuda.is_available()

@vasilevklart vasilevklart Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_cu_buffer is int64, the pinned source is int32. copy_ can't DMA across dtypes so it copies to a GPU temporary then converts, adding a kernel + an allocation per step.. We can match the dtypes (int32 would also match query_start_loc)

Claude says: "FlashInfer's SM90 prefill casts cu_seqlens to int64, and only the SM100 path wants int32. On H100 keep the device buffer int64 and make the pinned source int64. Switching to int32 would add a device cast per prefill call."

@merceod

merceod commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks, the resources and model math lgtm (gates, layouts, RoPE, loaders and the ViT match HF and vLLM with real weights, and single-request greedy output matches HF). I ran it on H100s on marlowe and found two major issues plus a TP alignment problem that I guess the benchmarks did not see because they used --ignore-eos and power-of-two concurrencies.

NOTE: There were quite a few issues so instead of leaving comments (which would have been fairly time-consuming), I tried fixing them all in PR #285 . @NSagan271 @vasilevklart @stephen-dwq please take a look, stress test, and approve/comment as needed.

@merceod merceod left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See PR #285

merceod and others added 2 commits September 22, 2026 10:49
…s, TP alignment, sampler (#285)

* step: is_padding_row on StepContext

* recurrent pool: padding and capture rows take no slot

* cuda graph runner: keep padding rows' pages resident again

* BatchedModelOutput: carry the forward's row order

* engine: stamp row_request_ids on collected outputs

* worker: map stop-check rows through the forward order

* gdn: 32-byte gate pad, zero the pad block

* gdn tp: zero the rank's gate pad

* sampler: split softmax survives -inf leading blocks

* qwen3.5: image-only prompts, 400s for bad input, rgba, top_k

* deps: flashinfer >= 0.6.14

* gdn wrappers: pass the pool's pad index to the conv kernels

* gdn manager: null slot id for the conv kernels

* rope: declare force_double_buffer

* numa: only pin when nobody narrowed us

* norm: honest RMSNormGated comment

* qwen3.5: fix stale capture comment

* benchmark: fix Qwen3.5 docstring

* docs: qwen3.5 slot sizing

* test: padding rows take no pool slots

* test: row mapping through the forward order

* test: gate pad alignment

* test: pad accounting for the 32-byte alignment

* test: split softmax with -inf regions

* test: conv kernels honour the pad index

* test: numa policy cases

* test: qwen3.5 prompt edge cases

* test: skip vision parity without qwen3_5 in transformers

* test: skip the gdn graph test without CUDA

* worker: call the row mapper through the class

* test: fix the reference shape in the masked-rows test

* kv: padding rows run against SINK_PAGE and hold no pages

* cuda graph runner: padding rows hold nothing, so free per capture and per step

* attn wrappers: room for one sink index per padding row

* test: kv padding rows take no pages

* padding rows for the GDN prefill kernel: newer flashinfer can't run GDN on cuda 12.8, and the flashinfer 0.6.14 prefill kernel fails when given length-0 rows

---------

Co-authored-by: NSagan271 <nsagan@stanford.edu>
@NSagan271

Copy link
Copy Markdown
Collaborator Author

@merceod I did some testing and merged your branch in; can you approve and merge if it looks good?

@NSagan271 NSagan271 changed the title Gdn and qwen3 5 [hold until after rust transition] Gdn and qwen3 5 Sep 23, 2026
merceod added a commit that referenced this pull request Sep 24, 2026
…ut (merge fix: main's capture_into_graph with #258's collector)

This branch has not been deployed

No deployments
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