Skip to content

perf: remove 104 host synchronizations per training iteration - #7076

Open
alpha0422 wants to merge 5 commits into
NVIDIA:mainfrom
alpha0422:wkong/sync-less
Open

alpha0422 wants to merge 5 commits into
NVIDIA:mainfrom
alpha0422:wkong/sync-less

Conversation

@alpha0422

Copy link
Copy Markdown
Contributor

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*Synchronize calls
over a 6-iteration region of interest:

before after
host syncs / iteration 142 38
distinct sync sites 18 4
dynamic-shape sites (out of scope, see below) 35 35
everything else 107 3

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:

count site what it was
74 rerun_state_machine.py validate_result NaN/inf check reading a device predicate into Python control flow — one .item() per gradient bucket, on the autograd thread inside the grad-ready hook
8 common_utils.py logical_and_across_model_parallel_group update_successful — pageable H2D construction plus a host read
7 common_utils.py reduce_max_stat_across_model_parallel_group grad norm, num zeros, learning rate
5 training.py torch.tensor([0.0], device='cuda') seeding the loss accumulator
4 common_utils.py calc_params_l2_norm reading its result
4 timers.py + barrier() the interval timer: host clock behind torch.cuda.synchronize() and a world barrier, in both start() and stop()
2 rerun_state_machine.py _reduce_any the rerun decision's own construction and read
2 training.py checkpoint_and_decide_exit --exit-duration-in-mins, tested every iteration
1 clip_grads.py count_zeros_fp32 .item() on the zero count
1 training.py get_loss_scale().item()

Three recurring root causes:

  1. A device value pulled into Python control flow where nothing consumed it until much
    later (the NaN checks, update_successful).
  2. torch.tensor([x], device='cuda') for a host scalar — a pageable host-to-device
    copy, 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 kernel
    argument buffer with no copy at all.
  3. Diagnostics on the critical path — timing, logging and the exit check reading values
    every iteration that are only ever printed.

How they were removed

commit change syncs/iter
1 Bank validate_result verdicts on device; merge them into the all-reduce should_run_forward_backward already performs at the loop exit. Detection timing is unchanged — still before optimizer.step(). 74
2 Keep the logged stats on device and read the whole bundle — grad norm, num zeros, params norm, loss scale and the per-key loss averages — in one device-to-host copy at the log point. 17
3 Build device scalars with torch.full / zeros / ones instead of torch.tensor([x], device='cuda'); defer the learning rate into the same batched read. 7
4 Time with CUDA events instead of host clock plus torch.cuda.synchronize(), and drop the per-iteration barrier(). Reporting becomes lagged but lossless — every interval reported exactly once, in order. 4
5 Fold the --exit-duration-in-mins vote into the same batched read. 2
total 104

Three design points worth a reviewer's attention:

  • Batching, not just deferring. Deferring each read individually removed the six target
    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.
  • The timer's first report blocks once, deliberately. It has no earlier value to stand
    in for it and the caller divides by it for throughput; without that the run dies with
    ZeroDivisionError on the first log line.
  • non_blocking is gated on is_pinned(), not forced. A pageable source ignores the
    flag 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:

ms/iteration
baseline 2646.96
this branch 2595.99
−1.93%

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

  • Iteration-1 lm loss identical between arms at 256 GPUs, with every resolved argument
    identical across the two arms.
  • Iterations 1–2: lm loss and params norm bit-identical across all six runs of both
    arms
    at a smaller scale — spread exactly 0 within each arm and between arms. This is
    the sharp test that the computation is unchanged.
  • grad norm and num zeros vary in the last digits — but they vary that much within
    each 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.
  • Learning-rate sequence bit-identical over 200 iterations, all six runs.
  • Zero NaN, zero skipped iterations in every run of both arms at both scales — relevant
    because commit 1 changed when NaN verdicts are read.
  • Unit tests: 66 checks for the logging/exit changes, 21 for the CUDA-event timer.
    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-mode aborts on this hardware with SM100 backward with head_dim=256 does not support deterministic mode. The evidence is therefore the
bit-identical early iterations and the paired-run analysis above.


Reviewer notes — API changes

  • count_zeros_fp32 now returns a device tensor rather than a Python float; its return
    annotation 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 — the
    batched read accepts host values transparently.
  • reduce_max_stat_across_model_parallel_group gained return_tensor, replacing an
    earlier kind-follows-input rule that could not express "a host float that defers".
    Defaults unchanged, so existing callers and tests/unit_tests/test_utils.py are untouched.
  • training_log() now returns a tuple (report_memory_flag, exit_duration_done);
    tasks/finetune_utils.py updated.
  • Timer reporting is lagged. Timer.elapsed() returns intervals resolved from completed
    CUDA 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() and
    bounded at 1024 pending events. Aggregates over many iterations remain exact.
  • Exit granularity is now --log-interval rather than every iteration, since the
    --exit-duration-in-mins verdict rides the batched read. For a limit measured in minutes
    this is not a behavioral regression, and it reuses a knob that already exists rather than
    inventing one.

alpha0422 and others added 5 commits September 4, 2026 09:43
…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>
@copy-pr-bot

copy-pr-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant