Skip to content

refactor(wire): generic packed-ndarray codec and an N-field response-body splice - #2079

Open
dyurk-lila wants to merge 8 commits into
NovaSky-AI:mainfrom
dyurk-lila:dyurk/generate-wire-generic-codec
Open

refactor(wire): generic packed-ndarray codec and an N-field response-body splice#2079
dyurk-lila wants to merge 8 commits into
NovaSky-AI:mainfrom
dyurk-lila:dyurk/generate-wire-generic-codec

Conversation

@dyurk-lila

@dyurk-lila dyurk-lila commented Aug 22, 2026

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 cannot 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

The generate endpoint returns large NumPy side channels inside a JSON response. Parsing the entire body normally first materializes each base64 blob as a Python string, adding a large allocation and copy before decoding. The existing optimization recognized only the routed-expert field through one hard-coded byte prefix; adding another packed field would silently leave that second blob on the expensive path.

Wire contract

Packed arrays use one typed envelope:

{"data": "<base64>", "shape": [12, 40, 8], "dtype": "int16"}

data is deliberately the first key. The client can then replace registered blobs with memoryview objects before handing the remaining JSON to orjson. Shape, dimensionality, dtype, and decoded byte count are still validated by the field-specific decoder.

Implementation

  • Introduce shared pack_ndarray and unpack_ndarray helpers with explicit allowed-dtype and dimensionality contracts.
  • Keep routed-expert packing as a thin field-specific validator over the generic codec.
  • Register packed response fields centrally so future side channels do not add another parser.
  • Scan the response once, splice every registered blob, and restore blobs to envelopes in document order.
  • Preserve absent and null fields, sidecar metadata, multiple choices, and all ordinary JSON content.
  • Fail if the serialized envelope layout drifts instead of silently materializing a large base64 string.
  • Use packed parsing only for generate requests that can return side channels; ordinary generation retains direct orjson parsing.

Transport errors and undecodable gateway responses retain the existing retry behavior. A valid JSON response with a malformed packed contract is treated as a deterministic protocol error and is not retried.

Performance

For a representative 121 MiB response containing 90 MiB of routes, parsing and decoding improved from 268 ms to 83 ms. Registering a second packed field did not add another full-body scan. The optimization is therefore tied to the response as a whole rather than to one particular side channel.

Testing

  • Codec tests cover contiguous and non-contiguous arrays, dtype and shape validation, truncated buffers, sidecar fields, and canonical routed-expert dtypes.
  • Body-splice tests cover one and multiple fields, multiple choices, absent and null values, JSON lookalikes, reordered envelopes, unregistered fields, and unterminated data.
  • Remote-client integration tests cover successful two-field decoding, transient-response retries, deterministic layout failures, text error bodies, and bypassing the scanner for ordinary generation.

Note

High Risk
Changes the generate HTTP wire contract, client parsing of large binary side channels, and MoE routed-expert bookkeeping used in training. Bugs here can silently drop or misalign replay routes.

Overview
Makes generate-side NumPy payloads a generic packed-array envelope (data/shape/dtype, data first) so the client can splice all registered blobs into memoryviews before orjson parses the rest. pack_ndarray/unpack_ndarray plus load_packed_body replace the routed-experts-only codec; routed_experts packing is a thin validator on top. Layout drift fails instead of materializing huge base64 strings. Ordinary generate still uses direct JSON parse.

Extracts RemoteGenerateClient for one-shot token generation and retries. Generation can send routed_experts_prompt_starts. Multi-turn R3 no longer overwrites a full-sequence route array each turn: RoutedExpertTrace (on TokenMetadataTrace) records suffixes, pads only loss-masked tails, and rejects missing routes on loss-active tokens.

Controller collation writes left-padded route tensors in parallel via fill_batch_rows, sized with new pool_workers (affinity + cgroup quota). Weight-sync apply/publish uses the same helper. Canonical dtypes are validated, not re-compacted. Routing replay is rejected with virtual pipeline parallelism (FIFO desync).

Reviewed by Cursor Bugbot for commit a604e5c. Bugbot is set up for automated code reviews on this repo. Configure here.

dyurk-lila and others added 8 commits August 19, 2026 00:59
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>

@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 support for packed side-channel arrays (such as routed experts) in the inference server wire format using base64 envelopes, along with incremental routing trace tracking and dynamic thread pool sizing based on cgroup quotas and process affinity. The review feedback highlights several key improvements for robustness and type safety, including catching ValueError during response parsing to ensure transient failures are retried, preventing false-positive key matches in the JSON stream scanner, consistently using .value on StrEnum keys, and catching OSError on os.sched_getaffinity to support restricted environments.

try:
raw = await resp.read()
body = load_packed_body(raw) if packed_side_channels else orjson.loads(raw)
except orjson.JSONDecodeError as exc:

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.

high

If load_packed_body(raw) raises a ValueError (e.g., due to a truncated/incomplete response or layout drift), it will not be caught by except orjson.JSONDecodeError. This causes transient network truncation errors to immediately fail the request instead of being retried. Catching ValueError instead of orjson.JSONDecodeError ensures both JSON decoding and parsing/splicing errors are safely retried.

Suggested change
except orjson.JSONDecodeError as exc:
except ValueError as exc:


PACKED_SIDE_CHANNEL_FIELDS: tuple[str, ...] = tuple(PackedField)

_ENVELOPE_KEYS = frozenset(PackedArrayKey)

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

Using PackedArrayKey directly in frozenset creates a set of StrEnum instances. For cleaner and safer operations (especially when intersecting with sets of plain str keys), it is better to explicitly use their .value strings.

Suggested change
_ENVELOPE_KEYS = frozenset(PackedArrayKey)
_ENVELOPE_KEYS = frozenset(key.value for key in PackedArrayKey)

Comment on lines +231 to +237
def _match_packed_field(raw: bytes, anchor: int, prefixes: Mapping[str, bytes]) -> Optional[str]:
"""Name the registered field whose prefix ends at ``anchor``, if any."""
for field, prefix in prefixes.items():
begin = anchor + len(_PACKED_DATA_ANCHOR) - len(prefix)
if begin >= 0 and raw.startswith(prefix, begin):
return field
return None

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

The current suffix matching logic in _match_packed_field can lead to false positives if another key in the JSON response ends with a registered field name (e.g., "not_routed_experts" matching "routed_experts"). This would cause a ValueError during restoration and crash the request. To prevent this, ensure the matched prefix is preceded by a valid JSON delimiter like {, ,, or whitespace.

Suggested change
def _match_packed_field(raw: bytes, anchor: int, prefixes: Mapping[str, bytes]) -> Optional[str]:
"""Name the registered field whose prefix ends at ``anchor``, if any."""
for field, prefix in prefixes.items():
begin = anchor + len(_PACKED_DATA_ANCHOR) - len(prefix)
if begin >= 0 and raw.startswith(prefix, begin):
return field
return None
def _match_packed_field(raw: bytes, anchor: int, prefixes: Mapping[str, bytes]) -> Optional[str]:
"""Name the registered field whose prefix ends at ``anchor``, if any."""
for field, prefix in prefixes.items():
begin = anchor + len(_PACKED_DATA_ANCHOR) - len(prefix)
if begin >= 0 and raw.startswith(prefix, begin):
# Ensure the match is not a suffix of a longer key (e.g., "not_routed_experts")
if begin == 0 or raw[begin - 1] in (123, 44, 32, 9, 10, 13):
return field
return None

if isinstance(node, dict):
for key, value in node.items():
queue = blobs.get(key)
if queue is not None and isinstance(value, dict) and PackedArrayKey.DATA in value:

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

For consistency with line 248 and to ensure compatibility with strict type checkers, use PackedArrayKey.DATA.value instead of PackedArrayKey.DATA when checking for key existence in the dictionary.

Suggested change
if queue is not None and isinstance(value, dict) and PackedArrayKey.DATA in value:
if queue is not None and isinstance(value, dict) and PackedArrayKey.DATA.value in value:


routed_experts = None
if return_routed_experts:
packed_routed_experts = choice.get(PackedField.ROUTED_EXPERTS)

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

For consistency with other parts of the codebase (such as vllm_server_actor.py and test_remote_inference_client.py) and to prevent potential type-checking warnings, use .value on the PackedField enum member.

Suggested change
packed_routed_experts = choice.get(PackedField.ROUTED_EXPERTS)
packed_routed_experts = choice.get(PackedField.ROUTED_EXPERTS.value)

Comment on lines +58 to +61
try:
affinity = len(os.sched_getaffinity(0))
except AttributeError:
affinity = os.cpu_count() or 1

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 restricted, sandboxed, or containerized environments, os.sched_getaffinity can raise an OSError rather than AttributeError. Catching OSError as well ensures the function robustly falls back to os.cpu_count() instead of crashing.

Suggested change
try:
affinity = len(os.sched_getaffinity(0))
except AttributeError:
affinity = os.cpu_count() or 1
try:
affinity = len(os.sched_getaffinity(0))
except (AttributeError, OSError):
affinity = os.cpu_count() or 1

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit a604e5c. Configure here.

rollout_expert_indices_out = agent_loop_state.routed_expert_trace.finalize(
token_count=len(prompt_ids) + len(response_ids),
loss_mask=[0] * len(prompt_ids) + loss_mask,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Synthetic EOS breaks R3 finalize

High Severity

In single-turn mode the loop appends a synthetic EOS with loss_mask 1, then RoutedExpertTrace.finalize rejects any loss-active target past the captured route prefix. Trajectories that previously padded that terminal gap now raise during finalize, so single-turn R3 generation fails whenever EOS is appended.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a604e5c. Configure here.

f"{worker_type}.megatron_config: moe_enable_routing_replay is incompatible with "
"virtual_pipeline_model_parallel_size -- interleaved chunks desync the replay FIFO. "
"Unset virtual_pipeline_model_parallel_size."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

VPP=1 rejected inconsistently

Medium Severity

Config validation treats any truthy virtual_pipeline_model_parallel_size as incompatible with routing replay, including 1. The Megatron worker only errors when the resolved size is greater than 1, since size 1 is not interleaved. Valid configs with VPP explicitly set to 1 are refused at startup despite being allowed later.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a604e5c. Configure here.

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