Skip to content

feat(sc): report trajectory age in the SingleController train pump - #3759

Open
tianyi-zhang-02 wants to merge 5 commits into
NVIDIA-NeMo:mainfrom
tianyi-zhang-02:feat/sc-trajectory-age
Open

tianyi-zhang-02 wants to merge 5 commits into
NVIDIA-NeMo:mainfrom
tianyi-zhang-02:feat/sc-trajectory-age

Conversation

@tianyi-zhang-02

@tianyi-zhang-02 tianyi-zhang-02 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Reports avg_trajectory_age and max_trajectory_age for trajectories consumed by each Single Controller train step.

Built-in samplers return a typed SamplerSelection with selected metadata, group count, and trajectory ages. The train pump accumulates ages across all selections contributing to a step. External samplers returning the existing two-tuple remain compatible.

Age is current_train_weight - selected_group.start_weight, intentionally unclamped so invalid negative ages remain visible.

Validation

Refreshed September 18: head a8687819be2f51922d78ae619224f80495e95102, based on upstream main at b7a4d95d9099bae2b7f3713cc402aed3f21aef8d.

Check Scope Result
Current-head CPU suite sampler interface, SC actor, checkpointing and train-pump E2E tests 237 passed; 1 existing main-equivalent failure
Current-head static checks Ruff, import sorting, formatting, diff check, DCO passed

The unchanged TestPeriodicRolloutCheckpoint::test_logs_raw_and_canonical_rollout_throughput assertion expects 20.0 and observes 6.666666666666667. It fails identically on unmodified current main in the same local harness. It was included in the complete run, not deselected.

Environment: macOS arm64, Python 3.12.2, pytest 8.4.2, torch 2.8.0 CPU, Ray 2.51.1. This is not locked Linux worker or maintainer-CI validation. Rebase conflicts were limited to adjacent imports; range-diff preserves the feature/test commits. One test import ordering correction is included.

Coverage includes selected-only accounting, unclamped ages, reset behavior, every built-in sampler, external two-tuple compatibility, multi-selection accumulation, rollout metrics, and checkpoint restore. Main's eviction-before-selection ordering remains intact.

Earlier GPU evidence

Pre-refresh head b48548c6dd692d10dab3a831f70d74abac6eb33d completed a 2×H100 run with Qwen3-0.6B, Megatron policy, real vLLM rollout, native TQ, ready_first, importance-sampling correction, and max_staleness_versions=3:

train step 1 2 3 4 5 6
avg_trajectory_age 0 1 2 3 3 3
max_trajectory_age 0 1 2 3 3 3

It finished at train_steps=6, trainer_version=6, with no evicted, aborted, dropped, or replaced groups. This was not rerun at the current head; current-head GPU confirmation remains pending.

@tianyi-zhang-02
tianyi-zhang-02 requested review from a team as code owners August 21, 2026 16:49
@copy-pr-bot

copy-pr-bot Bot commented Aug 21, 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.

The pre-SC async GRPO path logs ``avg_trajectory_age`` from
``ReplayBuffer.sample``. The SC path replaced that buffer with
``TQReplayBuffer`` plus the sampler policies and never carried the metric over:
``start_weight_list`` is still maintained, but only for selection filtering,
bookkeeping and the checkpoint snapshot. So moving a recipe to the Single
Controller silently drops it.

The two counters that sound like they would cover it -- ``evicted_stale_prompt_groups``
and ``aborted_stale_inflight_groups`` -- count what was discarded, and both are
structurally zero under ``ready_first``: its ``evict`` returns 0, and it
inherits ``should_abort_inflight`` -> False (``WindowedSampler`` overrides that;
``_GatedSampler`` does not).

That matters because ``ReadyFirstSampler.select`` puts no lower bound on
``start_weight`` -- deliberately, so late stragglers are never thrown away --
while ``WeightFifoSampler`` bounds it with ``min_valid_version``. A group can
therefore be arbitrarily many versions old, and the importance ratio spans all
of them, with nothing reporting how far.

``_finalize_selection`` now records ``current_train_weight - start_weight`` per
selected group, just before ``remove()`` while the indices are still valid.
Every built-in sampler routes through it, so all four report uniformly; all
four also filter ``start_weight <= current_train_weight`` (``InOrderSampler``
via ``target_step``, the dispatch index, which is never below the trainer
version at dispatch), so the value is non-negative throughout.

Exposed as ``last_selection_trajectory_ages`` and deliberately kept off the
``PromptGroupSampler`` Protocol: a sampler loaded by FQN from outside the repo
needn't implement it, and the train pump reads it with ``getattr``, so it
degrades to reporting nothing instead of failing. The pump accumulates across
the several selects that assemble one step, then emits ``avg_trajectory_age``
-- same name and quantity as the async GRPO path, so one dashboard covers both
-- and ``max_trajectory_age``, which is the one that shows a ready_first tail.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The parametrized case covered the three samplers whose select filters on
start_weight <= current_train_weight. InOrderSampler filters on target_step
instead, so it was the one built-in the tests did not reach -- and the one
whose selection does not itself bound the age.

Adds it, plus a case pinning that a group newer than the trainer reports a
negative age rather than a clamped zero. I had claimed non-negativity held for
all four; tracing it, the gate only bounds dispatch_index from above
(dispatch_index <= trainer_version + gate_window) and reserve stamps the
generation worker's weight version, so that is an invariant of how the pumps
interact, not one the sampler enforces. Better to report the inversion than to
round it into something that looks fine.

Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The existing tests are all on the sampler. Nothing asserted that the two
keys ever reach step_metrics, and the two things the pump itself does were
both unpinned:

  - accumulation across selects. A step is assembled from several selects on
    the streaming path, so an assignment instead of an extend would report
    only the last one and pass any single-select test.
  - the empty guard. Without it the step epilogue does sum([]) / len([]) at
    step close, which kills the run -- and that is the path every sampler
    without the property takes, since the metric is deliberately not on the
    Protocol.

Mutation-tested: swapping extend for an assignment, and removing the guard,
each turn one of these red (the second as a ZeroDivisionError).

Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-request waiting-on-maintainers Waiting on maintainers to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants