feat(data-plane): track data-plane time, latency percentiles and byte volume - #3616
Conversation
c67f613 to
48ed054
Compare
|
/ok to test 48ed054 |
5e79a5d to
67be5a7
Compare
|
/ok to test 67be5a7 |
9cbeea4 to
501a7fd
Compare
|
/ok to test 501a7fd |
15a6c26 to
4ea3bc7
Compare
|
/ok to test 4ea3bc7 |
4ea3bc7 to
2d5719b
Compare
|
/ok to test 2d5719b |
|
/ok to test 05348d2 |
16e3cc0 to
d1cd96f
Compare
|
/ok to test dc3d609 |
|
/ok to test 485a454 |
485a454 to
e1505b1
Compare
|
/ok to test e1505b1 |
|
/ok to test 96c161c |
|
/ok to test 2af2c3a |
terrykong
left a comment
There was a problem hiding this comment.
Thanks for this — the measurement discipline behind it is better than most performance PRs get. Running the A/B interleaved on one node (off/on/off/on, 20 steps each) instead of a single pair, quoting the 687 ms gap between the two off-arms as the noise floor, and defaulting the expensive hash guard to false in all 24 recipes so it only ever turns on from the command line — that is the shape of evidence that makes a number believable. Every one of the five threads @zyzhou5 opened came back with a measurement.
The feature earns its keep. The data plane moves the whole training batch between processes and had no per-op timing at all; frac_of_step is the only number that says whether optimising it is worth anything. Land it once the blocking item is fixed.
One blocking item. The comment on ppo-qwen2.5-1.5b-gsm8k-2n8g-megatron-valuetp2sp-dynbatch-noncolocated-async-single-controller.sh:28 explains it: that suite's config chain is the only one of the 24 with no data_plane.observability block left in it, so the new override kills the nightly at config parse. The fix needs enabled: true as well as verify_tensor_hash — see the comment for why restoring just the deleted lines is not enough.
Why this reached head invisibly. No job on this PR runs any of the 24 modified suite scripts. The label is CI:L1, which runs tests/functional/, and test_all_tests_can_find_config_if_dryrun exits in common.env before any uv run, so the override is never parsed. gh pr checks 3616 is green and says nothing at all about these scripts.
The PR description has gone stale. It still advertises step/percent_of_dataplane/by_cause/{fixed_overhead,transfer}, a "Reading the numbers correctly" bullet about the affine fit, and an "overhead/transfer split" column. grep -rn by_cause over *.py, *.md, *.yaml and *.sh at head returns nothing — b80b24cb4 removed the fit, and _BREAKDOWN_COLUMNS (observability.py:975-984) has no such column. step/wall_ms in that table should be step/wall_s. The README itself is correct throughout; only the description needs updating.
What was run here. 518 unit tests passed (15 skipped, 0 failed) across tests/unit/data_plane and tests/unit/algorithms/test_grpo.py. The three pinned ruff hooks pass. No GPU on this machine, so no nightly, no real TransferQueue, and nothing exercising mooncake or RDMA.
One last small thing: the single-controller copy of the log-before-commit fix has no unit test, while the grpo_sync copy does.
Generated by Claude Code
The 1.7 s end-to-end difference is not explained by the 119 ms of guard time or the 297 ms of data-plane time. comm_volume_mb was unchanged, so the mirror columns are not measurable payload and the payload explanation was wrong. Step time varied 14.3-20.3 s within each run, guard-off was slower at step 10, and the runs were on different nodes. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
Replaces the two-node comparison. Interleaved off/on/off/on, 20 steps each: the guard costs ~180 ms/step by the counters (wall and self are disjoint, so it is their sum, not self alone), and its effect on step time is unmeasurable against a 0.6-0.9 s run-to-run floor -- the guard-on runs were 0.15 s faster. The earlier 1.7 s was two nodes and noise. That the accounted cost sits inside the floor is also the evidence the counters are not under-reporting. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
The DP ranks fetch in parallel and meet at the gradient all-reduce, so no rank passes the barrier until the straggler has its shard -- the phase costs what the slowest rank paid. That is why the reduction is a max: summing gives process-time that exceeded the step, and averaging reports a cost no rank ever paid. Records the two things it still understates -- the driver's ops are serial with the workers' fetches, and the rollout actor is in no scope -- and that the barrier is at the all-reduce, so what leaks through is the straggler's excess rather than its whole fetch. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
Scratch log of trials and dead-ends from the metrics-accuracy investigation. Working notes, not a deliverable -- it went onto the branch via a broad git add -A. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
Three findings from reviewing today's diff: - step/self/frac is a ratio of two sums, so it is the wrapper's share of data-plane process-time and does not equal overhead_ms / (step/wall_s * 1e3). Named rather than changed: sum over max is a ratio of nothing. - The comment claimed the max is a lower bound on serial cost. It is not a bound either way -- it drops the driver's phase, which is serial with the fetches, and counts time that overlapped compute on the async path. - test_cluster_frac_of_step used ten identical ranks, where max, mean and per-process share all coincide, so it passed under every reduction. Now one straggler against nine fast ranks: max 10%, mean 1.9%, sum 19%, and it asserts the first while rejecting the other two. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
No behaviour change. exposed_ms claimed a property the README spends a paragraph refuting -- the max drops the driver's phase, which is serial with the fetches, and counts time that overlapped compute on the async path. It is the slowest process's time, so it is slowest_ms. The single-process fallback was wall_ms / n_procs. Correct -- at one process the mean, sum and max coincide, and the only caller that could pass more always has the key -- but it reads as the per-process mean this reduction replaced. wall_ms says the same thing without the second look. n_procs then had no use left in _step_metrics beyond the comments. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
Both its terms are summed across processes while wall_s is now a max, so the two do not divide into each other. That is deliberate -- a sum over a max is a ratio of nothing -- but a reader seeing them in the same step/self/ group will try it, and the discrepancy is the DP degree rather than a bug. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
grpo_math_1B.yaml already sets observability.verify_tensor_hash: false, and every one of these recipes reaches it through defaults -- directly, or via grpo_math_1B_megatron.yaml. Restating false in each of them is dead config that has to be kept in sync with the base for no effect. The suites that need the guard already ask for it on the command line, so this removes 48 lines and changes nothing. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
The test asserted that every algorithm logs data-plane metrics before log_metrics(..., step_finished=True), because wandb accepted a log against a committed step and silently discarded it. main's Logger now buffers: _buffer_step_metrics_locked accumulates into _pending_metrics keyed by step and flushes one row per step, with an atexit drain. A log arriving after step_finished is buffered, not dropped, so source order is no longer observable behaviour and the guard would only ever fail on a legitimate reordering. Same reasoning as taking main's logger.py during this rebase, which dropped the _committed_step warning this PR had added for the same bug. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
Two blank-line slips from this branch's edits: an extra line left where _as_i64 was removed, and a missing one before the read closure in get_samples. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
_hash_field and _with_mirrors were defined twice, byte-identical. Python shadows the first silently, so the 73 unit tests and ruff both passed; only the docs build noticed, as autodoc2.dup_item warnings promoted to errors. Scanned every changed .py in the branch by AST for other duplicate functions, classes, methods and constants, and the changed configs for duplicate YAML keys: this was the only one. Signed-off-by: Zhiyu Li <zhiyul@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
The PPO config chain defines no data_plane.observability, so the suite's data_plane.observability.verify_tensor_hash=True override had no key to set and Hydra raised at config parse under struct mode. enabled: true is half the fix: without it no metrics wrapper is built and the suite's own rows_checked > 0 gate fails instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
frac_of_step differenced a max over cumulative per-process totals. The difference of maxima is not the maximum of differences: it equals the straggler's step only when the cumulative leader is also this step's straggler, and drifts toward the per-process mean the reduction replaced as ranks grow (31% low at 16 ranks). Accumulate step_wall_ms per process and reduce that with the max instead. Also gates the hash deltas on rows_checked, so a process that only reads still reports them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
The driver-scope fallback called get_step_metrics after the collect had already closed the driver's step window, so every step/by_op/*/max_ms read 0.0, and its baseline was never advanced on cluster steps, so the first fallback after N cluster steps reported N steps as one. Both now run off the snapshot the collect took, with a baseline per scope that is advanced on both paths. The state moves to TQPolicy with the client whose counters it differences, replacing the out-of-band _prev_cluster_snapshot the trainer set through getattr/setattr, and the four 'is observability on' tests become one is_metrics_client predicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
log_table was the only WandbLogger call passing step= to run.log directly, and the only one outside _log_lock. Custom-axis streams (GPU monitoring, on by default) advance wandb's internal step on their own, so within a couple of steps the table targeted a step wandb had already passed and the panel was dropped with no error. Routed through _buffer_step_metrics_locked, the way log_plot already is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
…gate
README and docstrings still described the per-process mean, the dropped
latency fit, three hash stores and a step/{op}/max_ms key that is
emitted as step/by_op/{op}/max_ms. Records the second accepted limit of
the hash guard beside the first: an XOR fold cancels in pairs, so a
constant-valued row of even length has a digest that depends only on its
dtype and shape.
The nightly gates merge the cluster and driver key families instead of
preferring cluster, so a step whose fan-out failed -- the step most
likely to have something wrong with it -- is still read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
The driver's baseline was copied onto the policy while the client kept its own, unused. get_step_metrics now accepts the snapshot its caller already took -- a fan-out has to read the client first, and reading is what closes the step window -- so the client stays the only owner and the policy holds just the cluster baseline. That drops the string-keyed baseline dict and the public step_metrics re-export the duplication needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
…apshot A default argument is evaluated eagerly, so `snap.get(cluster_key, snap[single_key])` looked up the single-process key unconditionally and raised on every cluster step. The metrics guard caught it and training continued, so the only symptom was that no data_plane series were logged at all. Found by an e2e run, not by review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
…rule Replaces the hand-rolled second key name and the branch that had to pick between the two. `step_wall_ms` joins `_SNAPSHOT_MAX`, so merge_snapshots reduces it the same way it already reduces every other max field, and both scopes read one key that means one thing: this step's data-plane wall time, of the process that paid the most. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
The guard swallows by design, so silence was its only symptom: a KeyError on every step of a real run logged one indistinguishable line per step, published no data_plane/* series, and an empty panel reads the same as a data plane that cost nothing. The first failure now carries its traceback at ERROR -- the exception names a key, not the line that asked for it -- and later ones carry a running count, which separates 'broken since step 1' from 'flaked once'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
Answers why the field was missing: a merged snapshot is assembled key by key from the merge tuples, not copied from a process snapshot, so a field in neither tuple is simply absent from the cluster view -- and the reader, which cannot tell the two shapes apart, raised on it and took every data_plane/* series down with it. The field is carried now, so this is belt and braces: the read falls back to the summed wall time, which over-reports one series instead of publishing none. Two tests pin it -- the merge contract (whatever the step metrics read, the merged snapshot carries) and the degraded path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
…ounter Two type errors from today's commits, caught by the lint job: - `snap.get(key, default)` widens to include None for pyrefly, and the result feeds a division. Reads the key explicitly and branches on None, which keeps the graceful fallback and types cleanly. - the panel-failure counter rebound a module int through `global`, which pyrefly rejects and which has no precedent elsewhere in nemo_rl. An itertools.count needs no rebinding, and 'give me the next number' was the only operation it ever wanted. Imported qualified: a bare `count` collides with two loop variables in this file (F402). pyrefly clean and 369 passed / 3 skipped in the nightly container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Zhiyu Li <zhiyul@oci-aga-slurm-1-dm-02.cm.cluster>
74ecb40 to
d60550e
Compare
|
/ok to test d60550e |
resolves the pyrefly lint failure unrelated to this branch's changes Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
What this adds
Per-step visibility into what the TransferQueue data plane costs, so "is the
data plane my bottleneck, and which part of it" is answerable from a dashboard
instead of a profiler.
Off by default at the top level (
data_plane.enabled: false); when the dataplane is on,
observability.enabled: truewraps the client inMetricsDataPlaneClient. Measured overhead is ~15-20 µs per op, and thewrapper bills itself so that cost is visible rather than asserted.
The metrics
Everything is logged under
data_plane/cluster/(the driver plus every policyworker, summed) or
data_plane/driver/when the fan-out reaches one process.step/frac_of_stepstep/percent_of_dataplane/by_op/{put,get,clear,register}step/percent_of_dataplane/by_cause/{fixed_overhead,transfer}step/volume_mb/by_op/{get,put}step/wall_ms,step/comm_volume_mbnow/bytes_outstanding_mbstep/self/{overhead_ms,frac}step/hash/*verify_tensor_hashonPlus a per-op breakdown table (
data_plane/{cluster,driver}/breakdown),ordered worst-first, carrying the detail that would be noise as 32 line charts:
calls, mean/max/p50/p90 ms, the overhead/transfer split, and MB.
Two denominators, deliberately.
frac_of_stepdivides by the step;percent_of_dataplanedivides by the data plane. A workload can be 43%putand still not be worth touching — you need both.
Reading the numbers correctly
Three things are easy to misread, and are documented in
nemo_rl/data_plane/README.md:both sides, and every process's transfers are summed. Correct for "what
crossed the wire"; useless for "how big was the batch".
putreads small. The rollout actor builds its own client and is not onthe worker group, so its
kv_first_writeof the whole batch is in neithervolume_mbnorcomm_volume_mb.by_causeis often absent. Request sizes in RL are frequently uniform,which makes the affine fit unidentifiable. It reports nothing rather than an
arbitrary split.
Optional wire-hash guard
verify_tensor_hash: falseby default, in the recipes as well — the suitesthat gate on it pass
data_plane.observability.verify_tensor_hash=Trueon thecommand line, so a recipe you copy does not silently opt into it.
When on, it fingerprints every tensor row with
torch.hash_tensoron the wayin and out and reports divergence — the one failure class nothing else
notices, because a mis-sharded read trains happily on the wrong rows. Catches a
row served from the wrong sample, two rows swapped between DP ranks,
truncation, a bf16→fp32 change, and a single element in any dtype.
The wire-in reading travels with the row. Each field is mirrored by a
<field>_hashint64 column, written by the same put and declared alongside thefield. Holding the reading in the putting process only ever verified a
same-process round trip, and the transfer worth checking is not one: the
rollout actor writes what the policy workers read. The mirror is per top-level
field, so a multimodal
imagesreduces its leaves to oneimages_hash.Measured on an interleaved same-node A/B —
off/on/off/on, 20 steps each,all four arms in one allocation (Llama-3.2-1B, 1 node x 4 GB300, TQ
simple,24.4 MB/step). Medians over steps >= 5, which drops vLLM warm-up and the first
refit.
hash/rows_recordedhash/rows_checkedhash/rows_unverifiedhash/mismatches,guard_failures,fields_skippedstep/wall_sstep/self/overhead_msstep/frac_of_stepstep/comm_volume_mbwall_sandself/overhead_msare disjoint — the first is the RPC, the secondis the wrapper with the RPC subtracted out — so the guard's cost is their sum:
+55 ms of extra
<field>_hashcolumns on the wire, +109 ms of hashing.The control is the two off arms, ~40 minutes apart in the same allocation.
They differ by 2.9 ms on
wall_sand 0.005 pp onfrac_of_step, against aguard effect of 164 ms — a 50:1 ratio of signal to drift. Both pairs,
(b-a)and
(d-c), agree within 2%.comm_volume_mbis identical across arms to fourdecimals, which is the check that the two arms moved the same data.
Its effect on step time is not measurable, and that is not evidence it is
free.
total_step_timereads 15.36 / 15.69 / 15.22 / 15.21 s; the twoidentical off arms differ by 0.14 s and per-arm MAD runs 0.16-0.56 s, so a
0.16 s effect sits inside the noise. The data-plane counters are the quiet
channel and the step clock the loud one, which is why the cost is reported from
the counters. An earlier revision of this section claimed the two agreed "to
within 4 ms" — that was a single pair read against this floor, and it is
withdrawn.
This supersedes the 10.2 ms this section previously claimed, measured when the
digest stayed process-local and most rows were abstained on rather than
compared.
Blind spot, measured rather than assumed: the row fold is an XOR reduction, so
it cannot see a permutation within a row (0/200 for a two-token swap). The
per-field fold over leaves uses
* 31 +rather than XOR, so identical leavescannot cancel there.
Verification
369 unit tests pass, 3 skipped (
tests/unit/data_plane, run inside thenightly container so TransferQueue is real -
simpleandmooncake_cpuround trips both included), plus the two
test_grpo.pycases covering bothlogging scopes. Re-run at the current head, 45 s. That includes 23/23
adversarial hash-guard checks (10 corruption modes caught, zero false alarms
over a 500-row soak, every shard grouping) and 48/48 metric-audit checks
against an independently computed ground truth.
Both accepted limits of the guard are pinned by name, so neither can
become a surprise: a within-row permutation, and a constant-valued row of
even length, whose digest is its
dtype|row shapeseed alone because theXOR fold cancels in pairs.
advantageshas exactly that shape.A real 25-step GRPO run — Llama-3.2-1B on OpenMathInstruct-2, 1 node ×
4 GB200, TransferQueue
simple, 5 processes reporting:https://wandb.ai/nvidia/nemo-rl-dataplane-obs/runs/w316chaj
get65% of data-plane time · 24 MB moved per step · 38,400 rowshash-checked, 0 mismatches. Its
frac_of_steppredates the reductionchange and was a per-process mean; the current figure, measured on the
four-arm A/B above, is 1.42% of a ~15.3 s step guard-off and 1.73-1.83%
with the guard on — the slowest process's serial data-plane time, since that
is the one the gradient all-reduce makes everyone wait for.
Notes for review
needs 40 (~4 observations above the rank). Below that the key is absent
rather than reporting the maximum under a percentile's name. p90 rather than
p99 because a step holds tens of calls — a p99 off 58 samples equalled the
maximum 80% of the time.
max_msis scoped to the step by being reset by its reader, since amaximum cannot be differenced out of a cumulative counter.
observability.pyalready on main.