An external reference for the trainer's MFU: the same model and precision policy in PyTorch — +12.4% at B=16, +0.99% at the matched B=32 - #106
Open
feitreim wants to merge 3 commits into
Conversation
…h, and torch.compile is 9% ahead
The trainer's 81,532 tokens/s and 36.20% MFU at B=16 had nothing to be a
percentage of. This adds the thing they are a percentage of: the same 4.39B
model in PyTorch, fed the same tokens off the same shard, measured over the
same twelve steps with the first discarded, and divided by the same
`training_flops_per_token()` over the same 2.25 PFLOP/s.
tier B tokens/s MFU vs trainer
trainer (bin/train.rs) 16 81,532 36.20% --
PyTorch eager 16 44,635 19.82% -45.3%
torch.compile() 16 88,840 39.45% +9.0%
torch.compile(reduce-overhead) 16 89,541 39.76% +9.8%
The escalation was meant to climb tiers only while the trainer was still
ahead, so it stops here: `max-autotune` was never run, because plain
`torch.compile()` -- no CUDA graphs, which the script now verifies rather than
inferring from the mode name -- already passed the trainer. `reduce-overhead`
is CUDA graphs and is labelled as such, but it is worth 0.8% on a 370ms step,
so it is not what the gap is made of.
Eager is the number not to read. It runs the same GEMMs and pays for every
elementwise pass between them, and it is launch-bound enough that B=12 landed
at 40.7k and 46.7k across two runs; the compiled tiers repeat to within 0.6%.
Fairness is the whole point of a baseline, so the places the two policies
cannot meet are written down in the README with the direction each one leans.
Two lean toward the trainer -- it keeps bf16 masters where PyTorch keeps fp32
ones under autocast, and its fused classifier never materializes fp32 logits
over the padded vocabulary. One leans the other way: the embedding output is
cast to bf16 so the residual stream is bf16 on both sides, which autocast
would not have done on its own.
Routing is transcribed from `nn::MoeFfn` rather than approximated -- top-2 of
8 on renormalized softmax probabilities, capacity slots handed out in
flattened (token, rank) order, overflow parked on a scratch row that no expert
reads and no gradient leaves. At a capacity factor of one both sides compute
`E * C` rows whatever the routing looks like, which is why load balance cannot
move either number, and why the shard could have been random tokens without
changing the measurement. It is the real shard anyway, because only that also
gives a loss curve worth reading, and the script refuses a non-finite one.
The baseline gets its own Modal image. It shares nothing with the kernel
toolchain -- no rustc, no LLVM, no codegen backend -- and adding torch to the
image every kernel run pulls would have been a multi-gigabyte tax on runs that
never import it.
…rainer's policy is worth 12.4%
The first table gave PyTorch fp32 masters under autocast, and said so as a
handicap the trainer did not carry. This removes it. The policy is now
`optim`'s, term for term: bf16 masters for every matrix-shaped parameter, fp32
for norms and the router, fp32 AdamW moments, and the update done in fp32 on a
widened master that rounds back to bf16 on commit.
tier tokens/s MFU vs trainer peak VRAM
trainer (bin/train.rs) 81,532 36.20% -- --
PyTorch eager 49,282 21.88% -39.6% 132.6 GiB
torch.compile() 91,669 40.71% +12.4% 95.5 GiB
torch.compile(reduce-overhead) 91,289 40.54% +12.0% 95.5 GiB
Against the fp32-master numbers, which stay in the README for the record, the
matched policy is worth +3.2% and 16.1 GiB -- the whole of the handicap and not
much more. `reduce-overhead` is now 0.4% *behind* plain `torch.compile()`,
which is the clearest statement yet that CUDA graphs are not what the gap is
made of.
`torch.optim.AdamW` cannot express this: it sizes moments after the parameter,
so a bf16 master would get bf16 moments. `MasterAdamW` keeps them fp32 and
transcribes `optim::adamw_step` directly, down to epsilon landing on the
corrected second moment and decay staying decoupled. The script now verifies
what it actually holds rather than what it meant to -- 50 bf16 masters, 37 fp32
norms and routers, 174 of 174 moments fp32 -- and refuses to report a number if
a moment is not fp32.
The update step is compiled, and that is not a flourish. PyTorch ships no fused
kernel for a mixed parameter/moment dtype, and the same arithmetic left
uncompiled is eleven passes over 4.39B parameters: 77ms a step, dragging
`torch.compile()` from 91,669 to 75,490. That number measures this file, not
the trainer, so every row now has a fused optimizer -- `fused=True` where the
dtypes allow it, a compiled step where they do not.
One gap is left and it leans the other way. The trainer keeps fp32 gradients
beside its bf16 masters, because its weight-gradient GEMMs write fp32 directly;
autograd produces gradients in the parameter's dtype, so the 50 narrowed
masters get bf16 gradients while the 37 fp32 parameters match. Half the
gradient bytes is a speed advantage to PyTorch and an accuracy loss to it,
most visibly in the embedding's scatter-add. Closing it would mean a custom
autograd function behind every matmul, which is a heroic port and not a
baseline.
`max-autotune` is still unrun: the escalation continues only while the trainer
is ahead, and 81,532 is not ahead of 91,669.
…and stopped 0.99% short
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The trainer's 81,532 tokens/s and 36.20% MFU at B=16 had nothing to be a percentage of. This adds the thing they are a percentage of: the same 4.39B model in PyTorch, fed the same tokens off the same shard, measured over the same twelve steps with the first discarded, and divided by the same
training_flops_per_token()over the same 2.25 PFLOP/s. No PyTorch FLOP counter is anywhere near the table.The table — matched precision policy
optim's policy, reproduced term for term: bf16 masters for every matrix-shaped parameter (ParameterKind::{Embedding, Matrix, Head}), fp32 norms and router (::Norm,::Router), fp32 AdamW moments, and the update arithmetic in fp32 on a widened master that rounds back to bf16 on commit. One B200, B=16, torch 2.13.0+cu130,wiki-val-00000.tok.bin/train.rs, main)torch.compile()torch.compile(mode="reduce-overhead")Compile time is excluded from throughput — compiled tiers get three warmup steps before the timed window opens, eager gets one because its optimizer step is compiled — and reported here instead.
The line to answer is +12.4%.
mode="reduce-overhead"is CUDA graphs, which the trainer does not have, and it is labelled as such everywhere — but it lands 0.4% behind plaintorch.compile(), inside the noise of an 11-step window on a 357 ms step. Whatever the gap is made of, it is not graphs. The script asks inductor whether graphs were actually recorded rather than trusting the mode name:cuda_graphs=Falsefordefault,Trueforreduce-overhead.mode="max-autotune"remains unrun. The escalation continues only while the trainer is ahead, and 81,532 is not ahead of 91,669.The rematch at B=32
The table above pits a B=16 trainer against a B=16 baseline, and both halves of
that have since moved. #108-#114 cut the trainer's cost per unit of batch from
5.33 GiB to 3.39, #117 spent the room on
B = 32, andmain(c5a3b1a) nowreports 92,718 tokens/s / 41.17% MFU. So the baseline was re-run at the
matched batch — same policy, same shard, same twelve-steps-minus-one window,
all three tiers in one container.
bin/train.rs,c5a3b1a)torch.compile()torch.compile(mode="reduce-overhead")Same gates as before, and they all held at the new batch: parameter count
asserted term by term against the Rust model, 174 of 174 moments fp32, losses
11.17 → 8.48 finite on the real shard, and inductor asked whether it recorded
graphs rather than trusted by mode name.
The trainer did not cross
It closed 11.4 points of a 12.4-point gap and stopped 0.99% short. Against
#117's conservative same-container number (90,718) the gap is 3.2%. Both point
estimates put
torch.compile()ahead, so by the standing escalation rulemode="max-autotune"stays locked — it is still unrun, and what unlocks itis a trainer that beats 93,634.
The 0.99% deserves its caveat rather than a victory lap in either direction: it
is below the 1.4% spread #117 measured between two containers running the same
trainer binary, and the trainer's number comes from that PR's container while
these come from today's. At matched batch the two are a coin-flip the coin has
not landed on. What is not a coin-flip is the direction of travel, and +12.4% →
+0.99% is the whole of the interesting result.
What changed shape
allocating 6.14 more. Its footprint was already the largest row in the B=16
table and the batch doubled underneath it. An OOM is a measurement, so it gets
a row.
reduce-overheadis now 0.76% ahead of default, where at B=16 it was0.4% behind. Both margins are sub-1% on an 11-step window, so this is a sign
flip inside the noise rather than graphs suddenly paying. It stays excluded
from the headline for the reason it always was: the trainer has no CUDA graphs
and cuda-oxide cannot give it any.
cuda_graphs=Falsefor default,Truehere, both verified.
against 34.2 s for
reduce-overheadis inductor's cold cache, not a propertyof the modes — default ran first in the container, exactly as it did at B=16
(38.0 s vs 23.1 s).
Memory
95.5 → 150.1 GiB over 16 → 32 is 3.41 GiB per unit of batch, against the
trainer's measured 3.39 — the two scale alike, which is the sanity check worth
having. So PyTorch's 24 GiB of headroom at B=32 is all intercept: 40.9 GiB fixed
against the trainer's 65.5. Part of that is real and already named below — bf16
gradients on the 50 matrix parameters save ~8.8 GiB the trainer spends on fp32.
The rest is not a like-for-like subtraction, because
max_memory_allocated()counts what the caching allocator handed out and not the CUDA context or cuBLAS
workspaces beside it. The slopes are measured by the same instrument; the
intercepts are not.
One container, or two
Two, and the harness cannot make it one.
torch_baselineis the only Modalfunction on
torch_image; every trainer entrypoint runs on the kernel image;neither image contains the other's toolchain. Pairing them in a single container
means one image carrying both torch and the whole cuda-oxide backend — the
multi-gigabyte tax this baseline was split out to avoid in the first place. The
trainer's 92,718 is #117's sweep container and 90,718 is its
train_abcontainer; the two PyTorch rows are today's.
modal run modal_app.py::pytorch_baseline --tiers ",default,reduce-overhead" --batches 32Policy verification
The script refuses to report a number unless the moments are fp32, and prints what it actually holds rather than what it meant to:
87 parameters: 50 bf16 masters (embedding, lm-head, qkv, o_proj, gate_up, down), 37 fp32 (24 block norms + 12 routers + the final norm), and 174 of 174 moments fp32. Parameter count is asserted against the Rust model's term by term first:
Losses run 11.18 → 8.42 over the window on the real shard, finite, checked.
The stock autocast policy, kept for the record
The first pass measured fp32 masters under bf16 autocast with
AdamW(fused=True)— the default a practitioner reaches for, carrying a handicap the trainer does not: a weight cast per matmul and twice the parameter and gradient bytes. Those numbers stay here rather than being replaced.torch.compile()torch.compile(mode="reduce-overhead")Matching the policy is worth +3.2% and 16.1 GiB against that — the whole of the handicap and not much more. The trainer's reference at B=12 (79,200 / 35.17%) was measured against the fp32-master eager tier only; the matched policy was run at B=16, the recommended config.
Where the policies still cannot meet
torch.optim.AdamWsizes moments after the parameter, so a bf16 master would get bf16 moments — not the policy.MasterAdamWkeeps them fp32 and transcribesoptim::adamw_stepdirectly (epsilon on the corrected second moment, decoupled decay,copy_into bf16 asMasterStorage::commit's round-to-nearest). Its step istorch.compiled because PyTorch ships no fused kernel for a mixed parameter/moment dtype, and that is not a flourish: the same arithmetic uncompiled is eleven passes over 4.39B parameters, 77 ms a step, draggingtorch.compile()from 91,669 to 75,490 — a number that measures this file rather than the trainer's kernels. Every row above has a fused optimizer one way or the other.F.cross_entropydoes, in both memory and bandwidth.Fairness of the model itself
Transcribed from
crates/nn/andgpu/model/src/lib.rsrather than approximated: bias-free linears, fused[D, 3, D]QKV, RMSNorm at 1e-5 with fp32 statistics, interleaved-pair RoPE at theta 10000 computed in fp32, causal SDPA on the flash backend (runtime-probed, cuDNN fallback that says so), SwiGLU experts as[E, D, 2FF]/[E, FF, D]batched GEMMs, AdamW at lr 3e-4 / wd 0.1 / betas (0.9, 0.999) / eps 1e-8, aux-loss decaying 1e-2 linearly to zero at 10,000 steps.The lm-head is padded to
VP = 50,432because the Rust pads it, because the MFU formula'sD * VPcounts it, and because 50,257 would cost cuBLAS its aligned epilogue; the loss still normalizes overVOCABalone, as the fused classifier does with its VOCAB/VP pair.Routing is the part most easily strawmanned, so it is copied most literally from
nn::MoeFfnandmoe_bin_assign: top-2 of 8 on renormalized softmax probabilities, capacity slots handed out in flattened(token, rank)order, overflow parked on one scratch row that no expert reads and no gradient leaves. At a capacity factor of one both sides computeE * Cexpert rows whatever the routing looks like — so load balance cannot move either number, and random tokens would have measured the same thing. The real shard is used anyway, because only that also gives a loss curve worth reading.Shape of the change
gpu/model/baselines/pytorch_baseline.pyis new and standalone.modal_app.pygains a second image and apytorch_baselineentrypoint; the baseline shares nothing with the kernel toolchain — no rustc, no LLVM, no codegen backend — so adding torch to the image every kernel run pulls would have been a multi-gigabyte tax on runs that never import it. Nothing the trainer builds or runs is touched.