Skip to content

Commit 9107e37

Browse files
committed
[train] Skip building unused per-token loss_fn_outputs when the caller does not consume them
## Summary 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`). ## What changed - **`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. ## Numerical equivalence / safety 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. ## 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, `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. ## 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 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. ## Relationship to open PRs This gate is orthogonal/complementary to several in-flight efforts touching the same files: - **#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. - **#1752** (VLM SFT on Megatron) — disjoint regions in the shared files. - **#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.
1 parent e1b995f commit 9107e37

8 files changed

Lines changed: 721 additions & 126 deletions

File tree

skyrl/backends/skyrl_train/workers/megatron/megatron_model_wrapper.py

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
setup_per_microbatch_replay_forward,
3333
)
3434
from skyrl.backends.skyrl_train.utils.torch_utils import masked_mean
35+
from skyrl.backends.skyrl_train.workers.worker_utils import pop_return_per_token_outputs
3536
from skyrl.train.config import TrainerConfig
3637

3738

@@ -286,6 +287,9 @@ def forward_backward_mini_batch(
286287
loss_fn: Optional loss function name (e.g., "cross_entropy", "ppo").
287288
If provided, overrides the config's policy_loss_type.
288289
loss_fn_config: Optional config overrides for the loss function.
290+
May also carry the reserved key ``return_per_token_outputs`` (bool, default
291+
``True``); set ``False`` to skip the per-token ``loss_fn_outputs`` build when
292+
the caller reads only ``metrics``. See :func:`pop_return_per_token_outputs`.
289293
forward_only: If True, run the forward pass without backward (no gradients).
290294
Useful for evaluation / loss-only inference paths (e.g., SFT
291295
``forward(loss_fn=...)`` codepath).
@@ -302,10 +306,12 @@ def forward_backward_mini_batch(
302306
else:
303307
current_loss_fn = self.policy_loss_fn
304308

309+
# Pop the per-request flag that gates the per-token loss_fn_outputs build.
310+
loss_fn_config, return_per_token_outputs = pop_return_per_token_outputs(loss_fn_config)
311+
305312
# Build config for loss function, applying any overrides
306313
loss_config = self.cfg.algorithm
307-
if loss_fn_config is not None:
308-
314+
if loss_fn_config:
309315
new_loss_config = OmegaConf.merge(OmegaConf.create(asdict(loss_config)), OmegaConf.create(loss_fn_config))
310316
# NOTE: users can provide a custom loss config class, so we need to use the same class after applying overrides
311317
loss_config = type(loss_config).from_dict_config(new_loss_config)
@@ -379,41 +385,47 @@ def loss_func(logits, data):
379385
if resolved_loss_name == "cross_entropy":
380386
loss = policy_loss
381387

382-
# Compute elementwise loss for Tinker API (per-token NLL)
383-
with torch.no_grad():
384-
elementwise_loss = -action_log_probs
385-
if loss_mask is not None:
386-
elementwise_loss = elementwise_loss * loss_mask
387-
388-
# Build per-sequence loss_fn_outputs.
389-
# Compute valid_lens vectorized on GPU, then move tensors to CPU
390-
# exactly once before iterating in Python — avoids ~3N GPU->CPU
391-
# syncs per micro-batch (item()/cpu()/tolist() inside the loop).
392-
batch_size = action_log_probs.shape[0]
393-
seq_len = action_log_probs.shape[1]
394-
if action_mask is not None:
395-
valid_lens_t = action_mask.sum(dim=-1).long()
396-
elif loss_mask is not None:
397-
valid_lens_t = loss_mask.sum(dim=-1).long()
388+
# Build per-token loss_fn_outputs unless the caller opted out (see
389+
# pop_return_per_token_outputs); skipping returns one empty dict per sequence.
390+
if return_per_token_outputs:
391+
# Compute elementwise loss for Tinker API (per-token NLL)
392+
with torch.no_grad():
393+
elementwise_loss = -action_log_probs
394+
if loss_mask is not None:
395+
elementwise_loss = elementwise_loss * loss_mask
396+
397+
# Compute valid_lens vectorized on GPU, then move tensors to CPU
398+
# exactly once before iterating in Python — avoids ~3N GPU->CPU
399+
# syncs per micro-batch (item()/cpu()/tolist() inside the loop).
400+
batch_size = action_log_probs.shape[0]
401+
seq_len = action_log_probs.shape[1]
402+
if action_mask is not None:
403+
valid_lens_t = action_mask.sum(dim=-1).long()
404+
elif loss_mask is not None:
405+
valid_lens_t = loss_mask.sum(dim=-1).long()
406+
else:
407+
valid_lens_t = torch.full(
408+
(batch_size,), seq_len, device=action_log_probs.device, dtype=torch.long
409+
)
410+
411+
# Bulk GPU->CPU sync: one transfer for logprobs, elementwise_loss, and valid_lens.
412+
action_log_probs_cpu = action_log_probs.detach().cpu()
413+
elementwise_loss_cpu = elementwise_loss.detach().cpu()
414+
valid_lens = valid_lens_t.cpu().tolist()
415+
416+
loss_fn_outputs = []
417+
for i in range(batch_size):
418+
valid_len = valid_lens[i]
419+
loss_fn_outputs.append(
420+
{
421+
"logprobs": (action_log_probs_cpu[i, -valid_len:].tolist() if valid_len > 0 else []),
422+
"elementwise_loss": (
423+
elementwise_loss_cpu[i, -valid_len:].tolist() if valid_len > 0 else []
424+
),
425+
}
426+
)
398427
else:
399-
valid_lens_t = torch.full((batch_size,), seq_len, device=action_log_probs.device, dtype=torch.long)
400-
401-
# Bulk GPU->CPU sync: one transfer for logprobs, elementwise_loss, and valid_lens.
402-
action_log_probs_cpu = action_log_probs.detach().cpu()
403-
elementwise_loss_cpu = elementwise_loss.detach().cpu()
404-
valid_lens = valid_lens_t.cpu().tolist()
405-
406-
loss_fn_outputs = []
407-
for i in range(batch_size):
408-
valid_len = valid_lens[i]
409-
loss_fn_outputs.append(
410-
{
411-
"logprobs": (action_log_probs_cpu[i, -valid_len:].tolist() if valid_len > 0 else []),
412-
"elementwise_loss": (
413-
elementwise_loss_cpu[i, -valid_len:].tolist() if valid_len > 0 else []
414-
),
415-
}
416-
)
428+
loss_fn_outputs = [{} for _ in range(action_log_probs.shape[0])]
417429

418430
metrics = {
419431
"loss": loss.item(),

skyrl/backends/skyrl_train/workers/worker.py

Lines changed: 96 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
BatchIterator,
5656
all_reduce_metrics,
5757
get_microbatch_iterator,
58+
pop_return_per_token_outputs,
5859
reduce_metrics,
5960
)
6061
from skyrl.env_vars import (
@@ -819,7 +820,10 @@ def _forward_backward_micro(
819820
loss_fn: Optional train loss function name to use instead of config default.
820821
Public Tinker aliases such as ``ppo`` should be normalized by the backend
821822
before reaching the worker.
822-
loss_fn_config: Optional config overrides for the resolved train loss function
823+
loss_fn_config: Optional config overrides for the resolved train loss function.
824+
May also carry the reserved key ``return_per_token_outputs`` (bool, default
825+
``True``); set ``False`` to skip the per-token ``loss_fn_outputs`` build when
826+
the caller reads only ``metrics``. See :func:`pop_return_per_token_outputs`.
823827
824828
Returns:
825829
Metrics dict for the worker's local micro batch
@@ -849,9 +853,12 @@ def _forward_backward_micro(
849853
# Fall back to config default
850854
current_loss_fn = self.policy_loss_fn
851855

856+
# Pop the per-request flag that gates the per-token loss_fn_outputs build.
857+
loss_fn_config, return_per_token_outputs = pop_return_per_token_outputs(loss_fn_config)
858+
852859
# Build config for loss function, applying any overrides
853860
loss_config = self.cfg.algorithm
854-
if loss_fn_config is not None:
861+
if loss_fn_config:
855862
# Create a copy of the config and apply overrides
856863
# TODO: Fix nested overrides
857864
from dataclasses import asdict
@@ -891,40 +898,47 @@ def _forward_backward_micro(
891898
loss = unscaled_loss * microbatch_weight
892899
self.strategy.backward(loss, self.model, self.optimizer)
893900

894-
# Compute elementwise loss for Tinker API (per-token NLL)
895-
with torch.no_grad():
896-
elementwise_loss = -action_log_probs
897-
if loss_mask is not None:
898-
elementwise_loss = elementwise_loss * loss_mask
899-
900-
# Build per-sequence loss_fn_outputs (matches Tinker's ForwardBackwardOutput structure)
901-
# Trim to actual response length per sample (Tinker expects variable-length arrays
902-
# that align with the input weights, not padded to batch max).
903-
# Compute valid_lens vectorized on GPU, then move tensors to CPU exactly
904-
# once before iterating in Python — avoids ~3N GPU->CPU syncs per micro-batch.
905-
batch_size = action_log_probs.shape[0]
906-
seq_len = action_log_probs.shape[1]
907-
if action_mask is not None:
908-
valid_lens_t = action_mask.sum(dim=-1).long()
909-
elif loss_mask is not None:
910-
valid_lens_t = loss_mask.sum(dim=-1).long()
901+
# Build per-token loss_fn_outputs unless the caller opted out (see
902+
# pop_return_per_token_outputs); skipping returns one empty dict per sequence.
903+
if return_per_token_outputs:
904+
# Compute elementwise loss for Tinker API (per-token NLL)
905+
with torch.no_grad():
906+
elementwise_loss = -action_log_probs
907+
if loss_mask is not None:
908+
elementwise_loss = elementwise_loss * loss_mask
909+
910+
# Build per-sequence loss_fn_outputs (matches Tinker's ForwardBackwardOutput structure)
911+
# Trim to actual response length per sample (Tinker expects variable-length arrays
912+
# that align with the input weights, not padded to batch max).
913+
# Compute valid_lens vectorized on GPU, then move tensors to CPU exactly
914+
# once before iterating in Python — avoids ~3N GPU->CPU syncs per micro-batch.
915+
batch_size = action_log_probs.shape[0]
916+
seq_len = action_log_probs.shape[1]
917+
if action_mask is not None:
918+
valid_lens_t = action_mask.sum(dim=-1).long()
919+
elif loss_mask is not None:
920+
valid_lens_t = loss_mask.sum(dim=-1).long()
921+
else:
922+
valid_lens_t = torch.full((batch_size,), seq_len, device=action_log_probs.device, dtype=torch.long)
923+
924+
# Bulk GPU->CPU sync: one transfer for logprobs, elementwise_loss, and valid_lens.
925+
action_log_probs_cpu = action_log_probs.detach().cpu()
926+
elementwise_loss_cpu = elementwise_loss.detach().cpu()
927+
valid_lens = valid_lens_t.cpu().tolist()
928+
929+
loss_fn_outputs = []
930+
for i in range(batch_size):
931+
valid_len = valid_lens[i]
932+
loss_fn_outputs.append(
933+
{
934+
"logprobs": action_log_probs_cpu[i, -valid_len:].tolist() if valid_len > 0 else [],
935+
"elementwise_loss": (
936+
elementwise_loss_cpu[i, -valid_len:].tolist() if valid_len > 0 else []
937+
),
938+
}
939+
)
911940
else:
912-
valid_lens_t = torch.full((batch_size,), seq_len, device=action_log_probs.device, dtype=torch.long)
913-
914-
# Bulk GPU->CPU sync: one transfer for logprobs, elementwise_loss, and valid_lens.
915-
action_log_probs_cpu = action_log_probs.detach().cpu()
916-
elementwise_loss_cpu = elementwise_loss.detach().cpu()
917-
valid_lens = valid_lens_t.cpu().tolist()
918-
919-
loss_fn_outputs = []
920-
for i in range(batch_size):
921-
valid_len = valid_lens[i]
922-
loss_fn_outputs.append(
923-
{
924-
"logprobs": action_log_probs_cpu[i, -valid_len:].tolist() if valid_len > 0 else [],
925-
"elementwise_loss": (elementwise_loss_cpu[i, -valid_len:].tolist() if valid_len > 0 else []),
926-
}
927-
)
941+
loss_fn_outputs = [{} for _ in range(action_log_probs.shape[0])]
928942

929943
status = {
930944
"loss": loss.item(),
@@ -1087,6 +1101,14 @@ def _forward_micro_with_loss(
10871101
Runs the model + loss under ``torch.no_grad()`` (no backward, no KL/entropy terms),
10881102
and returns the same metrics shape as the SFT branch of ``_forward_backward_micro``,
10891103
minus ``lr`` (no optimizer state involved).
1104+
1105+
Args:
1106+
experience: Experience object for one micro batch.
1107+
loss_fn: Eval loss function name (e.g., "cross_entropy").
1108+
loss_fn_config: Optional config overrides for the resolved loss function.
1109+
May also carry the reserved key ``return_per_token_outputs`` (bool, default
1110+
``True``); set ``False`` to skip the per-token ``loss_fn_outputs`` build when
1111+
the caller reads only ``metrics``. See :func:`pop_return_per_token_outputs`.
10901112
"""
10911113
self.model.eval()
10921114
experience.to_device(torch.cuda.current_device())
@@ -1102,9 +1124,12 @@ def _forward_micro_with_loss(
11021124

11031125
current_loss_fn = PolicyLossRegistry.get(loss_fn)
11041126

1127+
# Pop the per-request flag that gates the per-token loss_fn_outputs build.
1128+
loss_fn_config, return_per_token_outputs = pop_return_per_token_outputs(loss_fn_config)
1129+
11051130
# Build config for loss function, applying any overrides
11061131
loss_config = self.cfg.algorithm
1107-
if loss_fn_config is not None:
1132+
if loss_fn_config:
11081133
from dataclasses import asdict
11091134

11101135
new_loss_config = OmegaConf.merge(OmegaConf.create(asdict(loss_config)), OmegaConf.create(loss_fn_config))
@@ -1131,36 +1156,43 @@ def _forward_micro_with_loss(
11311156
rollout_logprobs=rollout_action_logprobs,
11321157
)
11331158

1134-
elementwise_loss = -action_log_probs
1135-
if loss_mask is not None:
1136-
elementwise_loss = elementwise_loss * loss_mask
1159+
# Build per-token loss_fn_outputs unless the caller opted out (see
1160+
# pop_return_per_token_outputs); skipping returns one empty dict per sequence.
1161+
if return_per_token_outputs:
1162+
elementwise_loss = -action_log_probs
1163+
if loss_mask is not None:
1164+
elementwise_loss = elementwise_loss * loss_mask
11371165

1138-
# Compute valid_lens vectorized on GPU, then move tensors to CPU
1139-
# exactly once before iterating in Python. Avoids ~3N GPU->CPU syncs
1140-
# per micro-batch (item()/cpu()/tolist() inside the per-sample loop).
1141-
batch_size = action_log_probs.shape[0]
1142-
seq_len = action_log_probs.shape[1]
1143-
if action_mask is not None:
1144-
valid_lens_t = action_mask.sum(dim=-1).long()
1145-
elif loss_mask is not None:
1146-
valid_lens_t = loss_mask.sum(dim=-1).long()
1166+
# Compute valid_lens vectorized on GPU, then move tensors to CPU
1167+
# exactly once before iterating in Python. Avoids ~3N GPU->CPU syncs
1168+
# per micro-batch (item()/cpu()/tolist() inside the per-sample loop).
1169+
batch_size = action_log_probs.shape[0]
1170+
seq_len = action_log_probs.shape[1]
1171+
if action_mask is not None:
1172+
valid_lens_t = action_mask.sum(dim=-1).long()
1173+
elif loss_mask is not None:
1174+
valid_lens_t = loss_mask.sum(dim=-1).long()
1175+
else:
1176+
valid_lens_t = torch.full((batch_size,), seq_len, device=action_log_probs.device, dtype=torch.long)
1177+
1178+
# Bulk GPU->CPU sync: one transfer for logprobs, elementwise_loss, and valid_lens.
1179+
action_log_probs_cpu = action_log_probs.detach().cpu()
1180+
elementwise_loss_cpu = elementwise_loss.detach().cpu()
1181+
valid_lens = valid_lens_t.cpu().tolist()
1182+
1183+
loss_fn_outputs = []
1184+
for i in range(batch_size):
1185+
valid_len = valid_lens[i]
1186+
loss_fn_outputs.append(
1187+
{
1188+
"logprobs": action_log_probs_cpu[i, -valid_len:].tolist() if valid_len > 0 else [],
1189+
"elementwise_loss": (
1190+
elementwise_loss_cpu[i, -valid_len:].tolist() if valid_len > 0 else []
1191+
),
1192+
}
1193+
)
11471194
else:
1148-
valid_lens_t = torch.full((batch_size,), seq_len, device=action_log_probs.device, dtype=torch.long)
1149-
1150-
# Bulk GPU->CPU sync: one transfer for logprobs, elementwise_loss, and valid_lens.
1151-
action_log_probs_cpu = action_log_probs.detach().cpu()
1152-
elementwise_loss_cpu = elementwise_loss.detach().cpu()
1153-
valid_lens = valid_lens_t.cpu().tolist()
1154-
1155-
loss_fn_outputs = []
1156-
for i in range(batch_size):
1157-
valid_len = valid_lens[i]
1158-
loss_fn_outputs.append(
1159-
{
1160-
"logprobs": action_log_probs_cpu[i, -valid_len:].tolist() if valid_len > 0 else [],
1161-
"elementwise_loss": (elementwise_loss_cpu[i, -valid_len:].tolist() if valid_len > 0 else []),
1162-
}
1163-
)
1195+
loss_fn_outputs = [{} for _ in range(action_log_probs.shape[0])]
11641196

11651197
return {
11661198
"loss": policy_loss.item(),

skyrl/backends/skyrl_train/workers/worker_utils.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import math
2-
from typing import Dict, Iterator, List
2+
from typing import Any, Dict, Iterator, List, Optional, Tuple
33

44
import torch
55
import torch.distributed as dist
@@ -9,6 +9,38 @@
99
from skyrl.train.dataset.bin_packing import make_seq_packer
1010
from skyrl.train.dataset.replay_buffer import Experience
1111

12+
# Reserved ``loss_fn_config`` key that gates the per-token ``loss_fn_outputs`` build.
13+
RETURN_PER_TOKEN_OUTPUTS_KEY = "return_per_token_outputs"
14+
15+
16+
def pop_return_per_token_outputs(
17+
loss_fn_config: Optional[Dict[str, Any]],
18+
) -> Tuple[Optional[Dict[str, Any]], bool]:
19+
"""Extract the per-request ``return_per_token_outputs`` flag from ``loss_fn_config``.
20+
21+
Default ``True`` keeps the existing contract (Tinker / RL consume the per-token
22+
``loss_fn_outputs``); SkyRL's own SFTTrainer sets it ``False`` since it only reads
23+
``metrics``. The flag is popped here because it is not an ``AlgorithmConfig`` field:
24+
leaving it in would trip the ``AlgorithmConfig`` key validation in
25+
``build_nested_dataclass`` (reached via ``from_dict_config``).
26+
27+
The pop runs on a shallow copy so the caller's ``loss_fn_config`` dict is never
28+
mutated (callers may reuse it across micro-batches). Returns the (possibly copied)
29+
config alongside the resolved flag.
30+
31+
Args:
32+
loss_fn_config: Optional per-call loss-function config overrides, or ``None``.
33+
34+
Returns:
35+
``(loss_fn_config, return_per_token_outputs)`` where ``loss_fn_config`` is a
36+
fresh copy with ``return_per_token_outputs`` removed when the input was not
37+
``None``, otherwise the original ``None``.
38+
"""
39+
if loss_fn_config is None:
40+
return None, True
41+
loss_fn_config = dict(loss_fn_config)
42+
return loss_fn_config, loss_fn_config.pop(RETURN_PER_TOKEN_OUTPUTS_KEY, True)
43+
1244

1345
def reduce_metrics(metrics: Dict[str, List[float]], sum_loss_metrics: bool = False) -> Dict[str, float]:
1446
"""Reduce scalar metrics from a list of entries per key with the appropriate reduction.

0 commit comments

Comments
 (0)