Skip to content

Context / Sequence Parallel 4/5: split long prefills over position-stored KV (all-gather and ring) - #1141

Open
dghosef wants to merge 44 commits into
sp/1-strategiesfrom
sp/2-pcp
Open

dghosef wants to merge 44 commits into
sp/1-strategiesfrom
sp/2-pcp

Conversation

@dghosef

@dghosef dghosef commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Context / Sequence Parallel stack: 1/5 #1124 → 2/5 #1125 → 3/5 #1138 → 4/5 #1141 → 5/5 #1147

Stacked on #1138.

What this does

  • A node can now split a long prefill's tokens across its SP GPUs and keep its KV stored by position (Context / Sequence Parallel 1/5: split the KV cache by position across GPUs (DCP) #1124's context_parallel). Before, you had to pick one: split work (Ulysses, KV by head) or split memory (DCP).
  • Two ways to do the split over position-stored KV:
  • You pick the strategy once per node, with exchange on its sequence shard (SeqShardSpec, a resources: override beside min_tokens): auto (the default), ulysses, all_gather, ring or none. auto follows the KV storage: Ulysses over KV stored by head, and over KV stored by position the node runs every step copied (DCP). So existing configs don't change, and splitting over position-stored KV is something you opt into. A value the storage can't take fails at load (table below).
  • Ragged (cacheless) attentions keep the model's own sp_exchange from Context / Sequence Parallel 3/5: move Ulysses into the engine and pick a strategy per resource #1138, now "none" (the default) or "ulysses". A shard that never splits stops them splitting too, and then their heads stay over TP.
  • The Qwen3-Omni thinker uses it: configs/qwen3omni_thinker_tp4_sp2.yaml runs it on TP4×SP2 with KV stored by position, thinker_shard: {min_tokens: 8192, exchange: all_gather}, and min_tokens is a placeholder.

How it works

  • The shard checks exchange against its node's KV storage when the deployment is derived, and each paged attention picks up the resolved value through its resolve_exchange when it's built. So there is one choice per node, and one attention can't split while another doesn't.
  • All-gather is just a wrapper around the existing placed attention: one gather of q, k and v over the SP group, the copied path as it is, then a slice.
  • Ring stripes the split by KV position: GPU r gets the step's tokens at positions x % P == r, which are exactly the slots it stores, so every K/V write is local. Then P rounds pass queries round the group; each round is one paged call over this GPU's slots, causal or not as the step declares, and the partials are merged in fp32. Hops are synchronous for now (overlap is a follow-up), only the FlashInfer backend plans it, and ring runs eager only: a captured step on a ring node runs copied.
  • CommGroup.all_to_all_rows sends rows to each group rank in counts the caller gives. It replaces Context / Sequence Parallel 1/5: split the KV cache by position across GPUs (DCP) #1124's to_partners, and the ring uses it for its hops.
  • When to pick ring: peak memory is the resident memory plus the bigger of attention and the MLP/MoE phase. Ring only lowers it once attention is the bigger one: from 4 SP GPUs, or on dense models. For the thinker at 2 SP GPUs the MoE sets the peak, so its config uses all-gather. (This is arithmetic, not measured.)
  • Chunked prefill is still off on SP nodes, so a long thinker prompt prefills in one piece.
  • The thinker declares a sequence shard over its one label and brackets its layer stack; the MoE padding mask and layer-0 embeds are computed over the whole step first, and the outputs are gathered after the final norm.
Shard exchange KV storage Split steps Copied steps (decode, short prompts)
auto (default) by head Ulysses head slice
auto (default) by position never split DCP merge
ulysses by head (refused by position) Ulysses head slice
all_gather by position (refused by head) gather q/k/v, placed attention, slice DCP merge
ring by position (refused by head) striped ring, eager only DCP merge
none by position (refused by head on SP GPUs) never split DCP merge

Testing

The CPU rows ran on 7ecc854. The two commits on top change only docs and a docstring.

Test Where Result
ruff check . clean
Full CPU Core suite vs main (eceea1b) CPU box 10376 passed; 0 failures only on this branch (the 38 failures are all main's too)
Rust tests (test/rust/), built from this head CPU box 666 passed
Targeted SP / CP modules CPU box 487 passed, 37 skipped (skips need 2 GPUs or FlashInfer)
Thinker toy model from the real layers, 2 ranks, split vs copied, eager and compiled: by-head Ulysses, by-position all-gather, by-position ring CPU, 2 ranks (gloo) passed
Ring vs all-gather vs one rank, causal and not; exchange values the storage can't take refused at load, and LoRA refused where steps split CPU, 2+ ranks (gloo) passed
A shard that never splits (auto over KV by position, none) plans every step copied, and a ragged Ulysses there plans its heads over TP CPU passed
Thinker TP4×SP2 config passes startup validation CPU passed
GPU: thinker TP4×SP2 parity vs TP4 (48 needle/summary prompts at 1K–60K, gsm8k 250, speech out) 8×H100 needles 24/24 on all-gather and ring; 0 diverge under vLLM's top-5 rule, and 23–24/48 outputs differ vs a same-code floor of 8/48, all near-ties; the rows below split that up; gsm8k 96.4% vs 96.4%; speech WER 1.0% vs 1.1%
GPU: Thinker TP1×SP2 all-gather vs the copied path, 48 greedy prompts (1K/30K/60K), c=1 2×H200 0/48 differ (copied vs itself 0/48); prefill hidden states bit-identical to TP1; needles 24/24
GPU: Thinker TP1×SP2 ring vs the copied path, same prompts 2×H200 15/48 differ, all 30K/60K summaries (floor 0/48); needles 24/24; prefill drifts from layer 0 (relative 2.2e-3) to 3.5e-2 at the final norm. Hypothesis: rounding from merging bf16 partials
GPU: copied context parallel vs TP1, same prompts 2×H200 22/48 differ, summaries only (TP1 vs TP1 0/48; 1024- and 4096-token chunks vs whole 0/48), so most of the TP4 difference above comes from the base, not this PR
GPU: TTFT p50 at 64K / 128K c=1, 2 fresh-server rounds 8×H100 ring 0.87 / 2.52 s, all-gather 1.00 / 2.79 s, DCP-only 1.25 / 3.25 s, TP8 2.37 / 7.20 s
GPU: all-gather vs ring at 128K, peak memory per GPU 8×H100 TP4×SP2: ring 58.8 GB, all-gather 57.9 GB (DCP-only 63.3); TP2×SP4: ring 2.69 s at 66.8 GB, all-gather 3.26 s at 70.5 GB, DCP-only OOMs
GPU: long/short mix (8 × 1K streams + a 32K prompt every 10 s) and the min_tokens sweep 8×H100 short TPOT p50 10.5–11.0 ms on every TP4×SP2 arm (TP8 7.3), 32K TTFT ring 0.39 s, all-gather 0.47, DCP-only 0.58; all-gather beats DCP-only from 16K tokens and is slower at 4K–8K
GPU: unit suites, 17 fresh SP boots with a 32K first request, aborts and bad requests, SP = 1 regression check (Omni TP2, Qwen3.5-0.8B) vs #1138 8×H100 183 passed, 0 failed; 0 hangs; no regression

🤖 Generated with Claude Code

dghosef and others added 5 commits November 2, 2026 11:04
…ing to_partners

One public face over the existing all_to_all_single: x's rows go to each group rank in
counts the caller gives per rank, and the rows each rank sent come back. TP placement's
partner exchange is the case of one row per partner, so to_partners goes; the ring over
a pool stored by position (next) is the case of one peer per hop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ring, sp_exchange

A node can now split its steps' tokens over SP ranks above a paged pool stored by position
(context_parallel), the refusal of that pairing gone:

- all_gather (the default over position storage): one gather of every rank's q, k and v,
  Part A's copied path unchanged, this rank's share of the output.
- ring (sp_exchange: ring): the shard stripes each split step by KV position (a row with c
  stored tokens gives rank r its tokens at positions x with x % P == r), so each rank writes
  exactly the K/V it stores (write_kv(tokens=)), then P synchronous rounds pass queries round
  the partners: one causal paged call of partner j's queries over this rank's slots, the
  partial back, an in-place fp32 merge (merge_into). A row's first query that sees no key on
  a rank plans as a one-query row over a sink slot, masked out of the merge, so no causal row
  holds more queries than keys. Eager only: a striped shard plans a leased step copied.
  RingAttentionWrapper carries the plan-state interface (global qo_indptr for
  select_last_hidden, reads_step_kv) and its own eager key; bind warms one hop per offset.
  The striped shard depends on the pool it stripes by (an edge of the shard, which keeps the
  pool's prefix cache open) and supports a placed pool, so the runner plans the pool first.
- sp_exchange on AttentionSpec, set only by a deployment's resources block: none, ulysses,
  all_gather or ring; unset derives it from storage, so existing configs are unchanged. A
  mismatch with the pool's storage is refused at load at any SP size. SeqShardSpec derives
  splits (any attention not none) and stripe_pool from raw fields, so an all-none node is a
  shard-less node to the engine and the refusals (DCP alone); a none attention beside
  splitting ones refuses a split label at plan. Ragged sp_exchange takes the same strings,
  "none" by default.

SeqRange gains local/whole over contiguous or striped layouts (start deleted). Refused at
load: an axis-1 shard over a placed pool, a ragged Ulysses beside a ring, ring attentions on
two pools; refused at the call: a K/V write without attention of a split label over a
placed pool; a placed run outside the exchange now raises too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k sp_exchange

The matrix's split-over-positions cell (all-gather, ring), the sp_exchange choice with a
config-keys-to-what-runs table, the memory rule (peak = residents + max(attention, MLP or
MoE); ring lowers it past a per-model crossover), ring as eager only, and chunked prefill
still off on SP ranks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Thinker declares a sequence shard over its one label, declares it in every
step, and brackets its layer stack: the MoE padding mask is computed over the
whole step and layer-0 embeds are cloned whole, then the embeds, MRoPE tables,
deepstack and mask are scattered, and the final hidden states and the Talker's
layer-N states gathered after the final norm.
configs/qwen3omni_thinker_tp4_sp2.yaml runs it on TP4 x SP2 with the KV pool
stored by position (all_gather, the storage's default exchange) and a
placeholder min_tokens.

The toy-Thinker test runs the real layers over the model's own declarations on
two gloo ranks, split against copied, eager and compiled, and checks a split
prefill samples each row's last token. Its by-position cases (all_gather, ring)
are skipped until the engine's exchanges over position storage land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xchanges exist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 16 commits November 2, 2026 12:48
…opied

A pool stored by position (context_parallel) on a node with a sequence shard used to take
all_gather when sp_exchange was unset, so a config that ran every step copied (DCP) before this
PR started splitting prefills, and was refused beside expert parallelism. Unset now takes the
storage's default: ulysses by head, none by position; splitting over position storage is the
deployment's opt-in (all_gather or ring). The Thinker's TP4 x SP2 config opts in.

The exchange is resolved once, on the spec (AttentionSpec.split_exchange, from
resolve_exchange, which the shard's derive also asks), so the manager keeps one field rather
than the configured and resolved pair; sp_exchange is a Literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eps beside ones that do

sp_exchange none beside a split-taking attention passed load and then failed every split step,
and only in FlashInfer's plan; the dense backend ignored none and ran Ulysses. A paged pool
serves whatever labels a step declares, so a split label can reach such an attention whenever
the node splits. AttentionSpec.split_step_refusals now refuses it at load, which runs only where
the node can split (decision 1). The plan-time raise (_split_range) and its test go; no backend
can then see a split label with none, dense included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…where its node would be

sequence_parallel_refusals refused a shard on a node with no attention that takes split steps
before it looked at whether the node places its pools, though such a node runs copied like one
without a shard. A model's declaration that never splits is a no-op (decision 1), so the
separate rule goes: a non-splitting shard takes the no-shard path, which accepts a placed node
and refuses an unplaced one (its SP ranks would only share heads), with one message for both
kinds of non-splitting node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sp_exchange set on a node with no sequence shard, or at one SP rank, was accepted and did
nothing. It is a deployment's setting, and a deployment value that can't take effect is refused
(decision 1; a model's declarations that never split stay no-ops). AttentionSpec.derive refuses
it without a shard, at any SP size; sequence_parallel_refusals refuses it at one SP rank. The
ring engine test moves to SP placement, where the setting means something.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The axis-1 refusal over a pool stored by position needs the node to split, which unset no longer
does there (R1.F5): the row opts in with all_gather. The storage-mismatch row moves to two SP
ranks, as at one any sp_exchange is now refused as well (R1 cr#7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RingAttentionWrapper planned every partner's queries causal=True, and FlashInferManager's ring
branch dropped step.causal, so a non-causal split step over position storage under
sp_exchange ring (Cosmos3's DiT steps, for one) got a causal mask: wrong output, no error.
The step's causal now reaches the plan. Not causal, each partner's queries in a row see the
row's whole length on this rank (no first-query sink split, which only causal alignment needs),
and a row with no slot here is masked as before. Passing it through costs no new concept, where
a refusal would add a rule and a docs row.

The per-partner layout moves into _ring_rows, which the next commit's check reuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ring plans one wrapper per partner whose rows differ by rank (a partner's first query in a
row is planned apart only where this rank holds no key before it), and passed no check, so a
planner refusal on one SP rank would leave its partner waiting in the first hop. Like
ContextParallelWrapper, RingAttentionWrapper now takes the backend's check and applies it,
before planning anything, to every rank's plan of every partner (_ring_rows), so a refusal is
every rank's. No backend that can split sets a check today; this makes the ring safe for one
that does, as the design described.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ring read each partner's packed partial back with q.dtype, but the bytes are in the
kernel's output dtype (FlashInferPrefillWrapper.run casts q to the attention's). Queries in
another dtype would have their out bytes viewed at the wrong width. It now uses the local
partial's dtype, as exchange_partials and gather_merge do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under MSTAR_KV_PINNED_INDPTRS ContextParallelWrapper copies its trimmed paged indices into a
page-locked buffer so FlashInfer's plan copies them asynchronously; the ring planned its P
partner plans from pageable indices, a synchronous copy each. The buffer and its reuse event
move out of ContextParallelWrapper into _PinnedIndices, which the ring holds one of per partner.
Perf only, on an eager opt-in path; no CPU test can pin memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erved, not refused

AttentionCallable.write on a split label over a pool stored by position raised, though both
exchanges can serve it: all_gather gathers k and v alone and runs the write as a copied step
(the placement sinks the slots this rank doesn't store), as ulysses does with q None; the ring
writes this rank's own tokens, which are exactly the ones it stores. The refusal goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g's KV typed

_warm_ring and RingAttentionWrapper.attend each built the per-offset send/receive counts, so a
change to one could warm different NCCL pairs than the ring uses. Both take them from ring_hop.
AttentionManager._kv is typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ring merges each round's partial into one fp32 accumulator as it arrives; _merged takes
every partial at once and allocates its output. The docstring now says so, so the two copies
of the log-sum-exp merge are a stated choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… dead branch

The tests' placed KV fake copied KVManager.write_kv's tokens= addressing, so the real branch
never ran. The fake now holds what write_kv reads (a plan state addressing each step token as a
placed pool's does, the cache's own scatter) and takes write_kv from KVManager. The Thinker
prefix-cache test appended a SeqShardSpec if the model declared none, which it now always does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y what it can't stripe

The crossover where ring lowers peak memory (about P = 4 for the Thinker, sooner for dense
models) is arithmetic from allocation sites, unmeasured; the docs and the Thinker config now say
so rather than stating it. The ring row also says a step combining labels (batched CFG) fails at
plan, since it has no stored positions to stripe by; no load-time declaration says a model may
combine labels, so this stays a documented symmetric plan-time refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… opt-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dghosef dghosef changed the title Split long prefills over KV stored by position: all-gather and ring, picked in config Context / Sequence Parallel 4/5: split long prefills over position-stored KV (all-gather and ring) Nov 2, 2026
…-pcp

Conflicts: docs/adding_models.rst (SP mode: this branch's split-step text
plus the parent's "chunked prefill over a placed pool is not supported
yet"), docs/models.rst (both Qwen3-Omni notes), and
test_qwen3omni_vision_mixed.py (both sides' tests).

Under this branch a node storing KV by position may split its steps, so
the head-split refusal (engine and validate_deployment) now names the
other side "keeps KV by head", and the LoRA docs and comment say LoRA is
refused wherever the steps split (SeqShardSpec.splits), placed pool or
not. A validate_deployment case pins it: placed + all_gather/ring refused,
placed + sp_exchange unset served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 2 commits November 2, 2026 13:34
Only FlashInferMLAManager sets _plan_check, and MLA can't take split steps, so the check
RingAttentionWrapper took (f0c773f) was always None on every ring that runs, and its test
monkeypatched it onto a FlashInfer manager. The docstring keeps the reason a future backend
with a plan check would need one: partner plans differ by rank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ring's call follows the step's causality since 7d816dd, and an unset sp_exchange over KV
stored by head resolves ulysses and splits without a pick; only position storage needs the opt-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dghosef and others added 19 commits November 2, 2026 14:03
The ring was the one exchange that ignored the `run` closure: it reached back
into the KV resource through a new attention-to-KV reference
(`AttentionManager._kv`) and wrote and attended in `_ring`, calling the plan
state with a signature of its own. It is now `sequence_shard.ring(run, q, k, v,
r)` beside `ulysses` / `all_gather` / `head_slice`: it calls
`run(q, k, v, tokens=r.index)`, `AttentionCallable._attend` forwards `tokens`
to `write_kv`, and `attn.run` -> `_attend_placed` drives
`RingAttentionWrapper.attend(queries, kv_cache_layer, new_keys)`, the same
signature as `ContextParallelWrapper.attend`. Drops `_kv` and `_ring`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(abstractions-4 #4)

`RingAttentionWrapper.reads_step_kv = True` was set only so Qwen3Attention's
fused norm/RoPE/KV-write path would fall back to `attend(q, k, v)` and the
write would go through the exchange. The ring attends only the pages, so it
is now False, and the fused path decides on what it actually depends on:
`attn.splits` (the call goes through an exchange) or `reads_step_kv()`.
This also covers Ulysses / head-slice nodes, whose fused write would have
bypassed the exchange (a split label raised in `write_target`, a copied
label wrote every head into a pool storing heads over TP x SP).

`RingAttentionWrapper.plan` no longer recomputes `stripe_owners` over the
KV plan to check the counts the sequence shard derived from the same plan;
it checks each partner's planned queries against them instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

`SeqShardSpec.derive` filtered its node's specs by `attends_split_steps`, then
type-switched to ask a paged attention `resolve_exchange(specs)` and a ragged
one `.sp_exchange`: a hook in disguise. `resolve_exchange(specs)` is now the
one `NodeResourceSpec` hook (default "none"; the paged spec resolves its
`sp_exchange` against its pool's storage, the ragged self-attention returns
its declared one), and the derive asks every spec over its nodes. Deletes
`attends_split_steps`.

Also deletes the base `splits_steps` hook, which only `SeqShardSpec` answered
(with its `splits` field) and only the engine still read; the engine now reads
`isinstance(spec, SeqShardSpec) and spec.splits`, as the rest of the shard
code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed where it acts (abstractions-4 #5)

- `merge_into` re-implemented `_merged`'s math for two partials. The ring now
  merges each round's partial into its running result with `_merged`, as
  `ContextParallel` already does for its two parts; `merge_into` is deleted.
- "sp_exchange set where the node's steps never split" was refused in two
  places (`AttentionSpec.derive` without a shard, `sequence_parallel_refusals`
  at one SP rank). `sequence_parallel_refusals` now refuses both cases.
- Connecting every pair of a group once is the communicator's job:
  `CommGroup.connect_pairs` replaces `AttentionManager._warm_ring`.
- `_ring_wrapper` moves from the `AttentionManager` base to
  `FlashInferManager`, the one backend that takes both split steps and a
  placed pool and the only one whose plan builds a ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons-4 #2)

`RingAttentionWrapper.attend` now takes `queries` as a tuple (the shared
plan-state signature), and the loop rebound that name to each round's
partner queries; it is `theirs` now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…label (cleanup4-review P3-1)

A pool stored by head holds this rank's tp * sp slice of the heads, so a layer that writes K/V
itself (write_target) or calls attn.run on a copied step passes the TP heads where the pool holds
fewer, silently. check_bypass now refuses any call outside an exchange over such a pool
(by_head, from the caller's placement), so the next fused-write layer that forgets to check
`splits` fails at its first step instead of writing the wrong heads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng (cleanup4-review P3-2)

Only FlashInferManager builds a RingAttentionWrapper, but exchange sends any placed ring node to
the ring. A backend that later gains split steps (MLA) would plan a ContextParallelWrapper and be
handed this rank's striped share of q, a plan/run mismatch deep in the kernel. The placed-pool
refusals at build now include ring on any backend but flashinfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…timate (cleanup4-review P3-3)

Each ring round merges into a new fp32 result while the previous one is alive. Per the review,
the code stays (the merge math lives in one place, `_merged`); the term is now in the doc's
unmeasured peak-memory estimate so whoever measures the ring/all-gather crossover counts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt (cleanup4-review P3-4)

RingAttentionWrapper names `_merged` (what it calls) and types its queries as
ContextParallelWrapper.attend does; the tokens comment in AttentionCallable says "a stand-in KV"
without the tests' parenthetical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eview-2 P3-1)

A KV pool keeps one sequence shard, which its writes' check_bypass consults
whichever node's step is running. Two SeqShardSpecs over nodes that share a
by-head pool left the pool with the last one bound, so a write inside the
other shard's exchange raised as if it were outside one.
bind_sequence_shard now raises when a different shard is already bound,
decided from config at load so every rank refuses alike. Rebinding the same
shard (several attentions over one pool, as in Cosmos3) is unchanged. The
docstring now says any write outside an exchange raises, and the SP docs
name the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cleanup4-review-2 P3-2)

Since fac250f check_bypass refuses any call outside an exchange over a pool
stored by head, not only a split label. The KVManager._sequence_shard comment
and the exchange comment in AttentionManager now say "outside an exchange" and
point to check_bypass, whose docstring carries both cases. The third comment,
bind_sequence_shard's docstring, was updated with the P3-1 commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n B)

Option B moves the node-level split choice from the paged attention's
per-resource `sp_exchange` to the sequence shard's YAML, beside
`min_tokens`: `exchange: auto | none | ulysses | all_gather | ring`.

- `auto` (the default) derives from storage: Ulysses over KV stored by
  head, copied (DCP, never split) over KV stored by position, so existing
  configs run as before. `none` never splits. `ulysses` needs KV by head,
  `all_gather` and `ring` KV by position; a value the node's storage can't
  take is refused at load, at any SP size, with its reason.
- Each paged attention derives its exchange from its pool's storage and
  the shard's choice through `resolve_exchange`, which the manager reads at
  build from its dependencies. Deleted: `AttentionSpec.sp_exchange`, its
  resolved twin `split_exchange`, the YAML plumbing, and the refusals only
  that field needed (a set value with no shard or at one SP rank, a paged
  attention taking none beside a split, its storage mismatch).
- The ring's striped layout is now chosen by `exchange: ring`.
- Ragged `sp_exchange="ulysses"` stays a model declaration.
- Thinker TP4xSP2 config: `thinker_shard: {min_tokens: 8192, exchange:
  all_gather}`. Docs: the how-to-pick table is per shard `exchange`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s over TP (optionB-review P2-1)

A ragged attention declaring sp_exchange="ulysses" planned heads/(tp*sp)
heads whenever its node had a sequence shard, even one whose exchange
(none, or auto over KV stored by position) means the node never splits.
The node then runs copied and feeds the attention heads/tp heads, which
the wrapper wasn't planned for. The builder now also reads the shard's
derived `splits`. Pinned in test_a_shard_that_never_splits_plans_every_step_copied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ld (optionB-review P3-1)

`AttentionManager._build_backend` refuses exchange 'ring' on a backend
other than FlashInfer over a context_parallel pool at sp=1 too (TP-mode
context parallelism), where the shard is a no-op. That matches the
shard's own exchange-vs-storage checks, which also run at any SP size,
so the rule stays and the docs say so, and that it fires when the engine
builds the attention rather than in pre-spawn validation.
test_attention_a_placed_pool_cannot_serve_is_refused_at_build already
pins it at sp=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nB-review P3-2)

The shard's `exchange` picks how split steps reach the node's paged
attentions; a ragged attention keeps the model's `sp_exchange`, and only
the shard's `none` (or `auto` over KV stored by position) reaches it, by
stopping the node splitting. Said beside the serving.rst example and in
the SeqShardSpec docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…endencies (optionB-review-2 P0-1)

A ragged Ulysses attention now reads its sequence shard's `splits` from
`EngineResourceInfo.dependencies`. The two hand builders of LTX-2.5's
declaration (test_ltx2_5_sequence_parallel._resources and
benchmark/ltx2_5/profile_step.ragged_denoise) shared one info with no
dependencies, so building VIDEO_ATTN raised KeyError. Pass each spec its
`depends_on` specs, as Engine does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts (optionB-review-2 P2-1)

Scope the RaggedAttentionSpec docstring's head-sharding and return_lse
sentences, and the adding_models.rst table cell and no-op sentence, to a
node whose sequence shard splits, matching _build_ragged since 8d522e6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RaggedAttentionSpec docstring said run(..., return_lse=True) raises on
any node whose shard splits. SeqShardSpec.splits doesn't depend on the SP
size, and at one SP rank the shard can't split, so the resource attends
without an exchange and returns the log-sum-exp. Say it raises on SP ranks
of a splitting node, and give the return_lse paragraph in adding_models.rst
the same scope.

The resource table's RaggedAttentionSpec row now says Ulysses splits the
heads over TP x SP only on a node whose shard splits; elsewhere they stay
over TP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The RaggedAttentionSpec row said the heads split across the node's TP
ranks without saying that a Ulysses resource on a splitting node splits
them over TP x SP, which the same cell explains a few lines down. Point
at that case.

The return_lse paragraph ran two cases into one sentence, so the
partial merge read as if a Ulysses self-attention did it. Name both
cases that can't return a log-sum-exp (every label of a "ulysses"
resource, and a cross-attention pair whose keys split), then say the
merge belongs to the split-key cross-attention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dghosef
dghosef marked this pull request as ready for review November 4, 2026 19:27

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant