(5/N) refactor(session): assemble training samples on the session server; records never leave it - #1605
(5/N) refactor(session): assemble training samples on the session server; records never leave it#1605guapisolo wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request shifts training-sample assembly into the session server's owning worker, preventing raw session records from leaving the server and significantly reducing IPC payload sizes. It introduces a new POST /sessions/{session_id}/samples endpoint, moves the assembly logic to a new sample_assembly.py module, and implements a custom binary wire codec to overlay computed fields onto the driver's local input sample. Feedback on the changes highlights opportunities to improve robustness and performance: safely retrieving input_ids to avoid an uncaught KeyError (which would bypass the 422 error handler), resolving inconsistent dictionary access for meta_info, explicitly defining dtypes for all serialized segments to prevent type mismatches, and using memoryview for zero-copy buffer slicing to optimize memory usage during deserialization.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
|
||
| for i, record in enumerate(records): | ||
| is_last = i == len(records) - 1 | ||
| prompt_ids = record.request["input_ids"] |
There was a problem hiding this comment.
In compute_samples_from_openai_records, record.request["input_ids"] is accessed directly. If "input_ids" is missing from the request, this will raise a KeyError before reaching _compute_sample_from_openai_record. Since KeyError is not caught in collect_samples (which only catches AssertionError and ValueError), this will propagate as an uncaught exception and return a 502 Bad Gateway instead of a 422 response.
To ensure missing "input_ids" is correctly handled as a ValueError (which maps to a 422 response), retrieve it safely using .get() and raise a ValueError if it is missing.
| prompt_ids = record.request["input_ids"] | |
| prompt_ids = record.request.get("input_ids") | |
| if prompt_ids is None: | |
| raise ValueError("input_ids not found in request — the session server should populate it") |
| sample.prefix_cache_info.add(choice.get("meta_info", {})) | ||
| if "weight_version" in choice["meta_info"]: | ||
| sample.weight_versions.append(choice["meta_info"]["weight_version"]) |
There was a problem hiding this comment.
Using choice.get("meta_info", {}) on line 145 and then directly accessing choice["meta_info"] on line 146 is inconsistent and redundant. If "meta_info" is missing, the direct access on line 146 will raise a KeyError.
To be consistent and safe, retrieve "meta_info" once using a safe accessor and reuse the local variable.
| sample.prefix_cache_info.add(choice.get("meta_info", {})) | |
| if "weight_version" in choice["meta_info"]: | |
| sample.weight_versions.append(choice["meta_info"]["weight_version"]) | |
| meta_info = choice.get("meta_info") or {} | |
| sample.prefix_cache_info.add(meta_info) | |
| if "weight_version" in meta_info: | |
| sample.weight_versions.append(meta_info["weight_version"]) |
References
- Avoid using safe accessors (e.g.,
.get()) for a dictionary key if a prior direct access in the same code path already guarantees its existence. This practice is redundant and inconsistent.
| # scalar/list computed fields ride in the JSON meta directly. Token ids and | ||
| # logprobs are re-materialized as Python lists on decode, exactly like the | ||
| # legacy JSON path (int64/f64 round-trips are lossless for both). | ||
| _SEGMENT_DTYPES = {"tokens": np.int64, "rollout_log_probs": np.float64} |
There was a problem hiding this comment.
The _SEGMENT_DTYPES dictionary does not specify dtypes for rollout_routed_experts and rollout_indexer_topk. If these fields are empty lists [] or have other non-array types, np.asarray(value, dtype=None) will infer their dtypes (e.g., float64 for empty lists), which can cause type mismatches downstream. Specifying their dtypes explicitly as np.int32 ensures consistent serialization and deserialization.
| _SEGMENT_DTYPES = {"tokens": np.int64, "rollout_log_probs": np.float64} | |
| _SEGMENT_DTYPES = { | |
| "tokens": np.int64, | |
| "rollout_log_probs": np.float64, | |
| "rollout_routed_experts": np.int32, | |
| "rollout_indexer_topk": np.int32, | |
| } |
| def _read_segment(body: bytes, segment_meta: dict | None) -> np.ndarray | None: | ||
| if segment_meta is None: | ||
| return None | ||
| start = segment_meta["offset"] | ||
| arr = np.frombuffer(body[start : start + segment_meta["nbytes"]], dtype=segment_meta["dtype"]) | ||
| return arr.reshape(segment_meta["shape"]) |
There was a problem hiding this comment.
Slicing the body bytes object (body[start : start + nbytes]) creates a copy of the sliced bytes in memory. For large R3 arrays (which can be hundreds of megabytes in production), this slicing and copying can be a performance bottleneck and increase memory usage.
Using memoryview allows zero-copy slicing of the buffer, which is much more efficient.
| def _read_segment(body: bytes, segment_meta: dict | None) -> np.ndarray | None: | |
| if segment_meta is None: | |
| return None | |
| start = segment_meta["offset"] | |
| arr = np.frombuffer(body[start : start + segment_meta["nbytes"]], dtype=segment_meta["dtype"]) | |
| return arr.reshape(segment_meta["shape"]) | |
| def _read_segment(body: bytes, segment_meta: dict | None) -> np.ndarray | None: | |
| if segment_meta is None: | |
| return None | |
| start = segment_meta["offset"] | |
| arr = np.frombuffer( | |
| memoryview(body)[start : start + segment_meta["nbytes"]], | |
| dtype=segment_meta["dtype"], | |
| ) | |
| return arr.reshape(segment_meta["shape"]) |
|
Bench repro on this exact base (rebased onto #1602 / da8870e) — same machine, same full production shape (32×50, R3 ≡ 25 layers × topk 10 int32, 16 workers). Core conclusions reproduce; the PR-body table stands.
* single outlier in n=19 overlap samples (p95=p99=max — one tail sample); still 4.7× better than the GET path's owning p99; likely machine load noise (other work ran concurrently on the box), flagged for a re-run rather than papered over. Honest residual, same as the PR body: /health false-503s inside collect windows are reduced ~2× in ratio (55%→27%) but not zero — a single ~12 s synchronous assembly exceeds Artifacts: 🤖 Generated with Claude Code |
da8870e to
2918190
Compare
795b929 to
5e792de
Compare
2918190 to
47426fd
Compare
5e792de to
8de998c
Compare
47426fd to
cb5c24b
Compare
8de998c to
1faecaf
Compare
compute_samples_from_openai_records, its per-record helper, and truncate_samples_by_total_tokens move from the client-side generate_utils module to miles/rollout/session/samples.py, unchanged: the follow-up commit makes the session server assemble samples where the records live, and server-owned logic must not live in a driver-named module (core.py importing generate_utils.sample_utils.merge_samples set the dependency-direction precedent). Their tests move alongside to tests/fast/rollout/session/test_samples.py. Also deletes tests/e2e/sglang/utils/logprob_verify_generate.py: already unreferenced, and built on the records-collection path the follow-up commit retires. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ds never leave it
POST /sessions/{session_id}/samples (registered before the catch-all proxy, which would otherwise forward it to the inference backend): the owning instance runs compute -> truncate -> merge synchronously on its event loop (the lock-free get_session invariant) and replies with one binary envelope — JSON meta plus raw segments for tokens/logprobs/R3 arrays. Only the ten COMPUTED_FIELDS cross the wire on blank templates; the driver overlays them onto deepcopies of its input sample, and an import-time guard forces every new Sample field to be classified computed-or-template. Deterministic assembly failures return 422 with the assertion text; empty replies carry a no_records/all_truncated discriminator preserving today's ABORTED semantics.
The old records path (GET the full dump, GiB-scale under R3 with per-turn full-prefix arrays, parsed on the driver's single interpreter) is retired from the training path. collect_samples posts once via the new http_utils.post_bytes_no_retry (post() force-decodes json/text and blind-retries; a 5xx here means the owning instance died with the records, a 422 is deterministic), raises on non-2xx with the body text, raises on timeout instead of silently ABORTing the sample (a measured data-loss bug in the records path), and attempts the session DELETE on every path so failed sessions cannot accumulate. agentic_tool_call.generate shrinks to a shell: agent call -> collect_samples -> empty_reason mapping -> metadata application in today's order.
Tests assert golden Sample field values derived from the records fixture (per-turn, merged, truncated), the wire-codec round-trip and drift guard, the 422/404/empty_reason/route-order contracts, and the client's four behavior deltas.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1faecaf to
cb11a4c
Compare
…fetensors payload The samples reply moves off the custom u64-length envelope + offset-table binary segments onto one safetensors buffer: one named tensor per computed array field, with the scalar JSON riding as the rank-one uint8 tensor _samples_meta (safetensors.numpy.load exposes no header metadata and __metadata__ is reserved by the format). A standard container replaces hand-rolled framing, and malformed payloads fail inside safetensors' validated parser instead of ad-hoc length checks. The codec (encode_samples_reply / decode_samples_reply / SamplesReply and the COMPUTED/TEMPLATE field split) moves out of samples.py into its own module samples_codec.py, so the wire contract depends only on Sample, NumPy, and safetensors — free of HTTP, session state, and assembly. Decoded arrays are writable copies rather than the removed envelope's read-only np.frombuffer views; values and Python/NumPy types stay contractual, array flags are not. safetensors>=0.8.0 becomes an explicit dependency (the malformed-payload exception contract is validated on 0.8.0). Codec tests grow malformed-payload and round-trip coverage against the new format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ckage samples.py -> samples/merge.py and samples_codec.py -> samples/codec.py, so the assembly and its wire codec sit as siblings under one package instead of two prefixed top-level modules; import sites updated mechanically. Also trim the codec module docstring to the black-box contract (what goes in, what comes out of encode/decode); the wire-format details stay in the inline comments next to the code they constrain.
…ple semantics
The flag made every turn a separate full-context sample (skipping the TITO
merge), which is wrong: sample boundaries are a property of the trajectory,
not a CLI switch, and per-turn siblings share one group_index so they pollute
group baselines. The session server now always folds linear turns into one
merged sample (a new sample only starts at a genuine context discontinuity,
i.e. future compaction/subagent support), the samples-op body shrinks to
{"max_seq_len"}, and agentic_tool_call.generate always returns list[Sample] —
including the aborted path, so a batch never mixes scalar and list shapes.
Consequences: multi_turn.generate always TITO-accumulates one scalar sample;
custom RMs on the agentic path are now called in batch form with
list[Sample]; check_reward_nonzero_std flattens nested groups; agentic +
--group-rm/--partial-rollout/--recompute-logprobs-via-prefill is documented
as unsupported.
The module is sample packing behind an opaque bytes payload; lead the docstring with the input/output contract and demote the wire format to an implementation detail.
…ample]] A dynamic-filter group mixes scalar and list elements (agentic generate returns list[Sample] per call), which is exactly why _flatten_samples exists; the old list[Sample] annotation misdocumented that contract.
Summary
Assemble training samples on the session server and drop
--generate-multi-samples.Motivation
Each record's R3 buffer covers the full prefix, so a session's records dump grows quadratically with turns — measured ~3.15 GiB per full
GET /sessions/{id}at production shape (an mp-stack figure; the magnitude carries to this stack) — then the rollout driver parsed every byte of it on one interpreter per session. The only consumer of records is Sample assembly, whose inputs all already live on the owning instance. Relocating the assembly also retires--generate-multi-samples: sample boundaries are a property of the trajectory (linear turns merge; only a genuine context discontinuity — future compaction/subagent support — starts a new sample), not a CLI switch, and per-turn siblings shared onegroup_index, polluting group baselines.Before / After
POST /sessions/{session_id}/samplesruns compute → truncate → merge on the owning instance, synchronously on its event loop (the lock-freeget_sessioninvariant)._samples_meta.compute_samples_from_openai_records/truncate_samples_by_total_tokensmoved as-is tomiles/rollout/session/samples/merge.py(review with--find-copies); the wire codec issamples/codec.py;SessionCore.collect_samples, the route,http_utils.post_bytes_no_retry, plus the client cutover follow.agentic_tool_call.generateshrinks to a shell: agent call →collect_samples→empty_reasonmapping → today's metadata application order. It now always returnslist[Sample](aborted path included), so a custom RM on this path is always called batched with alist[Sample];multi_turn.generatealways TITO-accumulates one scalar sample.Behavior Preservation
Samplefield values (merged, truncated) derivable from a fixed two-turn records fixture.COMPUTED_FIELDScross the wire on blank templates; the driver overlays them onto deepcopies of its input sample; an import-time guard forces every newSamplefield to be classified computed-or-template.check_reward_nonzero_stdnow flattens nested groups; agentic +--group-rm/--partial-rollout/--recompute-logprobs-via-prefillis documented as unsupported.Verification
tests/fast/router/test_session_samples_op.py— golden field values; 422 / 404;empty_reasondiscriminators; route registered before the catch-all proxy.tests/fast/rollout/session/— assembly goldens plus codec round-trip / drift-guard tests; client deltas intest_openai_endpoint_utils.py.Review Focus
collect_samplesincore.py: assembly must stay synchronous on the loop — offloading to an executor without snapshotting records breaks the lock-free read.COMPUTED_FIELDS/TEMPLATE_FIELDSsplit insamples/codec.py: a misclassified field silently keeps the driver's stale value at training time.agentic_tool_call.generate: downstreamisinstance(sample, list)forks now always take the list branch on this path.