perf(sample-support): reuse replay scores for entropy - #2004
Conversation
Extract the single-request HTTP generation path out of RemoteInferenceClient into a standalone RemoteGenerateClient plus a RemoteGenerateResult dataclass, so routed-expert results can be obtained without constructing the full inference/control-plane client. RemoteInferenceClient now owns an internal RemoteGenerateClient and delegates session management, _post, and _generate_single to it. Endpoint routing, retry/backoff, cache_salt handling, serialization, and lifecycle behavior are unchanged.
For multi-turn rollouts, build routed-expert (R3) trace data incrementally as the conversation grows instead of re-gathering the whole conversation's routes on every turn. - Add `routed_experts_prompt_starts` to `InferenceEngineInput` and thread a per-request `routed_experts_prompt_start` through `RemoteInferenceClient` and `RemoteInferenceGenerator.generate()`, forwarded as a sampling param so the engine only returns routes for the newly generated suffix. - Introduce `TokenMetadataTrace` (token-aligned array accumulator) and `RoutedExpertTrace`, which records each generation's routes and finalizes a full per-token routed-expert array with loss-mask-aware terminal padding. - Track a `RoutedExpertTrace` on `AgentLoopState` and record routes per turn, replacing the previous whole-conversation re-gather in `SkyRLGymGenerator.agent_loop`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Add the core of bounded sampler-support replay across data plumbing, inference capture, and the Megatron dense-path trainer. - Inference: /skyrl/v1/generate gains a return_sample_support flag that requests flat_logprobs from vLLM; the fixed-width rows are decoded into per-token support IDs and surfaced through RemoteGenerateClient, RemoteGenerateResult, and InferenceEngineOutput.rollout_sample_support. - Data plumbing: support rows ride the shared TokenMetadataTrace through multi-turn accumulation, dynamic filtering, truncation, replay buffering, and microbatch padding; preprocessing validates support width, trailing -1 padding, int32 vocab IDs, and presence of every loss-bearing token. - Config: reject sampler settings whose rollout distribution cannot be replayed exactly (temperature > 0, top_k > 1, repetition penalty 1.0, no arbitrary additional_kwargs). - Megatron: renormalize sampled-token scores over recorded support with fixed-shape gathers; fused LM-head path projects only selected candidate pairs and chunks by logprobs_chunk_size; controller-packed rows reuse TokenMetadataLayout. New utils/sample_support_replay.py holds the core. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…om its top-k support (NovaSky-AI#66) * fix(sample-support): repair rows where the sampled token is absent from its top-k support vLLM's approximate top-k/top-p pivot can let a sampled survivor rank just beyond top_k, so it is missing from the captured support row and the downstream sample-support invariant hard-crashes the run. When the sampled id is absent, overwrite the weakest support member with it, preserving width==top_k, single-occurrence, and trailing padding. Emit one aggregated warning per call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(sample-support): apply black formatting to regression test --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend bounded sampler-support replay to the FSDP backend so policy and reference forwards apply the same support-conditioned normalization as Megatron, keeping recomputed logprobs on the same distribution. - Enable replay for trainer.strategy=fsdp with the existing sampler-support constraints; thread support IDs and the response loss mask through policy training, policy recomputation, and reference forwards only when enabled. - Right-align the response loss mask into the full token layout and keep sampled IDs, support IDs, and the mask aligned through padding removal, next-token shifting, and Ulysses SP slicing; reuse padding-removal indices. - Generalize Ulysses input partitioning to [batch, seq, ...] metadata with a configurable padding value, avoiding a second validity-mask partition. - Share the backend-neutral aligned replay helper for support normalization and synthetic-EOS handling; preserve feature-disabled behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Transport dense sampler-support vocab IDs as a base64-encoded int32 NumPy buffer inside the orjson response envelope, replacing nested JSON integer lists. - keep vLLM's [tokens, top_k] support as a contiguous int32 array and encode it via pybase64 in the response envelope; - add sample_support_set_wire with PackedSampleSupportSet as the wire contract, validating dtype, shape, byte count, ID range, and trailing -1 padding; - decode once in RemoteInferenceGenerator before populating RemoteGenerateResult.sample_support; - add a benchmark and tests covering the codec, malformed payloads, empty token dimensions, legacy-list rejection, and endpoint decode. Trainer representation and replay semantics are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements sample support replay, allowing policy logprobs to be renormalized over the sampler's recorded support. It introduces a packed wire format for dense sampler-support vocab IDs, adds a reusable RemoteGenerateClient for handling raw token generation requests, and integrates support-conditioned logprob and entropy computations into the Megatron and FSDP training pipelines. Additionally, Ulysses sequence parallelism utilities are updated to preserve trailing dimensions for token-aligned metadata. The review feedback suggests optimizing the _selected_hidden_projection function in sample_support_replay.py by chunking along the row dimension instead of flat pairs to avoid costly gather operations and memory allocations on the large hidden tensor.
| def _selected_hidden_projection( | ||
| hidden: torch.Tensor, | ||
| token_ids: torch.Tensor, | ||
| local_mask: torch.Tensor, | ||
| lm_head_weight: torch.Tensor, | ||
| temperature: float, | ||
| chunk_size: int | None, | ||
| invalid_value: float, | ||
| ) -> torch.Tensor: | ||
| """Project selected candidate pairs without materializing vocabulary logits.""" | ||
| num_rows, width = token_ids.shape | ||
| row_ids = torch.arange(num_rows, device=hidden.device).unsqueeze(1).expand(-1, width).reshape(-1) | ||
| flat_token_ids = token_ids.reshape(-1) | ||
| flat_mask = local_mask.reshape(-1) | ||
| output = torch.empty(flat_token_ids.shape, dtype=torch.float32, device=hidden.device) | ||
| # Bound the temporary [candidate pairs, hidden] projection for wide supports. | ||
| pair_chunk_size = flat_token_ids.numel() if chunk_size is None else chunk_size | ||
| for start in range(0, flat_token_ids.numel(), pair_chunk_size): | ||
| end = min(start + pair_chunk_size, flat_token_ids.numel()) | ||
| selected_hidden = hidden.index_select(0, row_ids[start:end]).to(lm_head_weight.dtype) | ||
| selected_weight = lm_head_weight.index_select(0, flat_token_ids[start:end]) | ||
| projected = (selected_hidden * selected_weight).sum(dim=-1) / temperature | ||
| output[start:end] = torch.where( | ||
| flat_mask[start:end], | ||
| projected.to(torch.float32), | ||
| invalid_value, | ||
| ) | ||
| return output.reshape(num_rows, width) |
There was a problem hiding this comment.
In _selected_hidden_projection, using hidden.index_select with repeated row_ids triggers a costly gather operation and memory allocation on the large hidden tensor (shape [num_tokens, hidden_dim]).
Since hidden is already organized by token rows, we can optimize this by chunking along the row dimension instead of flat pairs. Slicing hidden[start:end] is a
def _selected_hidden_projection(
hidden: torch.Tensor,
token_ids: torch.Tensor,
local_mask: torch.Tensor,
lm_head_weight: torch.Tensor,
temperature: float,
chunk_size: int | None,
invalid_value: float,
) -> torch.Tensor:
"""Project selected candidate pairs without materializing vocabulary logits."""
num_rows, width = token_ids.shape
output = torch.empty(token_ids.shape, dtype=torch.float32, device=hidden.device)
# Bound the temporary projection by chunking along the token-row dimension.
row_chunk_size = num_rows if chunk_size is None else max(1, chunk_size // width)
for start in range(0, num_rows, row_chunk_size):
end = min(start + row_chunk_size, num_rows)
chunk_hidden = hidden[start:end].to(lm_head_weight.dtype)
chunk_token_ids = token_ids[start:end]
chunk_mask = local_mask[start:end]
selected_weight = lm_head_weight.index_select(0, chunk_token_ids.reshape(-1)).reshape(
end - start, width, -1
)
projected = (chunk_hidden.unsqueeze(1) * selected_weight).sum(dim=-1) / temperature
output[start:end] = torch.where(
chunk_mask,
projected.to(torch.float32),
invalid_value,
)
return output
Problem
Sample-support replay renormalizes token log-probabilities over the recorded sampling support, but entropy still takes the full-vocabulary path. That loses most of the compute and memory benefit when support is narrow, and it prevents differentiable entropy from composing with the fused LM-head path.
Implementation
SampleSupportScores;Entropy is computed for the policy conditioned on the recorded support set. A singleton support therefore has exactly zero entropy and zero entropy gradient. The change keeps the existing dense support representation and does not add a CSR path.
Performance
The replay entropy path scales with recorded support width rather than vocabulary size. It also shares the tensor-parallel collective with replay log-probability computation. With a fused LM head, it uses the existing selected hidden-state/weight projections and does not materialize full-vocabulary logits.
Testing