Skip to content

Commit b4f3593

Browse files
committed
[Docs] Record the routes measured for selective checkpointing under compile
Why a unit needs the compiler's cooperation at all, the two resolutions a unit can have and what each costs, and the four routes measured and not taken -- each with the numbers that decided it, on Qwen3-MoE-30BA3 at `ep_size=4`. The load-bearing results: withdrawing the callable that encloses attention costs −9.9% for a unit that op identity delivers for free, which is why the two resolutions exist; and `fx_traceback.annotate` survives compilation perfectly and still changes peak memory by zero bytes, because the outer checkpoint discards what the partitioner decided. Removing that checkpoint is what would make the tag route work, and it costs 61.8 GiB.
1 parent 0658d6e commit b4f3593

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# Selective checkpointing under `torch.compile`
2+
3+
Why unit-level SAC needs the compiler's cooperation, which routes exist, and what each one
4+
measured. All numbers are Qwen3-MoE-30BA3, `ep_size=4` on 8 GPUs, `dispatcher="deepep"`,
5+
torch 2.10, reported as the mean of steps 5-8 of an 8-step run. Two shapes recur:
6+
7+
- **8k**`pack_max_length=8192`, no domino. Baseline 8904.4 tgs, 84.32 GB peak.
8+
- **4k**`pack_max_length=4096` with domino (`intra_layer_micro_batch=2`). Baseline 7812.4 tgs.
9+
10+
The 4k shape is CPU-bound (GPU busy 55-60%), so every CPU-side cost lands fully in wall time there
11+
and is largely hidden at 8k. The same change reads as −0.9% at 8k and −13.9% at 4k; neither number
12+
is wrong, they measure different regimes.
13+
14+
## The problem
15+
16+
The checkpoint policy is asked about every op that reaches the dispatcher, and it has to answer one
17+
question: does this op belong to a unit the user asked to keep. There are exactly two ways to know,
18+
and which one applies is a property of the unit, not a style choice.
19+
20+
**Naming the op.** Works when the op is specific enough to identify the unit on its own —
21+
`flash_attn::_flash_attn_varlen_forward` appears nowhere else in the model. Costs nothing: the
22+
compile set is untouched, no graph breaks, and it works identically inside and outside compiled
23+
code, because the ops worth keeping are exactly the ones inductor cannot fuse and those are the ones
24+
still reaching the dispatcher.
25+
26+
**Naming the callable.** Works for anything, at a price. The marker is ordinary python state — a
27+
`ContextVar` set around the callable — and compiled code neither writes nor reads it: reading one
28+
inside a `fullgraph=True` region is a hard compile error, and a value written during tracing is
29+
traced away. So the callable has to leave the compiled set, via `torch._dynamo.disable`.
30+
31+
This is not a visibility problem. With the whole layer compiled the policy is still consulted
32+
**117597 times per run**, because ops that inductor cannot fuse still go through the dispatcher.
33+
Measured on this model, 26 distinct ops reach it, among them `flash_attn::_flash_attn_varlen_forward_v2`,
34+
`moe::m_grouped_gemm`, `moe::permute`/`unpermute` and `aten::mm.out`; 61% of the calls are
35+
`aten::record_stream`. What is missing for a callable-scoped unit is the region identity, not the op.
36+
37+
## The route taken: op identity where possible, withdrawal where not
38+
39+
`RecomputeTargetMap` binds each unit a model supports to one of the two resolutions. MoE declares
40+
`SAVE_ATTN` as `KeptOps` and the gate and dispatch stages as `KeptCallables`.
41+
42+
Two mechanics of the withdrawal are easy to get wrong:
43+
44+
- Removing an entry from `compile_cfg` does **not** keep it out of the compiled set. Dynamo inlines
45+
it into whichever compiled caller reaches it, and the marker is traced away exactly as before.
46+
`torch._dynamo.disable` is what makes it run in python.
47+
- A disabled callee inside a `fullgraph=True` region is a hard error
48+
(`Skip inlining torch.compiler.disable()d function`), not a split. The surviving entries are
49+
therefore relaxed to `fullgraph=False`.
50+
51+
Measured, 8k, `save_attn`, when it was still resolved by withdrawing the callable that encloses it:
52+
53+
| | tgs | peak | compiled graphs / captured calls | kept |
54+
|---|---|---|---|---|
55+
| no unit | 8904.4 | 84.32 GB | 6 / 154 ||
56+
| withdrawal, marker never fired | 8883.1 | 84.32 GB | 6 / 154 | nothing |
57+
| withdrawal, marker firing | 8021.7 | 112.14 GB | 4 / 26 | 42112 tensors |
58+
59+
−9.9% for the same unit that op identity delivers for free. The cost is concentrated, not diffuse:
60+
`_pre_moe_forward` accounts for 128 of the 154 captured calls, because it exists to give
61+
`torch.compile` the ops on either side of attention. Withdrawing it is most of what an MoE layer
62+
compiles. That is why attention resolves by op identity and why `KeptCallables` should name the
63+
*smallest* callable that covers the unit: the compilation given up is the whole callable's.
64+
65+
## What the shipped units cost
66+
67+
Measured on the shipped resolutions, 16k domino (`pack_max_length=8192`,
68+
`intra_layer_micro_batch=2`), `ep_size=4`, deepep. **Three independent runs per setting**, because
69+
one run per setting is not enough to say anything about throughput here:
70+
71+
| unit | tgs (3 runs) | mean | peak allocated | peak reserved |
72+
|---|---|---|---|---|
73+
| none | 10206.0 / 9990.6 / 10205.9 | 10134.2 | 84.4-85.3 GB | 108.5-109.3 GB |
74+
| `save_attn` | 10165.6 / 10204.6 / 10071.2 | 10147.2 | 90.4-91.2 GB | 114.4-115.3 GB |
75+
| `save_moe_gate` | 10095.2 / 10179.5 / 10144.4 | 10139.7 | 93.7-94.5 GB | 117.7-118.7 GB |
76+
| `save_moe_dispatch` | OOM ||||
77+
78+
**Throughput is unchanged.** The three means are within 0.1% of each other, while the baseline's own
79+
three runs span 2.1%. Any single-run comparison of these units reads as ±2% in whichever direction
80+
the run-to-run variance happened to fall, and means nothing.
81+
82+
**Memory is the reproducible effect**: `save_attn` costs +6.0 GB allocated, `save_moe_gate` +9.2 GB,
83+
each within ±0.4 GB across runs.
84+
85+
So a unit here buys nothing on this model and costs memory. That is not a statement about the
86+
mechanism -- it is a statement about which activations an MoE layer's recompute is actually spent
87+
on, and the census below says why.
88+
89+
`save_moe_dispatch` is not usable at this shape at all. It keeps the permutation and padding buffers
90+
on both sides of the all-to-all, the widest tensors in the layer, and domino already runs at 109 GB
91+
reserved of 140 GB. It OOMs during the first step -- and also OOMs without domino, and at
92+
`pack_max_length=4096`, where it peaks at 111.9 GB against the baseline's 85.9 GB. It is verified
93+
numerically for one step (see below) and declared for smaller models, not as a default here.
94+
95+
## Numerical correctness
96+
97+
Checkpointing changes only the backward pass, so **step-1 loss must be bit-identical** whatever is
98+
kept. It is, across every setting and both shapes -- `2.46262765` at 8k, `2.38410378` at 4k,
99+
including the `save_moe_dispatch` run that OOMs immediately afterwards.
100+
101+
Gradients need a noise floor to interpret, which is why the baseline was run twice under identical
102+
settings:
103+
104+
| | step-1 loss | step-1 grad_norm | vs baseline |
105+
|---|---|---|---|
106+
| baseline | 2.46262765 | 24.39401245 ||
107+
| baseline, second run | 2.46262765 | 24.39329147 | 3.0e-5 |
108+
| `save_attn` | 2.46262765 | 24.39325905 | 3.1e-5 |
109+
| `save_moe_gate` | 2.46262765 | 24.39400864 | 1.6e-8 |
110+
111+
Every unit sits at or below the floor two identical runs produce. The floor itself comes from
112+
reduction ordering in the grouped GEMM and the all-to-all, and exists with no unit selected.
113+
114+
Measured eager (`torch_compile=False`, all2all, no domino) so that compilation cannot be the source
115+
of a difference; the compiled path is covered by the throughput runs above.
116+
117+
## Whether a unit repays its cost
118+
119+
On this model: no. Every unit raises peak reserved -- keeping activations is the trade -- and none
120+
of them buys back measurable throughput. The census below says why.
121+
122+
A resident-activation census under the shipped configuration (domino, 8k, whole layer checkpointed)
123+
found **245 tensors totalling 15.27 GiB**:
124+
125+
| producer | count | bytes |
126+
|---|---|---|
127+
| `LogSoftmaxBackward0 (16384, 151936) float32` | 1 | 9.27 GiB |
128+
| layer-boundary `(1, 8192, 2048)` bf16 | 94 | 2.94 GiB |
129+
| `TBackward0 (2048, 151936)` bf16 | 1 | 0.58 GiB |
130+
| everything else (60 producers) | 149 | ~2.5 GiB |
131+
132+
The loss logits alone are 61% of the resident set, and no recompute unit addresses them. Nothing
133+
inside the MoE layers survives — the whole-layer checkpoint is doing its job. That bounds how much
134+
any unit-level policy can win here.
135+
136+
## Routes measured
137+
138+
### Keeping the expert GEMM by op identity
139+
140+
The same resolution `SAVE_ATTN` uses, pointed at the ops that dominate an MoE layer's recompute:
141+
142+
| kept | tgs (8k) | peak | captured |
143+
|---|---|---|---|
144+
| baseline | 8904.4 | 84.32 GB | 154 |
145+
| `flash_attn::_flash_attn_varlen_forward_v2` | 8828.2 | 87.31 GB | 154 |
146+
| `moe::m_grouped_gemm` | **9477.3 (+6.4%)** | 105.25 GB | 154 |
147+
| `moe::m_grouped_gemm` + `permute` + `unpermute` | **9616.7 (+8.0%)** | 122.61 GB | 154 |
148+
149+
Not declared as a unit here because +20 to +38 GB is not a trade a user can currently tune — the
150+
selection is per-model, not per-layer. It is the obvious next unit once the selection can say
151+
"in the first N layers". The limit of op identity is expressiveness: it cannot reach anything fused
152+
into a generated kernel, and it cannot scope a unit to part of the model. It is what torchtitan does
153+
(`torchtitan/distributed/activation_checkpoint.py`), with a preset op list and no region concept.
154+
155+
### Withdrawing a smaller callable
156+
157+
Making the excluded callable smaller does not make the exclusion cheaper in proportion, because
158+
`torch._dynamo.disable` breaks the graph once **per level of the inline stack it has to unwind**,
159+
not once per call:
160+
161+
| cut point | inline depth | breaks | captured | tgs (8k) |
162+
|---|---|---|---|---|
163+
| `MultiHeadAttention.forward` | 1 | 2 | 60 | 8101.7 |
164+
| `attn_imp.flash_attention` | 2 || 96 | 8664.5 |
165+
| `flash_attn_varlen_func` | 3 | 4 | 104 | 8660.3 |
166+
167+
Cutting deeper leaves more code compiled but unwinds more levels; the two effects cancel. A
168+
withdrawal is cheapest when its boundary is *shallow*, near the compile entry point — the opposite of intuition.
169+
170+
The two resolutions also cannot be made equivalent. Even at the tightest cut, the withdrawal keeps
171+
`aten::alias` alongside the attention kernel, because a `dynamo.disable`d segment runs eagerly and
172+
eager execution dispatches bookkeeping ops that do not exist in the compiled path.
173+
174+
### Letting the marker run while compiling, and taking the graph break
175+
176+
Instead of withdrawing the callable, let the marker execute during tracing and accept the graph
177+
break Dynamo takes at it. The marker becomes live and the policy sees the unit — but only for ops
178+
that reach the dispatcher, so it keeps 752 tensors where withdrawing the callable keeps 42112.
179+
180+
Measured, 8k, `save_attn`: 8578.4 tgs, 86.03 GB, 11 graphs / 90 captured, 5 breaks. Cheaper than
181+
withdrawing the callable and more expensive than op identity, with a third semantics again. Not
182+
adopted because "keep this unit" should not silently mean "keep the handful of ops in it that
183+
inductor happened not to fuse".
184+
185+
### `fx_traceback.annotate` plus a joint-graph pass
186+
187+
The only marker that survives compilation intact. Dynamo executes it during tracing
188+
(`_dynamo/variables/ctx_manager.py`), stamps `node.meta["custom"]`, and AOT carries it through
189+
decomposition and into the backward nodes. Verified on GPU under `fullgraph=True`: 6 graphs, 154
190+
captured calls, **0 graph breaks** — identical to the baseline — with the joint pass seeing 410
191+
annotated nodes and pinning 386.
192+
193+
And it changes nothing. Peak memory stayed at 84.32 GB, byte for byte.
194+
195+
The reason is a level mismatch. `torch.utils.checkpoint(use_reentrant=False)` is implemented with
196+
saved-tensor hooks: its `pack_hook` replaces every tensor autograd saves inside the region with a
197+
holder and frees it. A compiled region is an ordinary `autograd.Function`, so the tensors the
198+
partitioner chose to save are handed to `ctx.save_for_backward` and discarded like everything else.
199+
The partitioner's decision is made and honoured — its *product* is thrown away.
200+
201+
So the tag route requires the partitioner to be the only decider, which means removing the outer
202+
checkpoint.
203+
204+
### Removing the outer checkpoint
205+
206+
Then the partitioner governs, and the tags work. It also costs 61.8 GiB.
207+
208+
| | resident tensors | resident bytes |
209+
|---|---|---|
210+
| whole layer checkpointed | 245 | 15.27 GiB |
211+
| no checkpoint, everything forced to `MUST_RECOMPUTE` | 1706 | 77.04 GiB |
212+
213+
`MUST_RECOMPUTE` applies *within* one joint graph. It cannot recompute across a graph boundary,
214+
because the backward of each graph needs that graph's inputs. Domino + EP splits a layer into many
215+
graphs, and the boundaries land on the widest tensors in the model: one `(49152, 2048)` per layer
216+
(9.00 GiB total), one `(65536, 768)` per layer (4.50 GiB), 96 layer-boundary `(1, 8192, 2048)`
217+
(3.00 GiB). Of the 77.04 GiB, 18.33 GiB has no `grad_fn` at all — pure graph-boundary input.
218+
219+
Forcing every node to recompute changes nothing versus a `budget=0.0`: 131.14 GB against 131.34 GB.
220+
The memory that cannot be reclaimed is not the partitioner's to reclaim.
221+
222+
`torch._functorch.config.activation_memory_budget` on the same shape, with no checkpoint:
223+
224+
| budget | tgs | peak |
225+
|---|---|---|
226+
| baseline (whole layer checkpointed) | 8904.4 | 84.32 GB |
227+
| 0.0 | 10032.5 (+12.7%) | 107.71 GB |
228+
| 0.25 | **10282.0 (+15.5%)** | 111.68 GB |
229+
| 0.5 | 9750.1 (+9.5%) | 113.25 GB |
230+
231+
The fastest result measured anywhere, for +23 GB and no way back down: `budget=0.0` is the floor and
232+
it is 23.4 GB above the baseline. The curve is not monotone (0.25 beats both neighbours) and that is
233+
unexplained; it is a single point per budget, not a characterised curve.
234+
235+
**Under domino this route is unusable.** Domino already needs ~19 GB more reserved, and removing the
236+
checkpoint adds ~24 GB more, which crosses the 140 GB device limit: `budget=0.0` runs at 1925 tgs
237+
(5.4× slower than the checkpointed baseline) with 134.60 GB reserved, and 0.25 and 0.5 both OOM.
238+
239+
## The route that would be complete, and what blocks it
240+
241+
If the checkpoint became a `tag_activation_checkpoint` HOP — that is, if `torch.compile` wrapped the
242+
checkpoint call rather than sitting inside it — the two mechanisms collapse into one. The policy
243+
result is written to `node.meta["recompute"]` during tracing (`torch/utils/checkpoint.py`) and the
244+
partitioner consumes it; there is no second decider to overrule it, no dispatch mode at runtime, and
245+
no callable to withdraw from the compile set.
246+
247+
The HOP only forms if the checkpointed region speculates into a single subgraph. On the domino path
248+
it never does: `torch_all2all.py` reads `permuted_hidden_states.grad_fn` to register a backward
249+
pre-hook for the CUDA-event choreography, and Dynamo cannot trace a tensor's `grad_fn`. Speculation
250+
fails, everything traced is discarded, and the graph before the checkpoint call is empty — measured
251+
at 30B as `unique_graphs=0, calls_captured=0`, a silent degradation to eager rather than an error.
252+
253+
torchtitan reaches this topology because its MoE dispatch is a custom op (`deepep.dispatch.default`),
254+
which enters the graph whole. Making xtuner's dispatcher expressible the same way — replacing the
255+
`grad_fn.register_prehook` choreography — is the one change that unlocks it.
256+
257+
## Memory wall, for context
258+
259+
Independent of checkpointing: domino at 16384 is 4.44× *slower* than not using it (14.20 s vs
260+
3.199 s per step) purely because reserved memory reaches 134.43 GB of 140.06 GB and the caching
261+
allocator thrashes. At 8192 the same code is **16.1% faster** with domino than without (1.583 s vs
262+
1.838 s). `expandable_segments` does not help, so this is total demand rather than fragmentation, and
263+
switching between the all2all and deepep backends does not help either (deepep is 7.1% faster only
264+
when domino is off).
265+
266+
Any activation-memory work on the domino path should check headroom before it profiles kernels.

0 commit comments

Comments
 (0)