Skip to content

[train] Skip building unused per-token loss_fn_outputs when the caller does not consume them - #1807

Merged
SumanthRH merged 12 commits into
NovaSky-AI:mainfrom
dyurk-lila:perf/skip-unused-per-token-loss-outputs
Jul 28, 2026
Merged

[train] Skip building unused per-token loss_fn_outputs when the caller does not consume them#1807
SumanthRH merged 12 commits into
NovaSky-AI:mainfrom
dyurk-lila:perf/skip-unused-per-token-loss-outputs

Conversation

@dyurk-lila

@dyurk-lila dyurk-lila commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Reviewers: Where to Look

The behavioral core of this PR is small; concentrate review on these sites:

  • The gate at the two FSDP sites in skyrl/backends/skyrl_train/workers/worker.py_forward_backward_micro (train, gate at :939) and _forward_micro_with_loss (eval, gate at :1201), plus the equivalent gate in Megatron's loss_func inside forward_backward_mini_batch (skyrl/backends/skyrl_train/workers/megatron/megatron_model_wrapper.py:737). Key things to confirm: (a) loss, the backward pass, and all consumed scalar metrics are computed outside/before the gated block so they are byte-identical regardless of the flag; (b) the else branch yields one empty dict per sequence, and only the cross_entropy branch is gated — the RL (non-cross_entropy) else-branch that builds logprobs must stay ungated.
  • The threading of the parameter from the dispatch layer down to each gate: worker_dispatch.pyworker.py forward_backward (:776) / forward (:1079) → the micro methods (:848, :1142), and the Megatron path megatron_worker.py (:992, :1106) → megatron_model_wrapper.py (:474). It is an explicit keyword argument defaulting to True at every level, so any level that forgets to forward it silently reverts to the old behavior rather than erroring — worth a skim that no hop was missed.
  • Caller opt-out in skyrl/train/sft_trainer.pytrain_step (forward_backward, :1703) and run_eval (forward, :1677) now pass return_per_token_outputs=False. Confirm the SFT trainer truly reads only output.metrics and never output.loss_fn_outputs, so opting out is safe.

Needs less scrutiny: the near-identical per-token-build bodies moved wholesale under the if return_per_token_outputs: guard (unchanged logic, just re-indented); the docstring/comment additions at each gate site; and the test scaffolding.


Summary

Threads an explicit return_per_token_outputs keyword argument (default True) from the worker dispatch layer down to the cross_entropy branch of both backends on both the train and eval/forward paths, gating the per-token loss_fn_outputs build. When the flag is False, the per-token NLL, the two detached [mb, seq] D2H copies (logprobs + elementwise loss), and the .tolist() loop are skipped; each sequence gets an empty dict instead. The loss / response_length metrics and the loss_fn_output_type tag are unchanged.

SkyRL's own SFTTrainer reads only output.metrics (loss / response_length), never output.loss_fn_outputs, so it now opts out — eliminating dead work on the SFT train + eval hot path. RL and Tinker callers pass nothing and keep the existing contract (default True).

What changed

  • FSDP (workers/worker.py, workers/fsdp/fsdp_worker.py): accept and forward the flag through forward_backward / forward, and gate the per-token build in _forward_backward_micro (train) and _forward_micro_with_loss (eval).
  • Megatron (workers/megatron/megatron_worker.py, workers/megatron/megatron_model_wrapper.py): accept and forward the flag, gating the per-token build inside the shared loss_func (covers train + forward_only eval).
  • Dispatch (workers/worker_dispatch.py): plumb the flag through and document the reserved argument on forward / forward_backward.
  • Caller wiring (train/sft_trainer.py): train_step and run_eval pass return_per_token_outputs=False.

The flag is a plain function argument rather than a loss_fn_config dict key or an AlgorithmConfig field: it is per-call request metadata, not algorithm configuration, and keeping it out of the config avoids both the key-validation issue in build_nested_dataclass and any pop/copy dance around the AlgorithmConfig merge. It is documented in the parameter docstring at each site that reads it — _forward_backward_micro, _forward_micro_with_loss (FSDP), and forward_backward_mini_batch (Megatron).

Numerical equivalence / safety

Byte-identical for all existing callers:

  • The default True at every level reproduces the exact prior code paths; no existing caller passes the flag.
  • loss, the backward pass, and all consumed scalar metrics (loss, response_length, lr) are computed before/independent of the gated block, so they are identical whether per-token outputs are kept or skipped.
  • loss_fn_output_type is a WorkerOutput field that always defaults to "scalar" (never set explicitly), so the type tag survives automatically — only the arrays become empty. The empty-dict-with-"scalar"-tag combination is only reachable behind the explicit opt-out whose sole caller ignores the payload.
  • RL's separate (non-cross_entropy) loss_fn_outputs else-branch is untouched; the RL trainer and Tinker backend pass no flag, so their contracts hold. Because the flag is a Python keyword argument and not a loss_fn_config key, it cannot be injected through the Tinker public API's user-supplied config at all.

Note on the eval path: run_eval already iterates eval batches serially and reads only output.metrics["loss"], so opting out there removes per-token work without changing any reported eval metric.

Test plan

  • tests/backends/skyrl_train/workers/test_sft_loss_fn_outputs_gate.py (new, CPU): drives the real FSDP _forward_backward_micro / _forward_micro_with_loss cross_entropy builds on CPU; asserts default/explicit-True populate logprobs + elementwise_loss, False yields empty dicts, and loss/response_length/lr are identical across the flag. Includes a case where a real eps_clip_low override is passed via loss_fn_config alongside the flag, confirming the two are independent and the config merge still happens. Adds an RL-path test confirming the non-cross_entropy else-branch is ungated (logprobs still built; outputs + loss identical across the flag); that test disables use_kl_loss/use_entropy_loss explicitly (rather than relying on a default) so it isolates the gate from the KL/entropy terms.
  • tests/train/test_sft_callbacks.py (extended): assert SFTTrainer.train_step and run_eval pass loss_fn="cross_entropy" and return_per_token_outputs=False to the dispatch.
  • tests/train/test_trainer.py: the _forward_backward_micro mock in test_forward_backward_batch_calculations accepts the new argument.
  • tests/backends/skyrl_train/gpu/gpu_ci/test_training_step.py (extended, GPU): parametrized over FSDP + Megatron (the Megatron leg carries @pytest.mark.megatron like the sibling tests), DP=2; runs the real worker forward_backward/forward twice on the same dummy batch (flag default-True vs explicit-False) and asserts loss + response_length identical and loss_fn_output_type == "scalar" in both, with per-token outputs populated when kept vs empty when skipped.

Run locally:

uv run --isolated --extra skyrl-train --extra dev pytest \
  tests/backends/skyrl_train/workers/test_sft_loss_fn_outputs_gate.py \
  tests/train/test_trainer.py::test_forward_backward_batch_calculations

CPU run: 12 passed. The CPU suite for the new/extended tests runs automatically under the standard CPU CI job (tests/backends/skyrl_train/ + tests/train/); skyrl_train_tests is green on this head.

Generality & follow-ups

Covered: both backends (Megatron loss_func; FSDP micro methods) and both the train (forward_backward) and eval/forward (forward(loss_fn="cross_entropy")) paths. RL and Tinker contracts preserved (default True).

Intentionally out of scope:

  • The RL loss_fn_outputs build (separate else-branch, not cross_entropy) is untouched; the forward(loss_fn=None) pure-inference path is untouched.
  • The JAX backend builds loss_fn_outputs in a jit-traced path driven by a structured LossFnConfig dataclass, and is not reached by this argument — a possible follow-up, not a regression.
  • No config schema field is added — the flag is a per-call argument with a safe default, preferred over a global switch.

Minor cleanup carried here: the loss_config merge guard in the two FSDP micro methods was relaxed from if loss_fn_config is not None: to if loss_fn_config: so an empty override dict skips a no-op OmegaConf.merge. This is a strict no-op for every existing caller (no caller passes {}, and OmegaConf.merge(base, {}) is itself a no-op); it is incidental to the gate rather than required by it, and is easy to drop if reviewers would rather keep the diff minimal.

Relationship to open PRs

This gate is orthogonal/complementary to several in-flight efforts touching the same files:

The gate logic itself is composable with all of these.

@dyurk-lila
dyurk-lila marked this pull request as ready for review June 18, 2026 16:34

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a performance optimization to skip the generation of per-token loss function outputs (such as log probabilities and elementwise NLL) when they are not needed by the caller. A new configuration flag return_per_token_outputs is introduced and extracted from loss_fn_config via a utility function pop_return_per_token_outputs. In SFTTrainer, this flag is set to False during training and evaluation steps to avoid unnecessary GPU-to-CPU transfers and Python loops, as only scalar metrics are consumed. The Megatron and FSDP workers are updated to respect this flag, and comprehensive unit and integration tests are added to verify the correctness of this optimization. No review comments were provided, so there is no feedback to address.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

…r does not consume them

Threads a per-request `return_per_token_outputs` flag (default `True`) carried on the already-plumbed `loss_fn_config` dict to gate the per-token `loss_fn_outputs` build on the `cross_entropy` branch of **both** backends and **both** the train and eval/forward paths. When the flag is `False`, the per-token NLL, the two detached `[mb, seq]` D2H copies (logprobs + elementwise loss), and the `.tolist()` loop are skipped; each sequence gets an empty dict instead. The `loss` / `response_length` metrics and the `loss_fn_output_type` tag are unchanged.

SkyRL's own `SFTTrainer` reads only `output.metrics` (loss / response_length), never `output.loss_fn_outputs`, so it now opts out — eliminating dead work on the SFT train + eval hot path. RL and Tinker callers pass no flag and keep the existing contract (default `True`).

- **`worker_utils.py`**: new module-level helper `pop_return_per_token_outputs(loss_fn_config) -> (Optional[dict], bool)` that copies the dict before popping the flag (callers' dicts are never mutated) and returns the default `True` for `None`. Its docstring is the single authoritative source for the flag's contract and the why-popped-before-merge rationale.
- **Megatron** (`megatron/megatron_model_wrapper.py`): pop the flag once in `forward_backward_mini_batch` and gate the per-token build inside the shared `loss_func` (covers train + `forward_only` eval).
- **FSDP** (`workers/worker.py`): pop + gate in `_forward_backward_micro` (train) and `_forward_micro_with_loss` (eval).
- **Caller wiring** (`train/sft_trainer.py`): `train_step` (forward_backward) and `run_eval` (forward) now pass `loss_fn_config={"return_per_token_outputs": False}`.

The flag is popped from a per-call **copy** of `loss_fn_config` **before** the `AlgorithmConfig` merge — it is not an `AlgorithmConfig` field, so leaving it in would trip the key validation in `build_nested_dataclass` (reached via `from_dict_config`). The merge guard was changed from `if loss_fn_config is not None:` to `if loss_fn_config:` so an empty dict after the pop skips the merge exactly as today.

`return_per_token_outputs` is documented in the `pop_return_per_token_outputs` helper docstring and at each pop/gate site; the `loss_fn_config` docstrings on the three consuming worker methods that read the flag — `_forward_backward_micro`, `_forward_micro_with_loss` (FSDP), and `forward_backward_mini_batch` (Megatron) — also note the reserved key and its default. The per-build-site rationale comments were collapsed to a single line each (pointing at the helper docstring) to avoid hand-sync drift across the three near-identical sites.

Byte-identical for all existing callers:

- When the flag is absent/`None`, the pop never runs and the full prior per-token build executes unchanged. Default `True` reproduces the exact prior code paths.
- The merge-guard change (`is not None` → truthy) is a strict no-op: no existing caller ever passed `{}`, a non-empty override dict is still truthy and still merges, and `OmegaConf.merge(base, {})` is itself a no-op.
- `loss`, the backward pass, and all consumed scalar metrics (`loss`, `response_length`, `lr`) are computed before/independent of the gated block, so they are identical whether per-token outputs are kept or skipped.
- `loss_fn_output_type` is a `WorkerOutput` field that always defaults to `"scalar"` (never set explicitly), so the type tag survives automatically — only the arrays become empty. The empty-dict-with-`"scalar"`-tag combination is only reachable behind the explicit opt-out whose sole caller ignores the payload.
- RL's separate (non-`cross_entropy`) `loss_fn_outputs` else-branch is untouched; the RL trainer and Tinker backend pass no flag, so their contracts hold. The Tinker public API whitelists `loss_fn_config` keys (empty allowed-key set for `cross_entropy`), so the flag cannot be injected by users; and even if present it is popped before any merge.

Note on the eval path: `run_eval` already iterates eval batches serially and reads only `output.metrics["loss"]`, so opting out there removes per-token work without changing any reported eval metric.

- `tests/backends/skyrl_train/workers/test_sft_loss_fn_outputs_gate.py` (new, CPU): drives the real FSDP `_forward_backward_micro` / `_forward_micro_with_loss` `cross_entropy` builds on CPU; asserts default/explicit-True populate `logprobs` + `elementwise_loss`, `False` yields empty dicts, `loss`/`response_length`/`lr` are identical across the flag, the flag is popped before the `AlgorithmConfig` merge (a real `eps_clip_low` override alongside the flag still merges without raising), and the caller dict is not mutated. Adds an RL-path test confirming the non-`cross_entropy` else-branch is ungated (logprobs still built; outputs + loss identical across the flag); that test disables `use_kl_loss`/`use_entropy_loss` explicitly (rather than relying on a default) so it isolates the gate from the KL/entropy terms. CPU run: 12 passed.
- `tests/backends/skyrl_train/workers/test_worker_utils.py` (extended): unit tests for `pop_return_per_token_outputs` — `None`→`(None, True)`, absent-flag→`True` with config preserved, explicit `False`/`True` popped leaving legitimate overrides, and no caller-dict mutation.
- `tests/train/test_sft_callbacks.py` (extended): assert `SFTTrainer.train_step` and `run_eval` pass `loss_fn="cross_entropy"` and `loss_fn_config={"return_per_token_outputs": False}` to the dispatch.
- `tests/backends/skyrl_train/gpu/gpu_ci/test_training_step.py` (extended, GPU): parametrized over FSDP + Megatron (the Megatron leg carries `@pytest.mark.megatron` like the sibling tests), DP=2; runs the real worker `forward_backward`/`forward` twice on the same dummy batch (flag default-True vs explicit-False) and asserts `loss` + `response_length` identical and `loss_fn_output_type == "scalar"` in both, with per-token outputs populated when kept vs empty when skipped.

Run locally:

```bash
uv run --isolated --extra skyrl-train --extra dev pytest tests/backends/skyrl_train/workers/test_sft_loss_fn_outputs_gate.py tests/backends/skyrl_train/workers/test_worker_utils.py tests/train/test_sft_callbacks.py
```

The CPU suite for the new/extended tests runs automatically under the standard CPU CI job (`tests/backends/skyrl_train/` + `tests/train/`); confirm that check is green before merge.

Covered: both backends (Megatron `loss_func`; FSDP micro methods) and both the train (`forward_backward`) and eval/forward (`forward(loss_fn="cross_entropy")`) paths. RL and Tinker contracts preserved (default `True`).

Intentionally out of scope:
- The RL `loss_fn_outputs` build (separate else-branch, not `cross_entropy`) is untouched; the `forward(loss_fn=None)` pure-inference path is untouched.
- The JAX backend builds `loss_fn_outputs` in a jit-traced path driven by a structured `LossFnConfig` dataclass rather than the runtime `loss_fn_config` dict, so the dict-borne flag does not reach it — a possible follow-up, not a regression.
- No config schema field is added — the flag rides the runtime `loss_fn_config` dict only, preferring a function-arg/flag with a safe default over a global switch.

Doc follow-up: the caller-facing dispatch docstrings `WorkerDispatch.forward` / `WorkerDispatch.forward_backward` (the API the SFT trainer / Tinker call into) are outside this PR's touched files and so are left unchanged here; a one-line note about the reserved key could be added there in a follow-up so future callers can discover the opt-out from the dispatch layer.

This gate is orthogonal/complementary to several in-flight efforts touching the same files:

- **NovaSky-AI#1513** (SFT loss-aggregation rewrite of the same FSDP `cross_entropy` branch) — note it renames the SFT status key `loss`→`sft_loss`, so merge ordering matters; only the test coupling to the literal `loss` metric key would need a touch-up if it lands first.
- **NovaSky-AI#1752** (VLM SFT on Megatron) — disjoint regions in the shared files.
- **NovaSky-AI#1534** (preserve staged `forward_backward` loss_fn_outputs across DP ranks; `worker_dispatch.py`) — no overlapping file.

The gate logic itself is composable with all of these.
@dyurk-lila
dyurk-lila force-pushed the perf/skip-unused-per-token-loss-outputs branch from 9107e37 to fb3a6b0 Compare July 1, 2026 20:23

@SumanthRH SumanthRH left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a comment on the API!

Comment on lines +842 to +844
loss_fn_config: Optional config overrides for the resolved train loss function.
May include reserved key ``return_per_token_outputs`` to skip
per-token ``loss_fn_outputs`` when callers read only ``metrics``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return_per_token_outputs is a control flag that changes the output type

I would not want to smuggle this inside loss_fn_config

Can we just add the flag return_per_token_outputs as an argument to PolicyWorker.forward_backward and PolicyWorker.forward ? This is more clear to users on behaviour.

We should also make a similar change in WorkerDispatch.forward , WorkerDispatch.forward_backward and WorkerDispatch.forward_backward_from_staged -> allow pass through kwargs for worker-type-specific arguments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I did this refactor

dyurk-lila and others added 7 commits July 22, 2026 21:02
Address review: the flag changes the output type, so it should be an
explicit keyword arg on PolicyWorker.forward / forward_backward rather than
smuggled inside loss_fn_config. Thread it through the Megatron + FSDP worker
paths and forward_backward_mini_batch, and add **worker_kwargs pass-through to
WorkerDispatch.forward / forward_from_staged / forward_backward /
forward_backward_from_staged for worker-type-specific args. Drop the now-dead
pop_return_per_token_outputs helper and RETURN_PER_TOKEN_OUTPUTS_KEY; update
the SFT trainer call sites and the gate/worker-utils/callback tests.
Resolve worker.py + megatron_model_wrapper.py conflicts: keep the explicit
return_per_token_outputs gate around per-token output building while adopting
main's vectorized (loss_mask > 0).sum valid-length computation.
Main renamed eval_dataloader->eval_dataloaders (list of (name, loader)) and
namespaced eval metrics as {name}/loss; adapt the run_eval callback test.
The FSDP policy worker overrides forward() and must accept + forward the new
explicit return_per_token_outputs kwarg; otherwise dispatch.forward(...,
return_per_token_outputs=False) on the FSDP SFT-eval path raises TypeError.
Two CI regressions from threading return_per_token_outputs through the
worker API:

- tests/train/test_trainer.py monkeypatched a mock over
  _forward_backward_micro without the new return_per_token_outputs
  kwarg, so forward_backward raised TypeError.
- The new gate test's CPU worker fixture never stubbed mesh_rank, so the
  cross_entropy branch hit None.dp_size. Stub it once in the shared
  fixture (SimpleNamespace(dp_size=1), matching
  test_policy_worker_loss_scaling.py) and drop the duplicate stub from
  the RL helper.

Both are test-side only; worker.py's mesh_rank.dp_size dereference is
unchanged from main.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: SumanthRH <sumanthrh@anyscale.com>
@SumanthRH
SumanthRH merged commit a68bd5a into NovaSky-AI:main Jul 28, 2026
4 of 6 checks passed
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.

2 participants