|
| 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