Skip to content

[tinker] Encode forwarded sample results to proto once and serve them as-is - #2164

Open
avigyabb wants to merge 2 commits into
avi/stack-4-server-knobsfrom
avi/stack-5-proto-once
Open

[tinker] Encode forwarded sample results to proto once and serve them as-is#2164
avigyabb wants to merge 2 commits into
avi/stack-4-server-knobsfrom
avi/stack-5-proto-once

Conversation

@avigyabb

@avigyabb avigyabb commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Stack 5/7. Long-output rollouts made per-result payload work the API server's main cost.

Before. For a 32k-token result (356 KB JSON) the forwarding path spent 4 ms (orjson decode, pydantic validate, pydantic JSON dump) and the proto path the SDK >= 0.25 uses on retrieve_future spent another 7 ms (stdlib json.loads + proto build) inside a single global lock, capping proto delivery near 150 results/s regardless of concurrency. The proto build holds the GIL, so the thread hop bought nothing.

Change.

  • The forwarding client decodes the vLLM body once and encodes straight to SampleResponse wire bytes (serialize_sample_output, shared with the validated path and pinned to it byte-for-byte by tests). No pydantic model or JSON text is built for the result.
  • ExternalFutureStore keeps the proto bytes (8 bytes/token, 26% smaller than the JSON text); retrieve_future passes them through for proto clients and derives JSON lazily, cached, for pre-0.25 clients (sample_output_json_from_proto, float32 logprobs). Results stored as JSON (DB path, errors) keep the encode-in-thread path, now cached per entry.
  • Pending entries no longer retain the request body (never read back on this path; ~100 KB per entry for long prompts).
  • Store TTLs are EngineConfig fields (external_future_retrieved_ttl_sec, external_future_completed_ttl_sec): retention after delivery is the dominant memory term, roughly completion rate x result size x window.

Results (proto client, 2048 cap): 512 concurrent 32k-token results 7.5 s -> 3.5 s server CPU, 73 -> 167 results/s. 131072 requests with 8k-token results, 5 s engine queueing and SDK-style 45 s re-polls: 131072/131072, 0 failures, 267/s, 8.9 GB peak RSS. 32768 x 32k tokens: 32768/32768, 163/s, 9.6 GB.

Stack (each PR retargets to main as the one below merges)

  1. [tinker] Route uvicorn's access log to a plain handler instead of Rich #2160
  2. [tinker] Forward samples with aiohttp instead of httpx #2161
  3. [tinker] Keep an undelivered sample result alive for the SDK's retry #2162
  4. [tinker] Survive completion bursts at the socket layer (accept backlog, keep-alive) #2163
  5. [tinker] Encode forwarded sample results to proto once and serve them as-is #2164
  6. [tinker] Decode vLLM completion bodies straight into numpy with pysimdjson #2165
  7. [tinker] Load harness for the API server's sampling path at 131k concurrency #2166

🤖 Generated with Claude Code


Note

Medium Risk
Changes the external-sample retrieval wire path and in-memory retention (TTL-configurable); behavior is heavily tested but affects high-throughput rollout delivery and SDK retry compatibility.

Overview
Forwarded sample results now go from the vLLM JSON body straight into SampleResponse protobuf via serialize_sample_output, skipping Pydantic SampleOutput and intermediate JSON on the hot path. ExternalFutureStore holds proto bytes (PreparedResult), drops unused pending request_data, and exposes proto_result / json_result / cache_proto so retrieve_future can return proto as-is, lazily derive JSON for pre-0.25 SDKs, and cache one-time proto encoding for JSON-backed entries.

Configuration: EngineConfig adds external_future_retrieved_ttl_sec and external_future_completed_ttl_sec, wired into ExternalFutureStore at startup.

Proto helpers: serialize_sample_output is shared with the validated JSON path; sample_output_json_from_proto inverts it for legacy clients. Tests in test_sample_result_fast_path.py pin byte-for-byte equivalence and retrieval behavior.

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

avigyabb and others added 2 commits September 4, 2026 01:24
… as-is

Long-output rollouts made the per-result payload work the API server's main
cost. For a 32k-token result (356KB JSON) the forwarding path spent 4ms
(orjson decode, pydantic validate, pydantic JSON dump) and the proto path the
SDK >= 0.25 uses on retrieve_future spent another 7ms (stdlib json.loads plus
proto build) inside a single global lock, capping proto delivery near 150
results/s regardless of concurrency; the proto build holds the GIL, so the
thread hop bought nothing.

- The forwarding client decodes the vLLM body once and encodes straight to
  SampleResponse wire bytes (serialize_sample_output, shared with the validated
  path and pinned to it byte for byte by tests). No pydantic model or JSON text
  is built for the result.
- ExternalFutureStore keeps the proto bytes (8 bytes/token, 26% smaller than
  the JSON text); retrieve_future passes them through for proto clients and
  derives JSON lazily, cached, for pre-proto clients
  (sample_output_json_from_proto). Results stored as JSON (DB path, errors)
  keep the existing encode-in-thread path, now cached per entry.
- Pending entries no longer retain the request body (never read back on this
  path; ~100KB per entry for long prompts).
- Store TTLs are EngineConfig fields (external_future_retrieved_ttl_sec,
  external_future_completed_ttl_sec): retention after delivery is the
  dominant memory term, roughly completion rate x result size x window.

512 concurrent 32k-token results, proto client: server CPU 7.5s -> 3.5s,
73 -> 167 results/s. 131072 requests with 8k-token results, 2048-way engine
queueing and SDK-style 45s re-polls: 131072/131072, 0 failures, 267/s, peak
RSS 8.9GB. 32768 requests with 32k-token results: 32768/32768, 163/s, 9.6GB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
(cherry picked from commit ac48818)
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Removed comments explaining retention and population of result data in ExternalFuture class.

@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 a performance optimization for forwarded sample results by encoding them directly to Protobuf wire format (via PreparedResult) instead of serializing to Pydantic models or JSON text. Pre-proto clients requesting JSON will have the JSON lazily derived from the Protobuf representation and cached. Additionally, configurable TTL settings are introduced for retrieved and completed futures. The review feedback highlights a potential TypeError in skyrl_train_inference_forwarding.py if result.proto is None when attempting to decode it, suggesting a defensive check to prevent this issue.

Comment on lines +149 to +150
if isinstance(result, PreparedResult):
future.result_data = result.json or sample_output_json_from_proto(result.proto)

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

If result is a PreparedResult but both result.json and result.proto are None (or if result.proto is None), calling sample_output_json_from_proto(result.proto) will raise a TypeError because it expects bytes. We should defensively guard against result.proto being None before attempting to decode it.

Suggested change
if isinstance(result, PreparedResult):
future.result_data = result.json or sample_output_json_from_proto(result.proto)
if isinstance(result, PreparedResult):
future.result_data = result.json or (sample_output_json_from_proto(result.proto) if result.proto else None)

@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR appears safe to merge, with a non-blocking concurrency issue that can redundantly serialize the same large result during overlapping retrievals.

The new fast path correctly routes stored protobuf and legacy JSON responses, but its cache check occurs outside the serialization lock, allowing concurrent waiters that miss together to repeat expensive serialization sequentially.

Files Needing Attention: skyrl/tinker/api.py

Important Files Changed

Filename Overview
skyrl/tinker/api.py Wires configurable store TTLs into startup and adds protobuf pass-through and caching; the pre-lock cache lookup permits duplicate serialization by overlapping waiters.
skyrl/tinker/config.py Adds validated positive TTL configuration for delivered and undelivered external future results.
skyrl/tinker/external_future_store.py Introduces prepared protobuf results, lazy JSON conversion, per-entry representation caching, and removes unused request-body retention.
skyrl/tinker/extra/skyrl_train_inference_forwarding.py Converts decoded vLLM sample output directly into protobuf bytes while retaining JSON persistence for the database path.
skyrl/tinker/proto_serialization.py Extracts direct SampleResponse serialization and adds an inverse JSON conversion for legacy clients.
tests/tinker/test_external_future_store.py Updates the test store interface for protobuf cache access.
tests/tinker/test_sample_result_fast_path.py Covers byte equivalence, conversion, pass-through, sequential caching, and TTL behavior, but not concurrent cache misses.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[vLLM sample response] --> B[Decode response body]
  B --> C[Serialize SampleResponse once]
  C --> D[ExternalFutureStore]
  D -->|Proto client| E[Return stored protobuf bytes]
  D -->|Legacy JSON client| F[Convert protobuf to JSON]
  F --> G[Cache and return JSON]
  H[JSON-backed result] --> D
  D -->|First proto retrieval| I[Serialize under global lock]
  I --> J[Cache and return protobuf]
Loading

Reviews (1): Last reviewed commit: "Clean up comments in ExternalFuture clas..." | Re-trigger Greptile

Comment thread skyrl/tinker/api.py
Comment on lines +1521 to +1523
content = external_future_store.proto_result(request_id) if found_in_memory else None
if content is None:
async with req.app.state.proto_serialization_lock:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Cache check precedes lock

If retrievals for the same JSON-backed result overlap, each waiter can observe the empty proto cache before acquiring proto_serialization_lock, causing the same large result to be serialized sequentially multiple times and adding avoidable CPU and retrieval latency.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

avigyabb added a commit that referenced this pull request Sep 5, 2026
#2160)

Stack 1/7. Independent of the rest.

RichHandler renders each access-log record through a rich Table, about
1.5 ms of event-loop CPU per HTTP request. Under rollout load that was
63% of the Tinker API server's CPU: profiling a 4096-sample run showed
12.5 s of its 19.9 s of server CPU inside `rich.logging.emit`. With a
plain `StreamHandler` for `uvicorn.access` the same run takes 9.7 s and
server throughput goes from 337 to 568 samples/s. Startup and error
logging keep the Rich handler.

Measured with the load harness in stack 7/7
(`skyrl/benchmarks/load_test_tinker_sampling.py`).


**Stack** (each PR retargets to `main` as the one below merges)
1. #2160
2. #2161
3. #2162
4. #2163
5. #2164
6. #2165
7. #2166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Logging configuration only; no request handling, auth, or data path
changes—access output format may look slightly plainer on stderr.
> 
> **Overview**
> **HTTP access logging** for the Tinker API (via
`get_uvicorn_log_config`) no longer goes through `RichHandler`. A
dedicated **`access`** `StreamHandler` on stderr uses the same text
formatter, while **`uvicorn`** and **`uvicorn.error`** still use Rich
for startup, errors, and tracebacks.
> 
> This targets per-request access log volume: Rich’s table rendering was
a major event-loop CPU cost under high QPS. Access lines should read the
same format string but without Rich styling.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
254499a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
avigyabb added a commit that referenced this pull request Sep 5, 2026
…g, keep-alive) (#2163)

Stack 4/7.

`uvicorn.run`: `backlog=SKYRL_HTTP_CONNECTION_LIMIT` (50k, as on Chuck's
branch; the effective value is capped by `net.core.somaxconn`, raise it
to match) and `timeout_keep_alive=75`.

With 131072 outstanding samples and 212k-token results, each 2048-result
completion burst kept the event loop busy ~16 s; uvicorn's 5 s
keep-alive then closed every idle client connection, all clients
reconnected at once and the 2048-entry accept backlog overflowed,
refusing 109k of 131072 requests. With these settings the same run
completed 130917 of 131072 with zero forwarding errors and zero
reconnects (earlier runs on the 5 s keep-alive showed hundreds to
thousands).

Neither setting is needed for correctness: the SDK retries refused or
dropped connections. They avoid the reconnect storm rather than fix a
failure, and with the SDK's per-client in-flight cap the burst that
overflowed the backlog does not occur. An earlier revision of this PR
also exposed `sample_max_concurrent_requests` from `EngineConfig` via
`/client/config`; that was dropped as unnecessary (its default equalled
the SDK default, and no measured run depended on it).


**Stack** (each PR retargets to `main` as the one below merges)
1. #2160
2. #2161
3. #2162
4. #2163
5. #2164
6. #2165
7. #2166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Server-only uvicorn listen/keep-alive defaults; no API or auth
behavior change, with SDK retries as a fallback.
> 
> **Overview**
> The Tinker API server now passes **uvicorn** socket tuning so large
completion bursts do not trigger mass client reconnects and accept-queue
overflows.
> 
> **`timeout_keep_alive`** is set to **75s** (via
`HTTP_KEEP_ALIVE_TIMEOUT_SECONDS`) instead of uvicorn’s **5s** default,
so idle SDK connections stay open while the event loop is busy for many
seconds during bursts.
> 
> **`backlog`** is set to **`SKYRL_HTTP_CONNECTION_LIMIT`** (default
**50k**, overridable by env), so pending connections queue in the kernel
instead of being refused when the loop cannot accept fast enough
(effective cap is **`net.core.somaxconn`**).
> 
> These are **reliability/performance** knobs, not correctness fixes—the
SDK already retries refused or dropped connections.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
27c838e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
avigyabb added a commit that referenced this pull request Sep 5, 2026
…2162)

Stack 3/7. Fixes the 128x128 `404 Future not found` seen against
j316chuck#18.

**Chain.** The SDK polls `retrieve_future` with a 45 s client timeout
and gives up; the result lands afterwards; the abandoned handler wakes,
builds a response nobody receives (uvicorn drops the send to a dead
client silently) and starts the short retrieved-TTL clock; the sweeper
evicts the result 120 s later; the SDK's retry of the same request_id
gets 404, which the SDK treats as fatal.

**Fix.** Start the retrieved clock only if `request.is_disconnected()`
is false, and raise the retrieved TTL to 300 s so it outlasts the SDK's
worst-case re-poll gap (45 s timeout + up to 30 s backoff, twice).
`tests/tinker/test_retrieve_future_lost_response.py` reproduces the
chain under a real uvicorn socket with shortened TTLs; it fails on
`main` and passes here. A second test checks a delivered result still
expires on the short clock, so memory stays bounded.

Alternative considered: j316chuck#19 drops the retrieved clock and
keeps every result for 2048 s. That also fixes the 404 but retains ~35
minutes of results regardless of delivery; with long-output rollouts
(hundreds of KB per result) that is tens of GB. Verified at scale:
131072 requests with 5 s engine queueing and 224k SDK-style abandoned
polls completed with zero 404s.


**Stack** (each PR retargets to `main` as the one below merges)
1. #2160
2. #2161
3. #2162
4. #2163
5. #2164
6. #2165
7. #2166

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes async polling/delivery semantics and HTTP server tuning on the
hot `retrieve_future` path; behavior is covered by new integration tests
but affects SDK retry reliability under load.
> 
> **Overview**
> Fixes fatal **`404 Future not found`** when the SDK abandons a long
`retrieve_future` poll (45s client timeout) and retries the same
`request_id` after the result is ready.
> 
> **`retrieve_future`** now calls **`mark_retrieved`** (starting the
post-delivery eviction clock) only when the client is still connected
(`not await req.is_disconnected()`). If the handler finishes building a
response after the client disconnected, the short retrieved TTL no
longer starts, so the in-memory store keeps the result for a real retry.
> 
> **`ExternalFutureStore`** raises **`_RETRIEVED_TTL_SECONDS`** from
120s to 300s so delivered results still get a grace window that covers
worst-case SDK re-poll gaps (timeout + backoff, twice).
> 
> **Uvicorn** startup sets **`timeout_keep_alive=75`** (vs 5s default)
and **`backlog=SKYRL_HTTP_CONNECTION_LIMIT`** to reduce idle disconnects
and accept-queue overflows during completion bursts.
> 
> Adds **`test_retrieve_future_lost_response.py`** (real socket on
Linux) plus test stubs for **`is_disconnected`** on existing API tests.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
36188da. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

1 participant