Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
16e4f7b
[PCP] Fewer prefill collectives per layer, no reindex readback
yitingw1 Oct 28, 2026
dc90ce2
[PCP] DSpark: project context KV on each rank's own rows
yitingw1 Oct 28, 2026
7017852
[PCP] Grouped gather: fall back off aiter's 2/4/8 ranks; check row co…
yitingw1 Oct 28, 2026
222feeb
fix(config): key the compile cache on the KV cache dtype
yitingw1 Oct 28, 2026
b491763
[PCP] TBO: hold comm-stream gather outputs until the compute stream r…
yitingw1 Oct 28, 2026
6b49222
[PCP] TBO: run the PCP comm phases through tbo_on_comm
yitingw1 Oct 28, 2026
9d1db12
Merge remote-tracking branch 'origin/main' into yitingw1/pcp_opt
yitingw1 Oct 28, 2026
e182354
[PCP] TBO: hold every comm-stream output; one wire format for grouped…
yitingw1 Oct 29, 2026
e8ff821
test(tbo): stub aiter's communication_op in the TP all-reduce test
yitingw1 Oct 29, 2026
7e2920f
[PCP] Grouped gather: drop the 6-rank special case, rely on aiter
yitingw1 Oct 29, 2026
fd0893d
test(h2d): give the PCP reindex test the host prefill indptr
yitingw1 Oct 30, 2026
319ffe1
test(h2d): bring the V4 indexer publication GPU test up to date
yitingw1 Oct 30, 2026
fd912b7
DSV4 PCP: assert host and GPU paged-prefill indptrs agree in length
yitingw1 Nov 2, 2026
c889ff9
Merge branch 'main' into yitingw1/pcp_opt
yitingw1 Nov 2, 2026
65fed29
Merge remote-tracking branch 'origin/main' into yitingw1/pcp_opt
yitingw1 Nov 10, 2026
c9b9de7
DSV4 PCP: stage only this rank's paged-prefill rows on main's stage/f…
yitingw1 Nov 10, 2026
aee343c
Drop the duplicate kv_cache_dtype compile-hash factor left by the mai…
yitingw1 Nov 10, 2026
1e582ae
Merge remote-tracking branch 'origin/main' into yitingw1/pcp_opt
yitingw1 Nov 11, 2026
5e4e8bb
fix(spec_decode): check target_pcp_split before PCP world size in pcp…
yitingw1 Nov 11, 2026
c5b86da
fix(v4 attn): make _apply_pcp_reindex take cu_q / block_tables explic…
yitingw1 Nov 12, 2026
4149e4e
Merge origin/main into yitingw1/pcp_opt
yitingw1 Nov 13, 2026
32bf41e
test(comm-fused-moe): ids travel as int32, drop the no-op view
yitingw1 Nov 13, 2026
f7d83ab
dspark(v4.1): drop the unreachable PCP branch from write_context_kv
yitingw1 Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions atom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2822,6 +2822,10 @@ def compute_hash(self) -> str:
factors.append(bool(getattr(self.dcp_config, "indexer_dcp_only", False)))
factors.append(self.enable_dp_attention)
factors.append(self.index_cache_dtype)
# The KV cache dtype is fixed at trace time too: the DSpark drafter
# branches on it (`dspark_fp8_planned`), so a bf16 run over an fp8 run's
# cache loads a graph built for the other window layout.
factors.append(self.kv_cache_dtype)
# DeepSeek-V4.1 decoder SWA bounded replay compiles the backbone as two
# graphs (early + late layers) instead of one; both compile under the
# `backbone` tag, so the flag must key the artifacts.
Expand Down
72 changes: 60 additions & 12 deletions atom/distributed/pcp_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
get_prefill_context_model_parallel_world_size,
)

from atom.utils import upload_numpy


class PcpBalGroup(NamedTuple):
"""One request group for PCP+TBO request-boundary split prefill.
Expand Down Expand Up @@ -105,7 +107,12 @@ def pcp_round_robin_split(
# Divisibility by pcp_size is guaranteed upstream by pcp_pad_len (callers
# pad before splitting); the view below would error if violated.
rest = tuple(input_.shape[1:])
return input_.view(-1, pcp_size, *rest)[:, pcp_rank].contiguous()
# Not `.contiguous()`: a one-row shard counts as contiguous while its row
# stride is still pcp_size, which kernels that check strides reject (aiter's
# cached-positions RoPE).
return input_.view(-1, pcp_size, *rest)[:, pcp_rank].clone(
memory_format=torch.contiguous_format
)


def pcp_allgather_rerange(
Expand All @@ -127,16 +134,47 @@ def pcp_allgather_rerange(
group = get_pcp_group()
# aiter all_gather(dim=0) returns rank-major concat: [pcp*L, *rest].
gathered = group.all_gather(input_.contiguous(), dim=0)
local_len = input_.shape[0]
rest = tuple(input_.shape[1:])
return _rerange(gathered, pcp_size, input_.shape[0])


def pcp_allgather_rerange_many(

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.

pcp_allgather_rerange (group.all_gather) and pcp_allgather_rerange_many (device_communicator.all_gatherv via gather_rows) do the same thing by two transports, with different dtype handling and fallbacks. The single-tensor one could be _many([x])[0]. Callers that gather several tensors back to back should then use one call:

  • drafter.py:490, which this PR touches, gathers each aux buffer separately;
  • attention_mla.py:3079-3081, three gathers;
  • deepseek_v2.py:1732-1733 and :2857-2861.

Fixing it here and leaving those (Fix-then-sweep) keeps both paths alive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

pcp_allgather_rerange(x) is now pcp_allgather_rerange_many([x])[0], and the back-to-back call sites you listed (MLA, V2 indexer and hs+aux, the sglang plugin, the V4 extend-K path) now issue one grouped gather. The few remaining single calls are either not back-to-back or, in model_runner, per-group gathers with different row counts that _many can't batch.

inputs: list[torch.Tensor], pcp_size: int | None = None
) -> list[torch.Tensor]:
"""`pcp_allgather_rerange` of every tensor in `inputs` (same row count) in
one grouped collective. One-byte dtypes travel as uint8: past its custom
all-gather size limit `all_gatherv` falls back to PyNCCL, which has no fp8
dtype."""
if pcp_size is None:
pcp_size = get_pcp_world_size()
if pcp_size <= 1:
return list(inputs)
assert all(t.shape[0] == inputs[0].shape[0] for t in inputs), [
t.shape[0] for t in inputs
]
# aiter's custom all-gatherv kernel exists for 2, 4 and 8 ranks only, while
# its custom all-reduce (which `all_gatherv` tries first) also runs at 6.
if pcp_size not in (2, 4, 8):

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.

The 6-rank fallback lives in this caller (hard-coded (2, 4, 8)) instead of in aiter's custom_all_gatherv, which doesn't check world size — dispatchAllGatherV throws "allgatherv world_size error" in its default branch. Other callers that go straight to device_communicator.all_gatherv remain unprotected: with pcp=6 (or dp=6) and custom all-reduce on, FusedMoE's PCP routed-rows gather (tbo_on_comm(gather) → all_gatherv(ts, heights, pcp_group)) and dp_gather_routed_rows take the custom path, hipMemcpyAsync into the registered pool, then hit the runtime_error and crash the process. The root fix is for aiter's custom_all_gatherv to return None when world_size ∉ {2, 4, 8}, so every caller falls through to PyNCCL and this special case can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Fixed in ROCm/aiter#7149, which also gates the reduce-scatter and all-gather (they returned garbage at 6 ranks) and fixes an out-of-bounds read in the 6-rank one-stage all-reduce that made -pcp 6 decode fault. I dropped the ATOM special case; this PR now requires #7149 (-pcp 6 gsm8k 0.97).

return [pcp_allgather_rerange(t, pcp_size) for t in inputs]
sent = [
t.contiguous().view(torch.uint8) if t.element_size() == 1 else t.contiguous()

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.

This adds its own "one-byte dtypes travel as uint8" wire convention, duplicating fused_moe/routed_rows.gather_rows (which swaps in a float carrier by byte width), and the two disagree about the constraint: gather_rows' comment says PyNCCL and the custom all-gather only accept float dtypes, while this sends uint8 and int64 positions directly. Either that comment is stale for the target aiter, or uint8/int64 fail on the custom/PyNCCL paths here. Suggest reusing gather_rows (or moving it down into pcp_utils) so there's one convention and one statement of the constraint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The gather_rows comment was stale: grouped all_gatherv copies bytes and PyNCCL accepts uint8 / int32 / int64. Unified into one gather_rows in pcp_utils (one-byte dtypes as uint8, the rest as is), shared with MoE.

for t in inputs
]
gathered = get_pcp_group().device_communicator.all_gatherv(sent, dim=0)
return [
_rerange(g.view(t.dtype), pcp_size, t.shape[0])
for g, t in zip(gathered, inputs)
]


def _rerange(gathered: torch.Tensor, pcp_size: int, local_len: int) -> torch.Tensor:
# rank-major [pcp, L, *rest] -> transpose -> token-major [L, pcp, *rest]
# -> flatten to [L*pcp, *rest] == original global order.
out = (
rest = tuple(gathered.shape[1:])
return (
gathered.view(pcp_size, local_len, *rest)
.transpose(0, 1)
.reshape(pcp_size * local_len, *rest)
)
return out


# ==== MoE-path PCP collectives (rank-major gather + reduce_scatter) ====
Expand Down Expand Up @@ -246,10 +284,22 @@ def pcp_pad_dense(t: torch.Tensor, n_pad: int) -> torch.Tensor:
return torch.cat([t, t.new_zeros(n_pad, *t.shape[1:])], dim=0)


def pcp_local_indptr(indptr: np.ndarray, owned: np.ndarray) -> np.ndarray:
"""Host side of `pcp_reindex_ragged`: the prefix sum of the `owned`
queries' segment lengths in `indptr`. Owned ids past its last query are
padding queries, whose segments are empty."""
lens = np.diff(indptr)
lens = np.pad(lens, (0, max(int(owned.max(initial=-1)) + 1 - lens.shape[0], 0)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nothing checks the host indptr matches the device one. add a cheap shape/last-value assert so a mismatch fails loud instead of giving wrong indices?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in fd912b7: a length check at both reindex entries (rank and PCP+TBO group). It reads shapes only; comparing values would need a device read-back, which is the sync this PR removes.

out = np.zeros(owned.shape[0] + 1, dtype=np.int32)
np.cumsum(lens[owned], out=out[1:])
return out


def pcp_reindex_ragged(
kv_indptr: torch.Tensor, # [T_global + 1] int32 — global per-query prefix sum
kv_indices: torch.Tensor, # [kv_indptr[-1]] — ragged packed values
owned_q: torch.Tensor, # [T_local] long — global query ids this rank owns
indptr_local_cpu: np.ndarray, # [T_local + 1] — `pcp_local_indptr`
) -> tuple[torch.Tensor, torch.Tensor]:
"""Reindex a ragged (indptr, indices) pair down to this rank's queries.

Expand All @@ -262,17 +312,15 @@ def pcp_reindex_ragged(
Used to shard the per-query prefill index buffers (kv_indptr/kv_indices
_prefix_swa / _extend) to 1/W while the values themselves still point into
the full KV (paged unified_kv) / full extend kv tensor.

`indptr_local` comes from the host (`indptr_local_cpu`), so sizing the
output reads nothing back from the device.
"""
device = kv_indptr.device
owned_q = owned_q.to(device)
starts = kv_indptr[owned_q] # [T_local]
ends = kv_indptr[owned_q + 1] # [T_local]
lens = ends - starts # [T_local] per-owned-query segment length
indptr_local = torch.zeros(
owned_q.shape[0] + 1, dtype=kv_indptr.dtype, device=device
)
torch.cumsum(lens, dim=0, out=indptr_local[1:])
total = int(indptr_local[-1].item())
indptr_local = upload_numpy(indptr_local_cpu, device)

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.

Performance item 1: upload_numpy here runs every prefill forward, for each of the four fields. Its docstring reserves it for one-off and size-varying uploads ("A buffer uploaded every forward should be a CpuGpuBuffer"). With the builder producing the local indptr (comment on deepseek_v4_attn.py:406), this upload and the arange / searchsorted / gather that follow go away. If it stays, it needs a persistent CpuGpuBuffer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The per-step reindex kernel and its asserts are removed (see the indptr comment above), and so is the csa_indices.fill_(-1); totals are read from the host-side staging arrays, so this path has no D2H.

total = int(indptr_local_cpu[-1])
if total == 0:
return indptr_local, kv_indices.new_empty(0)
# Build a gather map: for each output slot, which source index to read.
Expand Down
30 changes: 27 additions & 3 deletions atom/model_ops/attentions/deepseek_v4_attn.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
get_pcp_rank,
get_pcp_world_size,
pcp_is_enabled,
pcp_local_indptr,
pcp_owned_rows_by_request,
pcp_pad_dense,
pcp_pad_indptr,
Expand Down Expand Up @@ -388,6 +389,9 @@ class AttentionMetaData_DSV4(AttentionMetaData):
(same `kv` shared by all 3 ratios; one builder pass)."""
kv_indptr_extend: torch.Tensor | None = None
"""[total_tokens + 1] int32 GPU — packed cumsum of `extend_count`."""
paged_prefill_indptr_cpu: dict[str, np.ndarray] | None = None

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.

paged_prefill_indptr_cpu mirrors the four kv_indptr_* fields in a string-keyed dict, so two copies have to be kept in step by hand. The length asserts added in fd912b7 are there to catch them drifting. "paged" also doesn't fit kv_indptr_extend. The builder already has these indptrs in numpy before upload. Computing the rank-local (or PCP+TBO group-local) indptr there and uploading only that would remove the mirror, the asserts and the per-step re-uploads in pcp_reindex_ragged (performance item 1) in one change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We went further than mirroring: each rank now stages its own indptrs from numpy through the CpuGpuBuffer publication group and launches the paged-prefill index kernel only over the tokens it owns, so the host mirror, the pad/reindex kernels and upload_numpy are all removed. What remains per step is a small arange + gather for the indexer's positions. On V4-Pro -tp 1 -pcp 8, ISL 16K, mbt 4096, TTFT drops 9.8 % at concurrency 1 and 11.1 % at concurrency 8 against the PR base (11.0 % / 8.5 % with --enable-tbo prefill). That arm also carries the grouped-gather changes, so it is the combined effect.

"""Host copies of the four `kv_indptr_*` above, keyed by field name. PCP
reindexes from these, so it never reads the GPU copies back."""


class DeepseekV4Backend(AttentionBackend):
Expand Down Expand Up @@ -2868,10 +2872,13 @@ def _apply_pcp_reindex(
# on its own pcp pad). Either way the divisor is pcp_size.
padded_total = pcp_pad_len(total_tokens, pcp_size)
n_pad = padded_total - total_tokens
# This rank's round-robin queries, generated on the GPU.
# This rank's round-robin queries, on the GPU and on the host.
owned_q = torch.arange(get_pcp_rank(), padded_total, pcp_size, device=device)
owned_cpu = np.arange(get_pcp_rank(), padded_total, pcp_size)

# --- ragged per-query buffers: pad indptr to padded_total, then 1/W ---
indptr_cpu = attn_metadata.paged_prefill_indptr_cpu
Comment thread
yitingw1 marked this conversation as resolved.
Resolved
local_indptr_cpu = {}
for ind_attr, idx_attr in (
("kv_indptr_prefix_swa", "kv_indices_prefix_swa"),
("kv_indptr_prefix_csa", "kv_indices_prefix_csa"),
Expand All @@ -2883,9 +2890,14 @@ def _apply_pcp_reindex(
if indptr is None or indices is None:
continue
indptr = pcp_pad_indptr(indptr, n_pad) # dummy queries: 0-length KV
new_indptr, new_indices = pcp_reindex_ragged(indptr, indices, owned_q)
local_cpu = pcp_local_indptr(indptr_cpu[ind_attr], owned_cpu)
new_indptr, new_indices = pcp_reindex_ragged(
indptr, indices, owned_q, local_cpu
)
setattr(attn_metadata, ind_attr, new_indptr)
setattr(attn_metadata, idx_attr, new_indices)
local_indptr_cpu[ind_attr] = local_cpu
attn_metadata.paged_prefill_indptr_cpu = local_indptr_cpu

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

overwrites the full-batch indptr in place, a second call would silently give too-short kv. keep it local instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Each metadata object is reindexed once: the non-TBO path reindexes the full batch once, and the PCP+TBO path reindexes each group's fresh ub once while reading the untouched full src. The device indptrs are already replaced in place (as on main). Writing the host copy back keeps the two in agreement; keeping it local would leave a host copy describing the full batch next to rank-local device indptrs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this field is documented as the global host copies, now we write local ids into it.. use a separate field?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworded in fd912b7: the host copies mirror whichever kv_indptr_* the metadata currently carries; after either reindex (rank or PCP+TBO group) both describe the same rank- or group-local queries.


# --- dense per-token fields: pad then round-robin-slice to 1/W ---
if attn_metadata.skip_prefix_len_csa is not None:
Expand Down Expand Up @@ -3184,6 +3196,8 @@ def _build_ubatch_prefill_metadata_balanced(

# ---- per-token DSV4 fields sliced by the GLOBAL token range [gts,gte) ----
owned = torch.arange(gts, gte, device=device)

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.

Performance item 2: a group's queries are the contiguous global range [gts, gte), and the host indptr is already here (src_cpu). For a contiguous range, the local indices are indices[src_cpu[gts]:src_cpu[gte]], a view, and the local indptr is src_cpu[gts:gte+1] - src_cpu[gts]. No torch.arange(gts, gte), no pcp_local_indptr, and no pcp_reindex_ragged kernels or upload. The general reindex is only needed for the round-robin rank split.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Instead of slicing the whole-batch meta, each TBO group now stages its own ub{i}_ paged-prefill rows with the same per-rank split. test_v4_tbo_group_paged_prefill_indices_match_the_whole_batch checks the result against the whole-batch build for ws 2/3/4/6/8.

owned_cpu = np.arange(gts, gte)
ub.paged_prefill_indptr_cpu = {}
for ind_attr, idx_attr in (
("kv_indptr_prefix_swa", "kv_indices_prefix_swa"),
("kv_indptr_prefix_csa", "kv_indices_prefix_csa"),
Expand All @@ -3194,7 +3208,11 @@ def _build_ubatch_prefill_metadata_balanced(
indices = getattr(src, idx_attr, None)
if indptr is None or indices is None:
continue
ni, nx = pcp_reindex_ragged(indptr, indices, owned)
local_cpu = pcp_local_indptr(
src.paged_prefill_indptr_cpu[ind_attr], owned_cpu
)
ni, nx = pcp_reindex_ragged(indptr, indices, owned, local_cpu)
ub.paged_prefill_indptr_cpu[ind_attr] = local_cpu
# kv_indices_extend are ROW offsets into the per-fwd kv_full tensor.
# In the full metadata they index the WHOLE sequence's kv_full [0,T);
# for this group kv_full only holds the group's tokens (global order
Expand Down Expand Up @@ -3856,6 +3874,12 @@ def _build_paged_prefill_meta(
attn_metadata.kv_indptr_prefix_csa = csa_indptr
attn_metadata.kv_indices_prefix_hca = hca_indices[:hca_total]
attn_metadata.kv_indptr_prefix_hca = hca_indptr
attn_metadata.paged_prefill_indptr_cpu = {
"kv_indptr_extend": ext_indptr_np,
"kv_indptr_prefix_swa": swa_indptr_np,
"kv_indptr_prefix_csa": csa_indptr_np,
"kv_indptr_prefix_hca": hca_indptr_np,
}
attn_metadata.skip_prefix_len_csa = skip_csa_gpu
attn_metadata.envelope_rows = envelope_rows

Expand Down
23 changes: 9 additions & 14 deletions atom/model_ops/moe.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
from atom.utils.tbo.ubatching import (
tbo_active,
tbo_enabled,
tbo_on_comm,
tbo_switch_to_compute_sync,
tbo_yield_and_switch_from_compute_to_comm,
)
Expand Down Expand Up @@ -5372,19 +5373,17 @@ def _run_experts(
topk_ids,
x_scale,
)
tbo = tbo_active()
if tbo:
tbo_yield_and_switch_from_compute_to_comm()
# One grouped collective, rank-major: every PCP shard is one height.
pcp_group = get_pcp_group()
heights = [hidden_states.shape[0]] * pcp_group.world_size
hidden_states, topk_weights, topk_ids, *scale = gather_rows(
lambda ts: all_gatherv(ts, heights, pcp_group), rows
)

def gather():
out = gather_rows(lambda ts: all_gatherv(ts, heights, pcp_group), rows)
reassign_gathered_shared_ids(self, out[2])
return out

hidden_states, topk_weights, topk_ids, *scale = tbo_on_comm(gather)
x_scale = scale[0] if scale else None
reassign_gathered_shared_ids(self, topk_ids)
if tbo:
tbo_switch_to_compute_sync()
gathered = gathered or split
topk_weights, topk_ids = self.quant_method.place_experts(
self, topk_weights, topk_ids, gathered
Expand All @@ -5407,11 +5406,7 @@ def _run_experts(
),
)
if split:
if tbo:
tbo_yield_and_switch_from_compute_to_comm()
output = pcp_reduce_scatter(output)
if tbo:
tbo_switch_to_compute_sync()
output = tbo_on_comm(pcp_reduce_scatter, output)
elif pcp:
output = pcp_all_reduce(output)
return output
Expand Down
Loading
Loading