Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/content/docs/algorithms/off_policy_correction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ trainer:
```

To enable rollout router replay, set `generator.inference_engine.enable_return_routed_experts=True`, `trainer.policy.megatron_config.moe_enable_routing_replay=True`, and use the `mp` distributed_executor_backend for vLLM. Note that
R3 requires per-token routes that line up with the trained tokens, so `SkyRLGymGenerator` refuses it together with `generator.step_wise_trajectories`, `generator.use_conversation_multi_turn=False`, a custom `generator.chat_template`, and `generator.vision_language_generator` (each breaks that alignment; see the [step-wise training](../tutorials/step-wise-training) page for the step-wise reason). Routes are captured for train batches only. Note that
R3 does induce additional training bias when mini-batching, since routing decisions are fixed for all mini-batches in a training batch. However, it has been shown to be important for stabilizing large-scale MoE training, particularly
in models adopting a DeepSeek-V3 like architecture (notably the GLM family) due to the use of sigmoid-based affinity scoring instead of softmax for top-k routing.

Expand Down
7 changes: 6 additions & 1 deletion docs/content/docs/tutorials/step-wise-training.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ class GeneratorOutput(TypedDict):
rollout_metrics: Optional[Dict[str, Any]]
rollout_logprobs: Optional[List[List[float]]]
trajectory_ids: Optional[List[TrajectoryID]]
rollout_expert_indices: Optional[List[List[List[List[int]]]]]
trajectory_generation_times: Optional[List[float]]
trajectory_time_splits: Optional[Dict[str, List[float]]]
rollout_expert_indices: Optional[List[RoutedExpertIndices]]
rollout_sample_support: Optional[List[List[List[int]]]]
# Applicable only for step-wise training
is_last_step: Optional[List[bool]]
```
Expand All @@ -85,6 +88,8 @@ When `step_wise_trajectories=True`, some related fields:
| `is_last_step` | `List[bool]` | Marks the final step of each trajectory. Must have at least one `True`, and the last element must be `True`. |
| `trajectory_ids` | `List[TrajectoryID]` | Associates each step-sample with its parent trajectory. All steps of the same trajectory share the same `TrajectoryID`. |
| `rollout_logprobs` | `List[List[float]]` | Per-token logprobs from the inference engine, aligned with `response_ids`. Required for TIS. |
| `rollout_sample_support` | `List[List[List[int]]]` | Per generated token, the bounded top-k vocab IDs the sampler drew from, right-padded with `SAMPLE_SUPPORT_PADDING` (`-1`). One dense `[tokens, top_k]` block per row, aligned with `response_ids`. Every position must be present: an observation token or a synthetic (loop-appended) EOS carries an all-padding row rather than being absent, so the block stays rectangular and aligned. Enabled with `generator.inference_engine.enable_return_sample_support_set`, and requested per request (train batches only — eval's greedy `top_k=-1` params cannot satisfy the capture contract). |
| `rollout_expert_indices` | `Optional[List[RoutedExpertIndices]]` | Per token, the `[layers, topk]` MoE routes vLLM recorded, as one `[tokens, layers, topk]` integer array per trajectory (rollout router replay, R3). **Refused with `step_wise_trajectories=True`**: each step-wise row's prompt is the whole history so far while routes are recorded for that step's generated tokens only, so a step's routes would replay onto the first N prompt tokens of its row with no length mismatch to assert on, silently training against routing that does not match the rollout. |

### Concrete Example

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
import torch
import torch.distributed as dist

from skyrl.backends.skyrl_train.utils.packed_tensor import lengths_from_offsets


@torch.no_grad()
def _compute_distributed_log_softmax(
Expand Down Expand Up @@ -924,7 +926,7 @@ def _packed_sequence_indices(
token_indices = torch.arange(total_tokens, device=device)
seq_indices = torch.searchsorted(cu_seqlens_padded[1:], token_indices, right=True)
seq_offsets = token_indices - cu_seqlens_padded[seq_indices]
seq_lens_padded = cu_seqlens_padded[1:] - cu_seqlens_padded[:-1]
seq_lens_padded = lengths_from_offsets(cu_seqlens_padded)
return cu_seqlens_padded, token_indices, seq_indices, seq_offsets, seq_lens_padded


Expand Down
158 changes: 150 additions & 8 deletions skyrl/backends/skyrl_train/distributed/megatron/token_metadata.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Token-aligned metadata layout transforms shared by training features."""

from collections.abc import Callable, Sequence
from dataclasses import dataclass

import numpy as np
import torch

from skyrl.backends.skyrl_train.distributed.megatron.packing_utils import (
get_packed_seq_align_size,
get_unpacked_seq_align_size,
)
from skyrl.backends.skyrl_train.utils.packed_tensor import PackedTensor

# Megatron is imported lazily inside functions so that non-Megatron backends can
# import this module for its layout dataclass and padding transforms.
Expand Down Expand Up @@ -99,34 +102,113 @@ def align_token_metadata(
f"attention_mask shape {layout.attention_mask.shape}"
)

return _align_token_rows(
lambda row_index: metadata[row_index, layout.attention_mask[row_index]],
metadata,
metadata.shape[2:],
layout,
padding_value,
next_token=next_token,
)


def align_packed_token_metadata(
metadata: PackedTensor,
layout: TokenMetadataLayout,
padding_value: torch.Tensor | bool | int,
*,
next_token: bool = False,
segment_starts: Sequence[int] | None = None,
) -> torch.Tensor:
"""Align metadata that already arrives packed as ``[sum(seqlen), *row_shape]``.

This relies on left padding, which makes each trajectory's real tokens contiguous.
Without ``segment_starts``, segments must match ``layout.sequence_lengths``;
otherwise each segment is placed at its specified real-token offset.
"""
if metadata.device != layout.attention_mask.device:
raise ValueError("Token-aligned metadata and attention_mask must be on the same device")
if len(metadata) != len(layout.sequence_lengths):
raise ValueError(
f"Packed metadata holds {len(metadata)} segments for {len(layout.sequence_lengths)} trajectories"
)
segment_lengths = metadata.sequence_lengths.tolist()
if segment_starts is None:
if segment_lengths != list(layout.sequence_lengths):
raise ValueError(
f"Packed metadata segments {segment_lengths} do not match "
f"trajectory lengths {list(layout.sequence_lengths)}"
)
else:
if len(segment_starts) != len(metadata):
raise ValueError(f"Got {len(segment_starts)} segment starts for {len(metadata)} segments")
for row_index, (start, length) in enumerate(zip(segment_starts, segment_lengths, strict=True)):
if start < 0 or start + length > layout.sequence_lengths[row_index]:
raise ValueError(
f"Segment {row_index} spans real tokens [{start}, {start + length}) of a "
f"{layout.sequence_lengths[row_index]}-token trajectory"
)

return _align_token_rows(
metadata.segment,
metadata.values,
metadata.row_shape,
layout,
padding_value,
next_token=next_token,
segment_starts=segment_starts,
)


def _align_token_rows(
rows_for: Callable[[int], torch.Tensor],
source: torch.Tensor,
row_shape: tuple[int, ...] | torch.Size,
layout: TokenMetadataLayout,
padding_value: torch.Tensor | bool | int,
*,
next_token: bool = False,
segment_starts: Sequence[int] | None = None,
) -> torch.Tensor:
"""Place each trajectory's real-token rows into Megatron's layout and CP-shard them.

``rows_for(row_index)`` yields one trajectory's rows. They land at the front of its
padded region unless ``segment_starts`` names a per-trajectory destination offset.
"""
if layout.padded_sequence_lengths is None:
if next_token:
raise ValueError("next-token metadata alignment is only used for packed sequences")
aligned = _new_metadata_tensor(
metadata,
(metadata.shape[0], layout.aligned_sequence_length, *metadata.shape[2:]),
source,
(len(layout.sequence_lengths), layout.aligned_sequence_length, *row_shape),
padding_value,
)
for row_index, sequence_length in enumerate(layout.sequence_lengths):
aligned[row_index, :sequence_length] = metadata[row_index, layout.attention_mask[row_index]]
rows = rows_for(row_index)
start = 0 if segment_starts is None else segment_starts[row_index]
end = sequence_length if segment_starts is None else start + rows.shape[0]
aligned[row_index, start:end] = rows
return aligned

packed = _new_metadata_tensor(
metadata,
(layout.aligned_sequence_length, *metadata.shape[2:]),
source,
(layout.aligned_sequence_length, *row_shape),
padding_value,
)
offset = 0
for row_index, (sequence_length, padded_length) in enumerate(
zip(layout.sequence_lengths, layout.padded_sequence_lengths, strict=True)
):
packed[offset : offset + sequence_length] = metadata[row_index, layout.attention_mask[row_index]]
rows = rows_for(row_index)
start = offset if segment_starts is None else offset + segment_starts[row_index]
end = offset + sequence_length if segment_starts is None else start + rows.shape[0]
packed[start:end] = rows
# Match Megatron's [seq0, pad0, seq1, pad1, ...] microbatch layout.
offset += padded_length

if next_token:
# Each packed logit predicts the next token within its own padded sequence.
shifted = _new_metadata_tensor(metadata, packed.shape, padding_value)
shifted = _new_metadata_tensor(source, packed.shape, padding_value)
offset = 0
for padded_length in layout.padded_sequence_lengths:
shifted[offset : offset + padded_length - 1] = packed[offset + 1 : offset + padded_length]
Expand All @@ -135,7 +217,7 @@ def align_token_metadata(

if layout.context_parallel_size > 1:
out = _new_metadata_tensor(
metadata,
source,
(packed.shape[0] // layout.context_parallel_size, *packed.shape[1:]),
padding_value,
)
Expand Down Expand Up @@ -205,3 +287,63 @@ def scatter_packed_token_values_to_batch(
)
batch_values[output_mask] = values[packed_mask]
return batch_values


class TokenMetadataTrace:
"""Accumulate arrays whose first dimension is aligned to tokens."""

def __init__(self) -> None:
self._chunks: list[np.ndarray] = []
self._schema: tuple[tuple[int, ...], np.dtype] | None = None
self._num_rows = 0
self._finalized = False

@property
def num_rows(self) -> int:
return self._num_rows

def append(self, rows: np.ndarray, *, expected_rows: int) -> None:
if self._finalized:
raise RuntimeError("token metadata trace is already finalized")
if isinstance(expected_rows, bool) or not isinstance(expected_rows, int) or expected_rows < 0:
raise ValueError(f"expected_rows must be a non-negative integer, got {expected_rows!r}")
if not isinstance(rows, np.ndarray):
raise TypeError("token metadata rows must be a NumPy array")
if rows.ndim < 1:
raise ValueError("token metadata must have a token-row dimension")
if rows.shape[0] != expected_rows:
raise ValueError(f"token metadata has {rows.shape[0]} rows, expected {expected_rows}")
if not rows.flags.c_contiguous:
raise ValueError("token metadata rows must be contiguous")

schema = (rows.shape[1:], rows.dtype)
if self._schema is None:
self._schema = schema
elif schema != self._schema:
raise ValueError(f"token metadata schema changed from {self._schema} to {schema}")

self._chunks.append(rows)
self._num_rows += expected_rows

def append_padding(self, count: int, *, fill: int = -1) -> None:
"""Append ``count`` rows of ``fill`` in the schema already established by ``append``."""
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
raise ValueError(f"padding count must be a non-negative integer, got {count!r}")
if count == 0:
return
if self._schema is None:
raise ValueError("cannot pad token metadata before any rows are captured")

row_shape, dtype = self._schema
self.append(np.full((count, *row_shape), fill, dtype=dtype, order="C"), expected_rows=count)

def finalize(self, *, expected_rows: int) -> np.ndarray:
if self._finalized:
raise RuntimeError("token metadata trace is already finalized")
if self._num_rows != expected_rows:
raise ValueError(f"token metadata trace has {self._num_rows} rows, expected {expected_rows}")
if not self._chunks:
raise ValueError("token metadata trace has no chunks")

self._finalized = True
return self._chunks[0] if len(self._chunks) == 1 else np.concatenate(self._chunks, axis=0)
6 changes: 6 additions & 0 deletions skyrl/backends/skyrl_train/inference_servers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import TYPE_CHECKING, Any, Dict, Hashable, List, Optional, Tuple, TypedDict

from skyrl.backends.skyrl_train.utils.routed_experts import RoutedExpertIndices
from skyrl.backends.skyrl_train.utils.sample_support import SampleSupport

if TYPE_CHECKING:
from skyrl.backends.skyrl_train.weight_sync import WeightUpdateRequest
Expand Down Expand Up @@ -34,6 +35,9 @@ class InferenceEngineInput(TypedDict):
# Optional prefix-cache salt forwarded to vLLM as the request ``cache_salt`` so cache blocks are
# only shared between requests carrying the same salt. See ``GeneratorConfig.use_cache_salt``.
cache_salt: Optional[str]
routed_experts_prompt_starts: Optional[List[int]]
# Per-batch opt-in; the engine must enable sample-support capture at startup.
return_sample_support: Optional[bool]


class InferenceEngineOutput(TypedDict):
Expand All @@ -50,6 +54,8 @@ class InferenceEngineOutput(TypedDict):
response_logprobs: Optional[List[List[float]]]
prompt_logprobs: Optional[List[List[float]]] # per-prompt-token logprobs under the current model
rollout_expert_indices: Optional[List[RoutedExpertIndices]]
# One ``[generated_tokens, top_k]`` int32 support array per prompt.
rollout_sample_support: Optional[List[SampleSupport]]


class InferenceEngineInterface(ABC):
Expand Down
Loading
Loading