Repository navigation
Conversation
…op exit
validate_result() read its rejection verdict on the host immediately -- one
cudaStreamSynchronize per call. On a large MoE configuration that is 74 per
iteration, 72 of them from ParamAndGradBuffer.check_grads(), which validates
every gradient bucket.
None of those reads had a consumer. The only thing that acts on a rejection is
should_run_forward_backward()'s loop-exit test, which already all-reduces
rerun_requested and already pays one .item() for it. So bank the verdicts in a
device vector and merge them into that existing all-reduce.
The verdicts are produced on whatever stream the caller runs on -- check_grads()
runs from autograd's backward hooks, before start_grad_sync() enters its
communication-stream context -- so join the producing streams before the read.
Measured: 2 streams on some ranks, 1 on others. Without the join the reduction
reads slots the producing stream has not written.
Reduce a scalar, not the vector: ranks bank different counts (26 vs 10 across
pipeline stages, measured), so only the local OR can enter the collective.
Detection timing is unchanged -- the loop exit is still before optimizer.step(),
so a NaN is caught on the iteration it occurred and before the step that would
consume it. Identity of the failing check is resolved only when the reduction
comes back set. Overflowing the flag buffer degrades to the synchronous path.
_reduce_any() also gains a device-tensor branch, removing the
torch.tensor([...], device='cuda') pageable copy that was itself a sync.
Verification:
syncs rerun_state_machine 32 -> 1 per iteration on a 4-GPU node;
file total 65 -> 34 driver cu*Synchronize calls in the ROI
unit 16/16, including a side-stream verdict behind 60 chained
matmuls, first-rejection-wins ordering, Inf, and buffer overflow
functional 100 iterations, learning rate bit-identical to 10 baseline runs
model par. PP=2 and TP=2 + --sequence-parallel on 2 nodes, both COMPLETED
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Wil Kong <alpha0422@gmail.com>
…copy
grad_norm, num_zeros_in_grad, params_norm and loss_scale exist only to be
printed, but each was read to the host the moment it was produced -- inside
optimizer.step(), inside the model-parallel reduce, inside train(). Every one
of those reads is a cudaStreamSynchronize on the critical path, well before
anything consumes the value.
Carry them as device tensors to training_log() instead, and read the whole
bundle -- including the per-key loss averages -- in a single device-to-host
copy at the point the log line is actually formatted.
Batching is the part that matters. A first cut that simply deferred each read
removed the six target sites but only netted 10 of the 17 syncs: PyTorch skips
the sync when the stream is already idle, so the old early .item() calls had
been draining the device and hiding the later reads. Removing them surfaced
six calls that had never appeared in a trace. _read_logging_stats() collapses
all of them into one torch.cat().tolist().
reduce_max_stat_across_model_parallel_group() now follows its input's kind: a
device tensor in gives a device tensor out with the -1.0 "no rank had this
stat" sentinel still encoded, decoded at the log point by _none_if_absent().
A float or None in behaves exactly as before, so the learning_rate caller,
tasks/finetune_utils.py and the existing unit tests are untouched. Both
branches all-reduce an identical (1,) float32 buffer, which is required, not
incidental: ranks without trainable params take the float branch inside the
same collective. copy=True keeps the in-place all-reduce out of the caller's
buffer.
count_zeros_fp32() returns a device tensor rather than a Python float, and
MegatronOptimizer.count_zeros() follows. The int64 zero count still passes
through a float32 cast, so the log line keeps printing "num zeros: N.0"
rather than "num zeros: N".
calc_params_l2_norm() takes return_tensor and squares its root in fp64 either
way, so the deferred path prints the same digits as norm_2.item() ** 0.5 did.
The total_loss_dict seed becomes torch.zeros(1) -- torch.tensor([0.0]) is a
pageable host-to-device copy for a value that is just zero.
Nothing here changes when a NaN is detected: these are logging stats, and the
rerun/skip decisions read grad_norm only when grad_norm_skip_threshold is
finite (it defaults to inf).
Verification, one node, 4 GPU, driver cu*Synchronize in a 6-iteration ROI:
total 34 -> 18 calls, 13 -> 9 stacks
logging-attributable 17 -> 3
count_zeros 1 -> 0
reduce_max_stat (both sites) 7 -> 2 (learning_rate float caller)
params_norm 4 -> 0
loss_scale 1 -> 0
loss accumulator seed 4 -> 0
the single batched read 1
unit tests 36/36
convergence 100 iterations at --log-interval 1;
iterations 1-2 bit-identical to baseline,
1-10 agree to 5 significant figures. Past
that the reference workload is chaotic
enough that its own runs span a 48x
envelope, so no comparison is made.
num zeros moves by 1-2 float32 ULPs; it is
nondeterministic in the baseline too, and
this run's value matches three baseline
runs exactly.
Left in place: update_successful (6 syncs, deferred); learning_rate (2 syncs --
a host float going up to the device, which wants a pinned staging buffer, not
deferral); and the timer syncs, which ARE the "elapsed time per iteration (ms)"
instrument. Later commits in this series address all three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Wil Kong <alpha0422@gmail.com>
…ng rate
torch.tensor([v], device='cuda') is a pageable host-to-device copy, so it
blocks. torch.full()/zeros()/ones() pass the value in the kernel argument
buffer and do not copy at all. Measured with 20 repetitions each behind pending
device work:
torch.tensor([v], device='cuda') 20 / 20 synchronize
torch.empty(1, device='cuda').fill_(v) 0 / 20
torch.full((1,), v, device='cuda') 0 / 20
pinned staging + copy_(non_blocking=True) 0 / 20
torch.tensor([v]).cuda(non_blocking=True) 0 / 20
full() is preferred over the pinned-buffer route: no copy means no staging
buffer to keep alive until it lands. The last row is a trap worth recording --
it emits no synchronize call but still stalls, because a pageable async copy
stages through an internal driver buffer. A filter counting synchronize API
calls cannot see that one. This is also why non_blocking is gated on
is_pinned() elsewhere in this series rather than forced: forcing it would move
the stall and hide it from the trace.
Converted the four constructions on the per-iteration path:
reduce_max_stat_across_model_parallel_group, logical_and_across_model_parallel_group,
_compute_norm_2's dummy overflow buffer, and ChainedOptimizer.get_loss_scale.
The construction in optimizer.py is left alone -- it sits behind a cache and
runs once per gradient-norm group, not per iteration.
Also defers the learning rate. It is a host float, so it could not use the
"return kind follows input kind" rule introduced by the previous commit at all;
that rule said tensor in -> tensor out AND float in -> float out, which has no
way to express "a host float that defers". Replaced it with a single switch:
return_tensor decides the output kind whatever went in. The default is
unchanged, so tasks/finetune_utils.py and the tree's own tests in
tests/unit_tests/test_utils.py are untouched.
The learning rate then joins the existing batched read in training_log, costing
nothing. Its freeze_all_layers fixup has to move after that read, since it
inspects the decoded value.
Verification, one node, 4 GPU, driver cu*Synchronize in ROI:
total 34 -> 11 calls, 13 -> 7 stacks
logical_and construction 7 -> 0
reduce_max_stat (both sites) 7 -> 0 (gone from the trace)
_compute_norm_2 dummy buffer 0
Of the 11 left, 10 are out of scope or by design: 4 dynamic-shape, 4 timers
(which ARE the elapsed-time instrument), 1 _reduce_any, 1 batched read. The
last is logical_and's own bool(input.item()) for update_successful.
unit tests 53/53
convergence 100 iterations at --log-interval 1;
learning-rate sequence bit-identical to
baseline over all 100 iterations -- the
direct gate here, since the learning
rate is what moved. Iterations 1-2
identical, 1-10 agree to 4 significant
figures. No NaN, no skipped iterations.
update_successful stays. Pipelining it into the next iteration's _reduce_any
does not work: that merge point runs before optimizer.step() produces the flag,
so deferring it would delay opt_param_scheduler.step() by an iteration and
change training rather than just the logged value. Removing it needs a device
learning rate, and capturable Adam measured +7.4% step time on a small
configuration -- a measured regression to remove 1 sync of 11, which would want
a production-scale A/B before anyone could justify it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Wil Kong <alpha0422@gmail.com>
…ence
Timer read the host clock behind an unconditional torch.cuda.synchronize(), in
BOTH start() and stop(). elapsed() calls stop() then start(), so every call cost
2 full-device syncs plus 2 world barriers. With --log-interval 1 that fenced the
entire training loop on every iteration.
That fence is why removing 87 of 175 host syncs earlier in this series moved
step time by only -0.16%: the loop already ended in a barrier plus a device
sync, so the host could never run ahead and had slack for every stall behind it.
Record CUDA events instead and resolve them only once they have completed. The
host never waits, so measurement no longer fences the loop.
A profiler ROI of 2 steps had been reporting this site as 2 calls/iteration.
It is 4. Sampling tools merge call sites that share a native stack and label
the merged stack with just one Python line, so start()'s barrier and sync were
being counted under stop()'s line numbers. A 6-iteration ROI separates them,
and also exposed two checkpoint_and_decide_exit sites the short window clipped
entirely -- see the next commit.
Reporting is now lagged: a measurement describes a window a few calls back.
It is lossless and ordered -- every interval is reported exactly once, oldest
first -- so an aggregate over many iterations is still exact. Deferred event
reads cost zero syncs and agree with the host-clock-plus-sync timings they
replace to within 1.39%.
Three things the design has to get right, each found by a failing test:
The first report has no earlier value to stand in for it and the caller
divides by it to get throughput, so it waits for its window. Once per run.
Without this the workload dies with ZeroDivisionError on the first log line.
A window that timed nothing is dropped rather than reported as 0.0, for the
same reason. Any elapsed() call that closes an interval-free window would
otherwise plant a zero in the queue.
stop() folds completed intervals into a partial sum, so a window spanning many
intervals holds only the events still in flight. Otherwise a timer that is
started and stopped repeatedly between elapsed() calls accumulates events
without bound. Pending events are capped at 1024.
Also drops the barrier from the per-iteration elapsed() in training_log. It
aligned ranks so the host clock could not drift from the work; with the
measurement on device that no longer applies, and each rank's own timeline
already includes waiting inside the collectives. The once-per-loop-entry
start(barrier=True) calls keep theirs.
Verification, one node, 4 GPU, 6-iteration ROI, per iteration:
torch.distributed.barrier 2.00 -> 0
torch.cuda.synchronize 2.00 -> 0
total 12.7 -> 8.7
unit tests 21/21
no regression 53/53 on the logging-stats tests
convergence 100 iterations at --log-interval 1;
learning-rate sequence bit-identical to
baseline over all 100 iterations;
iterations 1-2 identical. No NaN, no
skipped iterations.
Step time reads 124.9 ms median against the baseline's 126.7 ms on that node.
That is NOT a claim of a speedup: the two numbers come from different
instruments -- host clock behind a fence versus CUDA events without one -- so
they are not comparable. The step-time evidence for this series is the
same-allocation A/B in the pull request description, measured from log-line
wall-clock rather than from either arm's internal timer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Wil Kong <alpha0422@gmail.com>
--exit-duration-in-mins was tested on every iteration, and each test cost a
pageable host-to-device construction plus a host read on the training step's
critical path -- to evaluate a limit measured in MINUTES.
Both sites were invisible until the profiler ROI was widened to six iterations;
a two-step window had been clipping them entirely.
Cast the vote before training_log() and let its host read ride along with the
batched read already there. The verdict then reaches checkpoint_and_decide_exit()
as a plain bool, so nothing synchronizes for it at all.
The vote is gated inside start_exit_duration_vote() on the same condition
training_log() gates its batched read on, rather than at the call site, so the
two cannot drift: casting more often adds a collective nobody reads, and casting
less leaves the verdict unread and the run past its limit. Exit granularity is
therefore --log-interval, a knob that already exists, rather than a new constant.
For a limit measured in minutes this is not a behavioral regression.
The rejected alternative was checking every N iterations behind a new constant.
It works, but it invents a knob and a granularity of its own when there is
already a batched host read one function call away.
training_log() now returns (report_memory_flag, exit_duration_done);
tasks/finetune_utils.py updated to match. That call site also passes only ten
positional arguments to an eleven-parameter signature -- it predates this change
and is left alone.
Verification, one node, 4 GPU, 6-iteration ROI, per iteration:
checkpoint_and_decide_exit 1.67 -> 0
non-dynamic-shape total 4.67 -> 3.00
total 8.67 -> 7.00
The three that remain are all load-bearing: _reduce_any (the rerun decision),
logical_and (update_successful), and _read_logging_stats (every logged value,
in one copy).
unit tests 66/66, including that the vote is cast on
exactly the iterations that read it
convergence 100 iterations at --log-interval 1;
learning-rate sequence bit-identical to
baseline over all 100 iterations;
iterations 1-2 identical. No NaN, no
skipped iterations.
This completes the series. Across all five commits the non-dynamic-shape sync
count on a large MoE configuration goes from 107 to 3 per iteration, with the
remaining dynamic-shape sites belonging to an MoE dispatcher outside this tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Wil Kong <alpha0422@gmail.com>
This branch has not been deployed
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.
perf: remove 104 host synchronizations per training iteration
Every training iteration blocked the host far more often than it needed to. Nearly all of
those blocks were diagnostic — values produced on device, pulled to the host immediately,
and then not consumed until the next log line. This removes 104 host synchronizations per
iteration without changing any kernel, any collective, any numerics, or any optimizer math.
7 files · +433 / −100 · 5 commits
The problem
Measured on rank 0 with a CUPTI-based tool counting driver-domain
cu*Synchronizecallsover a 6-iteration region of interest:
The 35 dynamic-shape syncs come from an MoE dispatcher outside this tree; they are counted
here only so the totals reconcile. The removable population is 107, and 104 of them go.
Where they came from, per iteration:
rerun_state_machine.pyvalidate_result.item()per gradient bucket, on the autograd thread inside the grad-ready hookcommon_utils.pylogical_and_across_model_parallel_groupupdate_successful— pageable H2D construction plus a host readcommon_utils.pyreduce_max_stat_across_model_parallel_grouptraining.pytorch.tensor([0.0], device='cuda')seeding the loss accumulatorcommon_utils.pycalc_params_l2_normtimers.py+barrier()torch.cuda.synchronize()and a world barrier, in bothstart()andstop()rerun_state_machine.py_reduce_anytraining.pycheckpoint_and_decide_exit--exit-duration-in-mins, tested every iterationclip_grads.pycount_zeros_fp32.item()on the zero counttraining.pyget_loss_scale().item()Three recurring root causes:
later (the NaN checks,
update_successful).torch.tensor([x], device='cuda')for a host scalar — a pageable host-to-devicecopy, which blocks. Measured 20/20 blocking over 20 repetitions behind pending device
work;
torch.full/zeros/fill_are 0/20, because the value rides in the kernelargument buffer with no copy at all.
every iteration that are only ever printed.
How they were removed
validate_resultverdicts on device; merge them into the all-reduceshould_run_forward_backwardalready performs at the loop exit. Detection timing is unchanged — still beforeoptimizer.step().torch.full/zeros/onesinstead oftorch.tensor([x], device='cuda'); defer the learning rate into the same batched read.torch.cuda.synchronize(), and drop the per-iterationbarrier(). Reporting becomes lagged but lossless — every interval reported exactly once, in order.--exit-duration-in-minsvote into the same batched read.Three design points worth a reviewer's attention:
sites but netted only 10 of 17 syncs: PyTorch elides the sync when the stream is already
idle, so the old early reads had been hiding the later ones. One batched
torch.cat(...).tolist()is what actually collapsed them.in for it and the caller divides by it for throughput; without that the run dies with
ZeroDivisionErroron the first log line.non_blockingis gated onis_pinned(), not forced. A pageable source ignores theflag and stalls anyway while emitting no synchronize call — forcing it would move the
stall and hide it from the trace.
The 3 remaining removable syncs are load-bearing: the rerun decision (
_reduce_any),update_successful(logical_and), and the single batched logging read.Results
Measured on an internal large-scale MoE pretraining workload; the configuration is not
disclosed here, so treat the percentage as indicative rather than reproducible. Both arms
ran in a single allocation as adjacent phases, because rack placement moves step time by
more than the effect being measured — an early attempt across separate allocations inverted
the sign.
At 256 GPUs, over iterations 40→160:
Measured as wall-clock between log-line timestamps with identical machinery in both arms —
the arms' internal timers differ by construction after commit 4 and are not comparable
across arms. Per-20-iteration windows: five of six branch windows sit below every baseline
window (Mann-Whitney 6v6, one inversion, p ≈ 0.004). This was measured at
--log-interval 20, where the baseline pays its logging syncs only one iteration in twenty — the settingthat gives this change the least to remove.
Nsight attribution on a smaller comm-bound configuration shows the gain landing entirely in
launch starvation: launch-starved time −108.3 ms/iteration against a step-time delta of
−105.8 ms, while blocking (98% communication) and dependency-stalled time are unchanged
(−8.0 ms and +0.1 ms). The two deltas agree to within 3 ms.
Expect the benefit to scale with how much exposed host time a configuration has. A
comm-bound run with a long log interval recovers the least; a run with
--log-interval 1,or one whose device work does not already cover the host, recovers more.
Correctness
lm lossidentical between arms at 256 GPUs, with every resolved argumentidentical across the two arms.
lm lossandparams normbit-identical across all six runs of botharms at a smaller scale — spread exactly 0 within each arm and between arms. This is
the sharp test that the computation is unchanged.
grad normandnum zerosvary in the last digits — but they vary that much withineach arm too, and the between-arm mean difference is smaller than the within-arm spread
at every field. Under 64-way data parallelism NCCL reduction order varies run to run; this
is a property of the reference, not of the change.
because commit 1 changed when NaN verdicts are read.
Multi-GPU model-parallel smoke: PP=2, and TP=2 with
--sequence-parallel.Limits of the convergence evidence, stated plainly. Beyond iteration 2 the loss
trajectories cannot be compared: the reference workload disagrees with itself by ~13% mean
and ~37% max relative loss deviation over iterations 50–200, and a held-out reference run
falls outside the envelope of two other reference runs 39–87% of the time. A distributional
test at n=3 has no power there. A bit-exact deterministic convergence test was also
unavailable —
--deterministic-modeaborts on this hardware withSM100 backward with head_dim=256 does not support deterministic mode. The evidence is therefore thebit-identical early iterations and the paired-run analysis above.
Reviewer notes — API changes
count_zeros_fp32now returns a device tensor rather than a Python float; its returnannotation changed with it, and
MegatronOptimizer.count_zeros()follows.LayerWiseOptimizer.count_zeros()forwards the tensor unchanged;MimoOptimizer.count_zeros()already reduces to a Python int and is unaffected — thebatched read accepts host values transparently.
reduce_max_stat_across_model_parallel_groupgainedreturn_tensor, replacing anearlier kind-follows-input rule that could not express "a host float that defers".
Defaults unchanged, so existing callers and
tests/unit_tests/test_utils.pyare untouched.training_log()now returns a tuple(report_memory_flag, exit_duration_done);tasks/finetune_utils.pyupdated.Timer.elapsed()returns intervals resolved from completedCUDA events, so a log line reports slightly older windows than it used to. Every interval
is still reported exactly once and in order; open windows are drained on
stop()andbounded at 1024 pending events. Aggregates over many iterations remain exact.
--log-intervalrather than every iteration, since the--exit-duration-in-minsverdict rides the batched read. For a limit measured in minutesthis is not a behavioral regression, and it reuses a knob that already exists rather than
inventing one.