Skip to content

perf(sample-support): reuse replay scores for entropy - #2004

Open
dyurk-lila wants to merge 9 commits into
NovaSky-AI:mainfrom
dyurk-lila:dyurk/sample-support-conditioned-entropy
Open

perf(sample-support): reuse replay scores for entropy#2004
dyurk-lila wants to merge 9 commits into
NovaSky-AI:mainfrom
dyurk-lila:dyurk/sample-support-conditioned-entropy

Conversation

@dyurk-lila

Copy link
Copy Markdown
Contributor

Note on the diff: This PR is part of a routed-expert-replay / sampler-support series and builds on the PRs below. GitHub can't show the intermediate branches here, so the diff is cumulative on top of main — the changes new to this PR sit on top of:

Reviewing in PR order (lowest number first) shows each incremental change cleanly.

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

  • return log-probabilities, optional entropy, and valid-row metadata together as SampleSupportScores;
  • reuse the support logits and exponentials already computed for replay, adding the entropy statistic to the same tensor-parallel reduction;
  • use support-conditioned entropy in both the FSDP/Hugging Face and Megatron trainers;
  • keep the synthetic-EOS full-vocabulary log-probability fallback, while excluding rows without recorded support from the entropy reduction;
  • make entropy computation and gradient tracking separate, explicit boolean controls;
  • preserve the existing full-vocabulary entropy path when sample-support replay is disabled.

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

  • value and gradient parity against direct support-conditioned references;
  • detached metric entropy and differentiable entropy-loss modes;
  • singleton, empty-support, and synthetic-EOS behavior;
  • sequence-parallel metadata alignment;
  • FSDP integration verifies that replay entropy bypasses full-vocabulary entropy;
  • focused test suite: 23 passed;
  • Black 24.10.0, Ruff 0.11.9, bytecode compilation, and explicit-call validation pass.

dyurk-lila and others added 9 commits August 7, 2026 16:09
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>

@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 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.

Comment on lines +24 to +51
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)

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.

medium

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 $O(1)$ view operation in PyTorch that avoids copying the hidden states. We can then use broadcasting to compute the dot products efficiently.

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

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