Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
ba71ea2
[feature] Support pp>1 for Kimi-K3, with DSpark speculative decoding
ganyi1996ppo Oct 27, 2026
0eac576
review: move the verify-window shift into the runner, clarify spec flags
ganyi1996ppo Oct 30, 2026
a247dab
test: skip test_verify_ctx_lens without AITER, like the other model_r…
ganyi1996ppo Oct 30, 2026
539e3fe
review: derive the PP aux relay layout from ascending tap order and c…
ganyi1996ppo Oct 30, 2026
a913f2b
review: release a batch's checkpoint holds before any per-seq work
ganyi1996ppo Oct 30, 2026
458260b
review: make the K3 DSpark skip prefixes a tuple the PP stage rebinds
ganyi1996ppo Oct 30, 2026
cba6844
review: fill the PP verify draft columns in one vectorized scatter
ganyi1996ppo Oct 30, 2026
9981597
review: read undeferred spec counts back in one D2H
ganyi1996ppo Oct 30, 2026
1f17859
review: pack the PP aux relay into one preallocated send buffer
ganyi1996ppo Oct 30, 2026
a128906
Merge main (0e71a5273) into guanbao/k3-pp2tp4
wanzhenchn Oct 31, 2026
6808659
review: size the PP aux relay send buffer to the forward's rows
ganyi1996ppo Nov 1, 2026
acdf74d
review: drop the stale read-side reason from _checkpoint_room
ganyi1996ppo Nov 1, 2026
f34cb8a
review: flush a held chunk's hashes on the PP ack path too
ganyi1996ppo Nov 1, 2026
5ab3d68
Merge main (99ebf633a) into guanbao/k3-pp2tp4
ganyi1996ppo Nov 2, 2026
a0b8ef0
review: let any PP aux relay tap allocate, and check every own tap fired
ganyi1996ppo Nov 2, 2026
f0522ff
review: build the PP aux relay in spec_decode, not in the runner
ganyi1996ppo Nov 2, 2026
9bebbb3
review: size a P/D first decode's neighbours from the shifted context…
ganyi1996ppo Nov 2, 2026
401bcae
review: read a forked state through the kernels' read-side index
ganyi1996ppo Nov 2, 2026
549ae77
review: refuse unwired PP combinations at config time
ganyi1996ppo Nov 2, 2026
c823e96
review: plan the vLLM plugin's dense MLA work at the layer's padded w…
ganyi1996ppo Nov 2, 2026
5b0b85d
review: drop the PP checkpoint hold's dead prompt-end case
ganyi1996ppo Nov 2, 2026
7a61f50
review: install the PP aux relay's taps through the drafter's own helper
ganyi1996ppo Nov 2, 2026
b5821ff
review: send the PP aux relay's payload without a second clone
ganyi1996ppo Nov 2, 2026
400f1f7
review: one use_spec on every stage, runner and scheduler alike
ganyi1996ppo Nov 2, 2026
202906e
review: one source each for the deferred-output mode and the verify w…
ganyi1996ppo Nov 2, 2026
74d743b
review: top-level imports, and document PP + DSpark for Kimi-K3
ganyi1996ppo Nov 2, 2026
3fc180e
review: keep the DSpark draft out of online quant in the PP2 accuracy…
ganyi1996ppo Nov 2, 2026
f660797
review: keep the PP aux relay's hook free of in-place set mutation un…
ganyi1996ppo Nov 2, 2026
64bda0c
review: read undeferred spec counts off the sampled ids, without a D2H
ganyi1996ppo Nov 2, 2026
89b996c
review: stage the forked write slots through pinned memory
ganyi1996ppo Nov 2, 2026
e1c80ce
review: the relay's docstring follows the spec's layer_ids order
ganyi1996ppo Nov 2, 2026
c4975cd
Merge main (825b63ba4) into guanbao/k3-pp2tp4
ganyi1996ppo Nov 3, 2026
2edbab7
review: pass the runner's attn_family to defers_output again
ganyi1996ppo Nov 3, 2026
5661b9f
review: gate PP placeholders on the batch's frozen chunk state
ganyi1996ppo Nov 3, 2026
91e51df
review: release a checkpoint hold only from the chunk that holds it
ganyi1996ppo Nov 3, 2026
1a570a2
review: let a P/D producer under PP keep any drafter
ganyi1996ppo Nov 3, 2026
d9f26d5
review: read a forked spec row's source in every column
ganyi1996ppo Nov 3, 2026
7a64ee6
review: keep the spec-path fork assert unless the backend opts in
ganyi1996ppo Nov 3, 2026
7e45c5b
review: refuse TBO under PP and drop the relay's TBO grow branch
ganyi1996ppo Nov 3, 2026
c024c82
review: seed ReplaySSM forks from host-known slot pairs, without a sync
ganyi1996ppo Nov 3, 2026
cf98ae7
review: write relay captures without bookkeeping Dynamo freezes
ganyi1996ppo Nov 3, 2026
cd0d315
review: let the registry declare which spec methods an arch serves un…
ganyi1996ppo Nov 3, 2026
e65bb71
review: name the checkpoint hold by what it does
ganyi1996ppo Nov 3, 2026
3a2258b
review: stage the undeferred window from the recorded placeholder width
ganyi1996ppo Nov 3, 2026
50998fd
review: let the drafter read the relayed aux block in place
ganyi1996ppo Nov 3, 2026
81917c0
review: derive DSpark's instance aux spec from its classmethod
ganyi1996ppo Nov 3, 2026
30829a3
review: drop the CLAUDE.md key list and getattrs on always-set fields
ganyi1996ppo Nov 3, 2026
337bcd5
Merge main (a8946ff73) into guanbao/k3-pp2tp4
ganyi1996ppo Nov 3, 2026
a1436cd
review: allocate aux buffers only for this stage's own taps
ganyi1996ppo Nov 4, 2026
b62bf7a
review: share one tap residency rule between the drafter and the relay
ganyi1996ppo Nov 4, 2026
c10069c
Merge main (ab4ada40a) into guanbao/k3-pp2tp4
ganyi1996ppo Nov 5, 2026
83e3223
fix: share the target's embedding with the DSpark draft on the last P…
ganyi1996ppo Nov 5, 2026
9fb45ff
Merge main (058c21957) into guanbao/k3-pp2tp4
ganyi1996ppo Nov 5, 2026
6bcb0de
Merge guanbao/k3-pp2tp4 (9fb45ff6f) into guanbao/k3-pp2tp4-k31-embed
ganyi1996ppo Nov 5, 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
13 changes: 13 additions & 0 deletions .github/benchmark/models_accuracy.json
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,19 @@
"accuracy_baseline_model": "moonshotai/Kimi-K3 + Inferact/Kimi-K3-DSpark",
"_baseline_note": "Kimi-K3 DSpark block spec-decode (7 tokens) on the Kimi-K3 target with the Inferact/Kimi-K3-DSpark draft. Spec-decode is lossless w.r.t. the target, so baseline reuses the Kimi-K3 FP8 base (0.95); threshold 0.94 matches the target. mtp_accept_threshold intentionally omitted until the first CI run reports the DSpark acceptance rate -- add it once measured."
},
{
"model_name": "Kimi-K3 DSpark PP2",
"model_path": "moonshotai/Kimi-K3",
"extraArgs": "--kv_cache_dtype fp8 -tp 4 --pipeline-parallel-size 2 --enforce-eager --trust-remote-code --gpu-memory-utilization 0.9 --block-size 128 --enable_prefix_caching --method dspark --draft-model Inferact/Kimi-K3-DSpark --num-speculative-tokens 7 --max-num-seqs 64 --online_quant_config '{\"global_quant_config\": \"ptpc_fp8\", \"exclude_layer\": [\"lm_head\", \"model.embed_tokens\", \"*self_attn.[qkv]_conv1d*\", \"*block_sparse_moe.experts*\", \"*block_sparse_moe.routed_expert_*\", \"*vision_tower*\", \"*mm_projector*\", \"context_proj\", \"layers\"]}'",
"env_vars": "",
"runner": "linux-atom-do-mi350x-8",
"test_level": "pr",
"accuracy_threshold": 0.94,
"accuracy_timeout_minutes": 60,
"accuracy_baseline": 0.95,
"accuracy_baseline_model": "moonshotai/Kimi-K3 + Inferact/Kimi-K3-DSpark",
"_baseline_note": "The only pipeline-parallel accuracy case: Kimi-K3 pp2 x tp4 with DSpark (7 tokens) and prefix caching on, which together exercise the PP-only paths -- the undeferred verify window, the cross-stage aux relay, the checkpoint hold and a KDA state fork landing on a verify step. Eager because PP decode has no CUDA graphs yet, hence the longer timeout. ptpc_fp8 online quant on the target only, as the Kimi-K3 TP8 case runs it (that case has no drafter); \"context_proj\" and \"layers\" (the draft's unprefixed module names, which no target module matches) keep the DSpark draft in BF16 as the Kimi-K3 DSpark TP8 case runs it. Spec decode is lossless, so baseline and threshold match Kimi-K3."
},
{
"model_name": "Kimi-K3.1-Pro",
"model_path": "moonshotai/Kimi-K3.1-Pro",
Expand Down
47 changes: 47 additions & 0 deletions atom/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2624,6 +2624,7 @@ def __post_init__(self):
# builds the model.
self.offload_kv_encoding = snapshot_kv_encoding(self.hf_config)
self._resolve_dynamic_chunking()
self._check_pp_support()

if self.speculative_config is not None:
num_spec = self.speculative_config.num_speculative_tokens
Expand Down Expand Up @@ -2835,6 +2836,52 @@ def __post_init__(self):

self.state_replay_tokens = state_replay_tokens(self)

def _check_pp_support(self) -> None:
"""Refuse what PP does not wire yet: TBO, P/D consumers, and speculation
an architecture does not declare in ``pp_speculative_methods`` (a P/D
producer does not speculate); anything else fails mid-serving or drafts
from zero buffers.
"""
if self.pipeline_parallel_size <= 1:
return
# UBatchWrapper.forward takes no intermediate_tensors.
if self.enable_tbo:
raise ValueError(
"TBO (enable_tbo) is not supported with pipeline_parallel_size > 1."
)
from atom.kv_transfer.disaggregation.pd_producer import (
disaggregated_kv_role,
)

# A consumer's first decode leaves T0 plus mtp_k placeholders, not the
# mtp_k + 1 the PP verify window assumes, and the PP loop has no
# remote-KV path at all.
role = disaggregated_kv_role(self)
if role == "kv_consumer":
raise ValueError(
"A P/D kv_consumer is not supported with pipeline_parallel_size > 1."
)
# A producer never speculates; it loads the drafter only to write the
# draft layer's KV.
if self.speculative_config is None or role == "kv_producer":
return
from atom.models.registry import pp_speculative_methods

archs = getattr(self.hf_config, "architectures", None) or ()
spec = self.speculative_config
method = "dspark" if spec.use_dspark() else spec.method
if not any(method in pp_speculative_methods.get(a, ()) for a in archs):
raise ValueError(
"pipeline_parallel_size > 1 with speculative decoding supports "
"only the methods registry.pp_speculative_methods declares, got "
f"architectures={list(archs)}, method={method!r}."
)
if self.dspark.confidence_schedule:
raise ValueError(
"The DSpark confidence scheduler (confidence_schedule) is not "
"supported with pipeline_parallel_size > 1."
)

def _resolve_dynamic_chunking(self) -> None:
"""Validate dynamic chunking, or turn it off where it cannot size a chunk.

Expand Down
26 changes: 22 additions & 4 deletions atom/distributed/pp_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,18 @@

# Keys carried between pipeline stages. `sparse_kv_indices` is optional: present
# only when a PP boundary splits a DSA IndexShare group (see model_runner).
_PP_PROXY_KEYS = ("hidden_states", "residual", "sparse_kv_indices")
# `block_residual` is Kimi-K3's AttnRes block window [T, k, H]. `PP_AUX_KEY`
# relays a drafter's target aux hidden states (`PPAuxRelay`, set/popped by
# model_runner). Keys missing here are silently dropped by the send.
PP_AUX_KEY = "pp_aux"

_PP_PROXY_KEYS = (
"hidden_states",
"residual",
"block_residual",
"sparse_kv_indices",
PP_AUX_KEY,
)


def pp_send_allgather_group():
Expand Down Expand Up @@ -125,15 +136,21 @@ def _async_send_object(


def async_send_intermediate_tensors(
it: IntermediateTensors,
it: IntermediateTensors, owned: tuple[str, ...] = ()
) -> list[P2PWork]:
"""Non-blocking send of hidden_states/residual to the next PP stage."""
"""Non-blocking send of hidden_states/residual to the next PP stage.

``owned`` keys name tensors the caller hands over and never touches
again: they are sent as they are. The rest are cloned, since the model
may overwrite them (a graph's output buffer, say) before the send lands.
"""
pp = get_pp_group()
dst_local = (pp.rank_in_group + 1) % pp.world_size
dst_global = pp.ranks[dst_local]

tensors = {k: it.tensors[k] for k in _PP_PROXY_KEYS if k in it.tensors}
metadata_list, tensor_list = _split_tensor_dict(tensors)
owned_ids = {id(tensors[k]) for k in owned if k in tensors}

works = _async_send_object(metadata_list, dst_global, pp.cpu_group)

Expand All @@ -144,9 +161,10 @@ def async_send_intermediate_tensors(
for tensor in tensor_list:
if tensor.numel() == 0:
continue
is_owned = id(tensor) in owned_ids
if ag_group is not None and tensor.numel() % ag_size == 0:
tensor = tensor.reshape(ag_size, -1)[ag_rank]
buf = tensor.clone()
buf = tensor if is_owned else tensor.clone()
if buf.device.type == "cpu":
w = torch.distributed.isend(buf, dst=dst_global, group=pp.cpu_group)
else:
Expand Down
121 changes: 86 additions & 35 deletions atom/model_engine/model_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
pcp_round_robin_split,
)
from atom.distributed.pp_comm import (
PP_AUX_KEY,
async_send_intermediate_tensors,
commit_pp_send_work,
recv_intermediate_tensors,
Expand All @@ -54,7 +55,11 @@
from atom.model_engine.output_transfer import D2HQueue
from atom.model_engine.page_unit_checkpoint import PagedStateCheckpointSpec
from atom.model_engine.run_labels import build_run_label
from atom.model_engine.scheduler import ScheduledBatch, ScheduledBatchOutput
from atom.model_engine.scheduler import (
ScheduledBatch,
ScheduledBatchOutput,
defers_output,
)
from atom.model_engine.sequence import (
Sequence,
SequenceStatus,
Expand Down Expand Up @@ -100,6 +105,7 @@
from atom.sampling_params import RequiredChannel
from atom.spec_decode.drafter import Drafter
from atom.spec_decode.factory import build_drafter
from atom.spec_decode.pp_aux_relay import PPAuxRelay
from atom.spec_decode.status import SpecStatusQueue
from atom.utils import (
CpuGpuBuffer,
Expand Down Expand Up @@ -200,17 +206,7 @@ def __init__(
# The scheduler never speculates for a P/D producer, though the runner
# keeps its drafter loaded: every decode it runs is q=1.
self.is_pd_producer = _kv_config_has_producer(kv_cfg)
# P/D hands off prompt-end state plus the first sampled token. Deferred
# output makes the producer decode once more to surface that token, so
# the consumer applies T0 to a state that already has it. Only a
# recurrent state notices -- a paged write lands at T0's own position,
# so repeating it is idempotent.
hands_off_recurrent_state = (
self.is_pd_producer and runner.attn_family.has_recurrent_state
)
self.is_deferred_out = (
not self.is_pipeline_parallel and not hands_off_recurrent_state
)
self.is_deferred_out = defers_output(runner.config, runner.attn_family)

self.runner = runner
device = runner.device
Expand All @@ -236,6 +232,8 @@ def __init__(
self.batch_info = CpuGpuBuffer(
3, dtype=torch.int32, device=device, publication_group="input_ids"
)
# On every PP stage: each runs the verify windows (draft-id staging,
# state rollback), though only the last one owns the drafter.
self.use_spec = use_spec
self.num_spec_tokens = num_spec_tokens
# The drafter's `draft_tokens_per_seq`, set once it is loaded.
Expand Down Expand Up @@ -345,6 +343,20 @@ def clean(self):
self.num_rejected: np.ndarray | None = None
self.num_bonus: np.ndarray | None = None

def verify_ctx_lens(self, context_lens: np.ndarray) -> np.ndarray:
"""`batch.context_lens` moved back to the start of this verify window.

Deferred output: the last step's `num_rejected` slots are still in
`context_lens`. Undeferred (PP): postprocess already left one run of
`mtp_k + 1` placeholders, so the shift is 1. Before any sampler output
there is nothing to shift. Only a verify step has a window, so every
caller sits behind `max_seqlen_q > 1`.
"""
if self.num_rejected is None:
return context_lens
shift = self.num_rejected if self.is_deferred_out else 1
return context_lens - np.asarray(shift, dtype=context_lens.dtype)

@staticmethod
def _batch_process_token_ids(token_ids: list) -> list[tuple[int, ...]]:
"""Batch process token_ids: vectorized -1 truncation using numpy."""
Expand Down Expand Up @@ -563,25 +575,40 @@ def _stage_input_ids(
token_ids = scheduled_tokens[
total_tokens_prefill : total_tokens_prefill + total_tokens_decode
]
# No spec path supports pipeline parallel yet; it wants the deferred
# branch's per-request staging. Gate on PP itself rather than on
# `use_spec`: a P/D producer also reaches this branch, and the flag
# is still set there (the runner keeps the drafter loaded to write
# the draft's context KV at prefill) even though the scheduler
# turned speculation off for it.
if self.use_spec and self.is_pipeline_parallel:
raise NotImplementedError("pipeline parallel + speculative decode")
# PP verifies nothing locally and a P/D producer never speculates
# (its DP dummy included), so no row here carries drafts. One that
# did would need the deferred branch's per-request staging: under
# DP a block drafter's `max_seqlen_q` is the group's maximum, not
# this batch's row width.
assert batch.num_spec_step == 0, (
"non-deferred decode got a speculative batch "
f"(num_spec_step={batch.num_spec_step})"
)

self.input_ids.np[:total_tokens_decode] = token_ids
# A P/D producer never speculates (its DP dummy included), so no
# row here carries drafts. One that did would need the deferred
# branch's per-request staging: under DP a block drafter's
# `max_seqlen_q` is the group's maximum, not this batch's row
# width. Under PP only a producer is held to it; a PP consumer
# verifies its own drafts below.
if not self.is_pipeline_parallel or self.is_pd_producer:
assert batch.num_spec_step == 0, (
"non-deferred decode got a speculative batch "
f"(num_spec_step={batch.num_spec_step})"
)
if not self.is_pipeline_parallel:
return (
self._publish_input_ids(total_tokens_decode, publication_group),
None,
)
if self.use_spec and batch.num_spec_step > 0:
# PP: the host already holds each row's anchor, so fill only
# the draft columns.
bs, lens, cu_np = self.runner.attn_metadata_builder.decode_spans(batch)
spec = batch.scheduled_spec_decode_tokens[:bs]
cols = np.arange(spec.shape[1])
# Row i's drafts land right after its anchor at cu_np[i].
drafted = cols < (lens[:, None] - 1)
self.input_ids.np[(cu_np[:bs, None] + 1 + cols)[drafted]] = spec[
drafted
]
# Previous step's acceptance counts, read off the batch so every
# stage (not just the sampling one) rolls its linear-attention
# state back correctly; unset, GDN assumes 1 accepted token.
if self.use_spec:
self.num_rejected = batch.num_rejected
self.num_bonus = batch.num_bonus
return (
self._publish_input_ids(total_tokens_decode, publication_group),
None,
Expand Down Expand Up @@ -811,7 +838,9 @@ def __init__(self, rank: int, config: Config):
default_dtype = self.config.torch_dtype
torch.set_default_dtype(default_dtype)
torch.set_default_device(self.device)
use_spec = bool(self.config.speculative_config) and get_pp_group().is_last_rank
# Every PP stage runs the verify window, drafter or not: its width
# sizes the KDA conv state and MLA work metadata.
use_spec = bool(self.config.speculative_config)
self.num_spec_tokens = (
self.config.speculative_config.num_speculative_tokens if use_spec else 0
)
Expand Down Expand Up @@ -927,6 +956,7 @@ def __init__(self, rank: int, config: Config):
logger.info("TBO enabled: model wrapped with UBatchWrapper")
if getattr(self, "drafter", None) is not None:
self.drafter.arm_aux_capture(self.model)
self.pp_aux_relay = PPAuxRelay.build(self.config, self.model)
installer = mono_decode_installers.get(hf_config.architectures[0])
if installer is not None:
self.model = resolve_obj_by_qualname(installer)(
Expand Down Expand Up @@ -3191,7 +3221,7 @@ def run_model(
if pp_enabled:
self._setup_pp_shared_indexer()

intermediate_tensors = None
intermediate_tensors = recv_aux = None
if pp_enabled and not pp_group.is_first_rank:
intermediate_tensors = recv_intermediate_tensors()
# GLM-5.2 IndexShare: load prior rank's top-k for leading
Expand All @@ -3202,6 +3232,11 @@ def run_model(
if recv_sparse is not None and self._pp_recv_needs_sparse:
tgt = self.attn_metadata_builder._sparse_kv_indices_gpu
tgt[: recv_sparse.numel()].copy_(recv_sparse)
# DSpark aux hidden states from target layers on earlier
# stages. Popped for the same reason as the top-k above.
recv_aux = intermediate_tensors.tensors.pop(PP_AUX_KEY, None)
if self.pp_aux_relay is not None and pp_group.is_last_rank:
self.pp_aux_relay.absorb(recv_aux, self.drafter)

# Time only the chunk computation between PP receive and send.
chunk_sample = self._dynamic_chunking.start_sample(batch)
Expand All @@ -3228,10 +3263,18 @@ def run_model(
model_output.tensors["sparse_kv_indices"] = (
self.attn_metadata_builder._sparse_kv_indices_gpu[:n]
)
if self.pp_aux_relay is not None:
aux = self.pp_aux_relay.pack(
recv_aux,
model_output.tensors["hidden_states"].shape[0],
)
if aux is not None:
model_output.tensors[PP_AUX_KEY] = aux
if self._pp_pending_send:
commit_pp_send_work(self._pp_pending_send)
# The relay hands its payload over: no clone needed.
self._pp_pending_send = async_send_intermediate_tensors(
model_output
model_output, owned=(PP_AUX_KEY,)
)
hidden_states = None
logits = None
Expand Down Expand Up @@ -3566,8 +3609,16 @@ def postprocess(
prev_rejected_num = np.zeros(0, dtype=np.int32)
prev_bonus_num = np.zeros(0, dtype=np.int32)
else:
prev_rejected_num = np.zeros(batch.total_seqs_num, dtype=np.int32)
prev_bonus_num = np.zeros(batch.total_seqs_num, dtype=np.int32)
# Undeferred: report THIS step's counts; the next batch carries them
# back for linear-attention state rollback. Zeros if no drafts scored.
if spec_decode_metadata is not None:
# From the host ids, no D2H: accepted drafts + 1, then -1s.
emitted = np.fromiter(map(len, token_ids_out), np.int32)
prev_bonus_num = emitted - 1
prev_rejected_num = sampled_tokens.shape[-1] - emitted
else:
prev_rejected_num = np.zeros(batch.total_seqs_num, dtype=np.int32)
prev_bonus_num = np.zeros(batch.total_seqs_num, dtype=np.int32)
# PP stages and P/D producers still run the drafter.
if hasattr(self, "drafter"):
# Mid-prompt sequences get their anchor corrected inside
Expand Down
Loading