Skip to content

[DO NOT review, pending K3 text PP merging] [Kimi K3] Add K3 MoonViT DEP support to schedule ViT stages into text LLM PP bubbles - #4381

Draft
QIU023 wants to merge 15 commits into
pytorch:mainfrom
QIU023:k3_pp_mm
Draft

[DO NOT review, pending K3 text PP merging] [Kimi K3] Add K3 MoonViT DEP support to schedule ViT stages into text LLM PP bubbles#4381
QIU023 wants to merge 15 commits into
pytorch:mainfrom
QIU023:k3_pp_mm

Conversation

@QIU023

@QIU023 QIU023 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Draft, stacked on the text-side PP PR: the diff tab shows that PR's content too, so review only the last commit (kimi_k3: DEP -- the vision tower takes a pipeline stage of its own). It will be rebased when the text PR lands.

Summary

Report sec 5.2.3: the vision tower gets a pipeline stage of its own ahead of the text stages, so its compute leaves the critical path of the stage that owns the embedding and can hide in pipeline bubbles. Opt-in via vit_dep because it changes the stage count, which the schedule and the checkpoint layout both see; with it off, this commit changes nothing.

Design

  • The tower stage is the FQN split alone, no core change: _inject_kimi_k3_fqns prepends a [tok_embeddings, vision_encoder] stage and takes it OUT of the text stage budget (the schedule asserts num_stages % pp_degree == 0, so appending would break pp=2 at the first vision stage).
    • The tower rides WITH the embedding: the splice needs token ids, which only stage 0 receives; the text stack's forward already treats missing tok_embeddings as "input IS the hidden state".
  • Engagement is asserted, not inferred: each vision stage is wired with its micro-batch index, the wired count is checked against what the rank should own by stage index, and a mismatch raises -- an unwired share passes activations through unprocessed and reports no error otherwise.
  • A stage owns the tower if it HOLDS one (walking wrapped modules), not if it is a particular class: FSDP2 rewrites the module class, and the folded layout never constructs a dedicated ViT stage class, so a type test wired nothing on a correct run.
  • The topology record moves from an adapter-local dataclass to knobs.py and gains the vision fields (vit_dep, vit_dep_stages, vit_prefetch, vit_bubble): these decide the pipeline topology, so every rank must resolve them identically, once, at the pipelining entry.
  • vit_dep_stages > 1 raises: splitting the tower across stages needs the split to address vision_encoder.layers, which core's _split_module cannot reach on this layout; the share entry points (forward_head/body/tail, contiguous block ranges recomputing position tables per share) are in place for when it can.
  • Two placement knobs for the encode, alternatives by construction: vit_prefetch issues the encode for micro-batch m+k on a side CUDA stream during m's text compute (cross-stream tensor lifetime handled by wait/record_stream on both edges), and vit_bubble runs planned encodes in the schedule's idle intervals on the main stream, with deferred tower backwards bounded by a GradQueue.

Results

Draft placeholder: the tables below were measured on the integration tree this commit is extracted from (8x RTX 5060 Ti, seed 42, --debug.deterministic, steps 1/3/10 protocol), not on this branch; they will be re-measured on this branch before the draft is undrafted.

Tower-stage split: pp2/pp4/pp8 all reproduce dp1's step-1 loss bitwise, with step-2 drift 1.85e-3 (reduction order across the stage boundary); with the delta transport on, pp2xvp2 / pp2xvp4 / pp4xvp2 / pp8xvp4 keep step 1 bitwise and stay within 1.5e-3 at step 2. Timing at debug scale: the bubble arm fills 8/8 planned slots with 0 fallbacks and costs +4.2% per step (the tower is too small to pay for placement there), and the prefetch arm is bitwise on loss with noise-level step time -- both are groundwork knobs, off by default, measured so their engagement is provable rather than assumed.

Changed files

torchtitan/models/kimi_k3/
  pipeline_adapter.py     +442/-86  the DEP stage budget and FQN placement, stage
                              wiring + engagement assertion, prefetch/bubble install;
                              the topology record moves out to knobs.py
  dep_vision_stage.py     +270  the ViT stage module for a split tower (share entry)
  dep_bubble_backward.py  +248  GradQueue: deferred tower backwards, bounded
  dep_bubble_plan.py      +244  bubble placement plans from the schedule's own shape
  dep_bubble_runtime.py   +176  runs planned encodes in schedule idle intervals
  vit_prefetch.py         +235  the run-ahead: per-step feature cache on a side stream
  knobs.py                +113  the topology record, moved and extended
  vit_cp_plan.py          +136  stage-boundary packing: config-level upper bounds,
                              never batch-derived (P2P buffers are sized once)
  vision_encoder.py       +139  the share entry points: head / body / tail
  model.py                +107  the DEP config fields; encode_images and the
                              vision-stream issue/join pair
tests/unit_tests/cpu/test_kimi_k3_vit_stage_shares.py  +90

CI/CD Coverage

CPU tests: the share split's block-bound invariants (test_kimi_k3_vit_stage_shares); the FQN-injection tests on the base branch already pin the DEP stage placement, including the text-model-with-None-tower shape that once produced a silently wrong pipeline.

QIU023 and others added 15 commits August 25, 2026 01:32
Add pipeline parallelism for the Kimi K3 text decoder on top of PR-4025:

- pipeline_adapter.py: PP pipelining_fn with FQN injection for the text
  decoder (tok_embeddings + layers.N + norm/lm_head/output_res_proj/
  output_res_norm split) and the CrossStageCacheAdapter for the AttnRes
  block-residual cross-stage carry under Interleaved1F1B.
- model.py: thread block_residual_TND across stage boundaries and return
  (hidden, block_residual) from a non-head stage; the head stage aggregates.
- parallelize.py: allow PP; set pp_enabled on the FSDP calls.
- layout.py, knobs.py: block stack/unstack layout tables and the
  attn_res_cache topology knob the adapter reads.
- __init__.py, config_registry.py: register the pipelining_fn and the
  text-only debugmodel_text / debugmodel_text_32l flavors.
- tests/test_pp_fqn_injection.py: CPU test for the FQN split.
…ation cells

The FQN-injection test sat in torchtitan/models/kimi_k3/tests/, the only
in-model tests directory in the tree; the convention is tests/unit_tests/.
Moved and renamed to carry the model name.

Adds two recipes and their integration cells: pp2 on the plain schedule, and
pp2 with two virtual stages on the interleaved one, so both the block-residual
carry and the interleaved path are covered by CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBy1d9YVu44nYCVqykRqL1
The pp2 x vp2 cell exercised two boundaries. One layer per stage over 32 stages
puts the block residual across every boundary the schedule has, which is the
thing this PR adds; the 32-layer text flavor is what makes it expressible.
Runs in the 8-GPU features workflow (ngpu=8); the pp2 cell stays for the
cheap path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBy1d9YVu44nYCVqykRqL1
The CPU unit-test workflow collects tests/unit_tests/cpu, not the top level, so
the FQN-injection test would have sat there un-run; move it. And both PP
integration cells now declare use_real_pg: validate_fake_pg_compatibility
rejects pipeline_parallel_degree > 1 under Fake PG, so without it the features
suite fails to build rather than skipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBy1d9YVu44nYCVqykRqL1
pipeline_kimi_k3_with_cache_adapter becomes pipeline_kimi_k3; the environment
variable debug channel and its six call sites go, and with them the last reader
of os in this file; the knob record keeps the one field this branch carries and
drops the environment fallback, so a knob is honoured from config or not at all
rather than silently from a stale export. Comment blocks compressed to the
invariant they guard.

adapter_enabled's docstring no longer says the curves match from step 1 -- the
transport changes the order the block gradients are summed and is not bitwise
against the naive one, which is what the measurements show. The even-split
requirement now names the two config fields a user would have to check.

Configuration-identical: 4810, 4678 and 6198 fields compared over the union of
both key sets, zero differences.
… caller goes

knobs.py existed for one boolean and had exactly one importer. Folded into
pipeline_adapter.py above its only reader, where the register-once/read-many
lifecycle is visible in one screen, and the three entry points drop to
module-private.

layout.py stays its own file: it is offline algebra over
(pp, vp, num_blocks, n_layers, layers_per_block) with no torch distributed, no
schedule and no state, and it is the part of this PR that can be tested on its
own. stack_blocks had no caller anywhere on this branch and is removed; the
carrier is only ever unstacked.

Configuration-identical: 4810, 4678 and 6198 fields over the union of both key
sets, zero differences.
…at all

register_topology reads eight knobs off the model config with getattr and a
default. None of them was ever declared on KimiK3Model.Config, so until the
environment fallbacks were retired they were reachable only through KIMI_VIT_*
and TORCHTITAN_ATTNRES_CACHE; since then they have been reachable through
nothing. adapter_enabled's docstring calls attn_res_cache a config gate and the
config had no such gate.

The consequence for attn_res_cache is the one that matters: with it unreachable,
pipeline_kimi_k3 returns passthrough on every run, so the cross-stage cache
adapter and the whole of layout.py never execute. Thirteen pipeline cells were
measured that way and none of them touched the code they were meant to cover --
the adapter logs a line when it wraps stages, and that line appears in none of
the run logs.

Declared in the shape the neighbouring fields already use: a dataclass field
with the invariant it guards above it. kimi_k3_debugmodel_text_32l_cache turns
the transport on, the same way kimi_k3_debugmodel_dyncp lowers the dynamic-CP
threshold. Defaults are unchanged, so no existing configuration moves.
…llelism

It was declared off, which made the fallback the thing every configuration
selected and the transport the thing nothing selected. Under pipeline
parallelism the transport is what this model ships: shipping the whole block
stack on every hop is the fallback for the schedules it cannot serve, not the
normal path.

It still engages only on Interleaved1F1B with an even split; plain 1F1B warns
and passes through, unchanged. kimi_k3_debugmodel_text_32l_naive selects the
fallback so a matrix can put the two side by side.
…ibutes

The adapter read num_blocks and layers_per_block off the stage's module and
num_hidden_layers off the model config. None of those three exists here: they
are the reference tree's shapes, where a wrapper model carried the first two and
the config carried the third. Every pipeline run therefore took the branch that
says 'this is a baseline non-AttnRes run' and returned passthrough, so the
adapter and the whole of layout.py never executed -- on any cell, at any degree,
whatever attn_res_cache was set to.

The layout is defined by the config in the first place: a block opens every
attn_res_block_size layers, so the layer count and that size give both numbers.
Deriving them there needs nothing added to the model.

The baseline-vs-AttnRes branch goes with it. That distinction belonged to a tree
with two models; this one has a single model whose attn_res_block_size is a
required parameter, so the branch could only ever fire on the shape mismatch it
was not written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBy1d9YVu44nYCVqykRqL1
EOF
Four assumptions in the adapter belonged to the reference tree's model and none
of them held here, so the delta transport had never executed once.

The carrier reaches the model as its second positional argument, not as a
blocks= keyword. The token axis is the hidden state's first dimension, because
batch and sequence are folded into one; multiplying the first two dimensions
gave T * D and sized a receive buffer that failed the residual's concatenation.
The model returns the carrier it was handed with this stage's commits appended,
not the commits alone, so the new blocks are the tail past what went in. And
model_parts is left alone: the schedule runs what stage.submod points at, while
the trainer reaches through model_parts for the real module -- it sets
_skip_lm_head there, so substituting the wrapper made the last stage apply
lm_head and the loss apply it again, and it would have prefixed every checkpoint
key with the wrapper's attribute name.

pp2 x vp2, 32 layers, seed 42, deterministic, three steps, transport against
fallback on the same tree:

    step 1   12.46351   12.46351    grad_norm 15.3125 both
    step 2    9.49685    9.49556
    step 3    6.95172    6.95147

The forward is bit-identical, which is what routing the same blocks a different
way should give. The gradients are not, which is what changing the order they
are summed in should give. Both statements were already in the file, in two
comments that read as contradicting each other; they do not.
… cut carried along

It overrides a core hook this branch does not introduce, so nothing calls it here.
…avors go

Same review direction as the EP and CP PRs: no text-only flavor. The 32-layer
flavor keeps the tower -- it rides with the embedding on the first stage and
takes no stage of its own, so the pipeline x virtual-stage arithmetic is
unchanged and 32 still divides every product. The pp2 and pp8_vp4 CI cells and
their recipes move to the multimodal names, and kimi_k3_debugmodel_text plus
its model-registry entry leave the branch.

Smoked before the switch: pp8 x vp4 on kimi_k3_debugmodel_32l, the adapter
wrapping the tower-bearing first stage -- wrapped [0, 8, 16, 24] on rank 0,
zero fallback warnings, two steps, exit 0.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 30, 2026
@QIU023
QIU023 marked this pull request as draft August 30, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant