Repository navigation
perf(api-server): stop keeping finished sequences, keep prompt rendering and tokenizing off the event loop - #2912
Conversation
…tes off the event loop Three costs in the OpenAI server that grow with the length of agentic conversations, found on DSV4.1-Flash DEP4 c512 (MI455X) where throughput fell from ~12k to ~5k tok/s/GPU over a 1 h run: - CoreManager's output thread put every finished Sequence list on outputs_queue, which only offline LLMEngine.step()/generate() read. Online, each finished request (token arrays, block tables) stayed reachable for the life of the process: API RSS 3 -> 26 GB in 1 h, gen-2 GC pauses 0.13 -> 0.92 s. The server now clears CoreManager.consumes_outputs_queue. - apply_chat_template ran on the event loop for /v1/chat/completions, /v1/messages and /v1/responses. It now runs on the default executor (context variables carried), so the loop keeps reading requests. - DeepSeek's encoding.py calls find_last_user_index once per rendered message, each a backward scan: O(n^2) per render. The loader memoizes it per (messages list, length), per thread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
valar-cop
left a comment
There was a problem hiding this comment.
some changes needed..
measure the encoder change on the real deepseek encoding.py?
ci green.
| engine = engine_args.create_engine(tokenizer=tokenizer) | ||
| # Every output reaches its request through the per-sequence callbacks; | ||
| # nothing here reads the offline outputs queue. | ||
| engine.core_mgr.consumes_outputs_queue = False |
There was a problem hiding this comment.
atomesh front end has the same leak, nothing there calls get_output.. please clear it there too, or default False in CoreManager?
There was a problem hiding this comment.
Right, atomesh's standalone service also delivers only through the callbacks. It now clears the flag too (bd367f7). Kept the class default True, since offline generate/step and the asyncio path do read the queue.
| local = threading.local() | ||
|
|
||
| @functools.wraps(orig) | ||
| def find_last_user_index(messages): |
There was a problem hiding this comment.
signature mismatch will 500 every request silently, use *args/**kwargs and forward?
There was a problem hiding this comment.
Dropped the memo entirely (bd367f7). On the checkpoints' own encoding.py it measured 0.97-1.03x, so there is nothing left to forward. Numbers are in the top-level reply.
| if cached is not None and cached[0] is messages and cached[1] == len(messages): | ||
| return cached[2] | ||
| result = orig(messages) | ||
| local.cached = (messages, len(messages), result) |
There was a problem hiding this comment.
this pins a full history per thread.. weakref instead?
| exc_info=True, | ||
| ) | ||
| self.outputs_queue.put_nowait(seqs) | ||
| if self.consumes_outputs_queue: |
There was a problem hiding this comment.
no test hits this line, delete it and suite is still green.. assert the behaviour instead?
There was a problem hiding this comment.
Agreed. Added test_finished_sequences_reach_the_outputs_queue_only_if_consumed in tests/test_engine_core_mgr_shared_state.py. It drives the real output thread with an ADD frame and checks the queue size with the flag on and off, so removing the check fails it. The source-grep test now only checks that both front ends clear the flag.
…ur, drop the encoder memo - atomesh's standalone front end also delivers through callbacks only, so it clears consumes_outputs_queue like the OpenAI server. - The output-thread test drives a real ADD frame and checks the queue with the flag on and off, instead of grepping the source. - Measured on the checkpoints' own encoding.py (V4-Flash, V4.1-Flash): the find_last_user_index memo is 0.97-1.03x up to 302 messages, because merge_tool_messages folds tool results into user turns and the backward scan stays short. Dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed bd367f7 for the review:
The PR is now just two parts: the leak fix and rendering the template off the event loop. The leak fix overlaps with #2680 and #2786; once this lands, those can drop their copy on rebase. @valar-cop re-review please. @valarLip PTAL when you have time. |
|
ci triage for 25cb433:
|
|
@valar-cop Can we merge? |
valarLip
left a comment
There was a problem hiding this comment.
Re-read at bd367f7. Thanks for dropping the memo and for the behavioural queue test. The direction of both fixes is right. I found nothing that returns wrong output. I ran the new tests (3 passed) and timed the tokenizer separately: DeepSeek-V4.1-Flash's encode takes about 117 ms for 100K tokens. What follows is about the depth of the event-loop fix and about how the leak fix is wired.
Must change
1. The larger cost is still on the event loop for /v1/messages and /v1/responses (api_server.py:2277, 2303, 2704, 2723). Right after the render moves to a thread, both endpoints call tokenizer.encode(prompt) on the full prompt, on the loop. When the prompt is over the limit, they encode it again to truncate. preprocess in the executor then encodes it a third time. Your render numbers are 1.6–2.6 ms at 302 messages; encoding an 80–100K-token agentic turn takes about 100 ms. So for these two endpoints, the symptom the description reports as fixed (other connections' sockets go unread at c512) is still there. Please do it the way chat_completions does: the length check and truncation happen inside the one preprocess call in the executor, using seq.num_prompt_tokens, with no encode on the loop. Alternatively, narrow the description's claim.
2. Decide who consumes the outputs at construction, not by assigning a flag from outside (engine_core_mgr.py:198). consumes_outputs_queue is a mutable class default. Each front end flips it after create_engine with engine.core_mgr.consumes_outputs_queue = False (api_server.py:3222, atomesh/server.py:107). That reaches past LLMEngine into CoreManager, and it is a second "who reads the outputs" switch next to the existing config.asyncio_mode. The next online front end (the multi-process front end in the description, for instance) that misses this line brings back the 3 GB → 26 GB/hour leak without any error. Keeping the default True for offline generate / step and the asyncio path is fine. Please make it a construction-time decision: an EngineArgs / Config field at the same level as asyncio_mode, or derived from whether a sequence has a callback. #2786's keep_finished_outputs has the same shape, and the same fix applies. With that change:
- the field moves into
_init_shared_state, next toself.outputs_queue. That is the place its docstring names. Right now it sits inside the class-level backpressure defaults (:194), which splits that group; - the source-text test goes away (
tests/entrypoints/test_api_server_helpers.py:918-920,inspect.getsource(fn)searched for"consumes_outputs_queue = False"). It passes on dead text and fails on a harmless refactor.
3. _render_chat_template re-implements asyncio.to_thread (api_server.py:1746). The standard library's version is exactly run_in_executor(None, partial(copy_context().run, func, ...)), and this file already uses asyncio.to_thread at 1596 / 1630. Please call await asyncio.to_thread(apply_chat_template, tokenizer, custom_message_encoder, messages, tools=..., **kwargs) at the three call sites. That drops the wrapper, the contextvars / functools imports, and the contextvar part of test_chat_template_renders_off_the_event_loop.
The docstring's "Context variables (request id, tracing) follow the call" is not accurate either:
/v1/messages(2275) and/v1/responses(2703) set the request id only after rendering;- neither
apply_chat_templatenor the custom encoders read anyContextVar.
Worth addressing
- Two serial hops into the default pool per request (
api_server.py:1853). There is the render, thensetup_*_request'srun_in_executor(None, do_preprocess)(843 / 966 / 1289 / 1509). Both share the default pool with multimodal media fetches, which can block inurlopenfor up to 30 s. Rendering is pure Python under the GIL, so the thread only yields the loop; it does not save CPU. Doing render and tokenize as one "prepare the prompt" step inside the existingpreprocesscall would cost one hop instead of two, and it is also where item 1 puts the encode. - The backpressure send-failure path, pre-existing (
engine_core_mgr.py:1290). It still doesoutputs.put(RuntimeError(...))unconditionally. Online, that is now a queue declared unread, so_bp_pendingrequests get no terminal output and their clients hang. This PR is what makes "online does not read this queue" explicit, so the error should reach those requests through their callbacks.
…utputs at construction, asyncio.to_thread - /v1/messages and /v1/responses render with asyncio.to_thread and pass max_prompt_tokens down; the length check and truncation run in the preprocess call on the executor (_preprocess_prompt), from the engine's own num_prompt_tokens. Input/output token counts come from the engine, so neither endpoint encodes on the event loop any more. - Config.keep_finished_outputs (next to asyncio_mode), set at construction through EngineArgs.create_engine(keep_finished_outputs=False) by both online front ends; CoreManager reads it in _init_shared_state. The class-level flag and the source-text test are gone. - _render_chat_template is gone: asyncio.to_thread at the call sites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@valarLip thanks for the careful read. Pushed 3036898 (on top of a merge of current main). Item by item: Must change
Worth addressing
Unit tests (CPU): @valar-cop re-review please. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| token_ids = tokenizer.encode(prompt)[:max_input] | ||
| prompt = tokenizer.decode(token_ids, skip_special_tokens=False) | ||
| input_tokens = max_input | ||
| max_input = _max_prompt_tokens(request.max_tokens, kv_transfer_params) |
There was a problem hiding this comment.
prompt is untruncated here, so the filter is seeded from text the engine never runs.. seed from the sent prefix?
There was a problem hiding this comment.
Good catch, fixed in b7387b9. The cap now travels as _PromptCap. When the preprocess call cuts the prompt, it decodes the kept ids on the executor into cap.cut_text, and both endpoints seed the reasoning channel from that (stream and non-stream). That matches what the old decode-then-send did, without the loop work. The unit test checks cut_text.
| # front ends deliver every output through per-sequence callbacks and build | ||
| # the engine with this off; left on there, every finished Sequence stays | ||
| # on a queue nothing reads, for the life of the process. | ||
| keep_finished_outputs: bool = True |
There was a problem hiding this comment.
pr description still describes the old design, please update it to match the diff..
There was a problem hiding this comment.
Updated after this review: Summary/Changes now describe Config.keep_finished_outputs via create_engine, asyncio.to_thread, and the check/truncation in the preprocess call. Validation lists the current unit test run.
| unittest.main() | ||
|
|
||
|
|
||
| def test_online_front_ends_build_the_engine_without_kept_outputs(monkeypatch): |
There was a problem hiding this comment.
this never touches a front end, nothing pins the flag. drive the stubs and assert kwargs, or rename it?
There was a problem hiding this comment.
Fair. Renamed it to test_create_engine_hands_keep_finished_outputs_to_the_config, which is what it checks. Added test_atomesh_front_end_builds_the_engine_without_kept_outputs, which drives atomesh's initialize_engine with stubbed EngineArgs / tokenizer and asserts it passes keep_finished_outputs=False. The OpenAI server's main() is too big to drive in a unit test; its call is the same one-line keyword.
… the front end - _PromptCap carries the cap into the preprocess call and, when the prompt is cut, the decoded text of what the engine runs (decoded on the executor); /v1/messages and /v1/responses seed the reasoning channel from it. - Tests: create_engine's test renamed to what it checks; a new test drives atomesh's initialize_engine and asserts keep_finished_outputs=False. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed b7387b9 for valar-cop's three follow-ups (replies inline): a cut prompt now seeds the reasoning channel from the text the engine runs, decoded on the executor; there's a test that drives a front end (atomesh) and asserts @valarLip @valar-cop re-review please. |
|
@valar-cop can we merge |
|
@yhl-amd not yet, the ruff job is still red on this head (log is empty, rerun or check the format locally): https://github.com/ROCm/ATOM/actions/runs/41581924513/job/125738494221 your three follow-ups look right, cut_text seeds the reasoning channel in both non-stream branches and the atomesh test drives the real front end. lgtm once ruff is green, and valarLip still owes a re-read after 3036898. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @valar-cop. The ruff failure was RUF023 ( |
|
@yhl-amd yeah, |
…sponses decides its channel before the engine call) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged current main (80aea67) to resolve the conflict with #2862:
|
| # Decided before the engine call, since structured output configures | ||
| # the sampling params from it; so for a prompt over the cap it reads | ||
| # the template's ending, not the cut text the engine runs. | ||
| _reasoning = reasoning_channel( |
There was a problem hiding this comment.
channel seeded from untruncated prompt here, /v1/messages uses cut_text.. recompute _reasoning after cut_text for schema None, and seed structured from the cut prompt
There was a problem hiding this comment.
Done in 991517c, both paths:
- No schema: the preprocess call cuts the prompt as before, and the channel is then re-seeded from
cut_text(_reseed_after_cut), stream and non-stream, the same as /v1/messages. - Structured: feat(api): constrain JSON output and expose deployment capabilities #2862 builds the channel into the sampling params before the engine call, so here the cut has to come first.
_cut_prompttokenizes off the loop (asyncio.to_thread) and, if the prompt is over the cap, returns the kept ids and their decoded text. The channel is seeded from that text, and the engine gets the ids, so preprocess doesn't cut again. This costs one extra tokenize for structured requests only, and none on the loop. - Unit test
test_responses_channel_follows_the_text_the_engine_runscovers both helpers. CPU tests: 2855 passed.
@valar-cop re-review please.
…ngine runs - With a schema, the channel goes into the sampling params before the engine call, so the prompt is cut first (_cut_prompt, off the loop) and the engine gets the kept ids. - Without one, the preprocess call cuts it and the channel is re-seeded from cut_text afterwards (_reseed_after_cut), as /v1/messages does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| token_ids = tokenizer.encode(prompt) | ||
| if len(token_ids) <= prompt_cap.tokens: | ||
| return prompt, prompt | ||
| token_ids = token_ids[: prompt_cap.tokens] |
There was a problem hiding this comment.
no log on truncation here? preprocess used to warn, now silent for structured..
There was a problem hiding this comment.
Right. _cut_prompt now logs the same "Prompt too long (n > cap), truncating" warning as the preprocess path (bf3f38a).
| assert list(requests) == [seq.id] | ||
|
|
||
|
|
||
| def test_responses_channel_follows_the_text_the_engine_runs(monkeypatch): |
There was a problem hiding this comment.
this doesn't drive responses_create, so deleting the call stays green.. can we add a structured case over the cap?
There was a problem hiding this comment.
Added TestStructuredResponsesCutFirst in test_pd_messages_responses.py (bf3f38a). It drives responses_create with json_object and an over-cap prompt, and asserts three things: the engine is handed the kept ids (a list, already at the cap), the structured config ran, and the channel was seeded from the cut text before the engine call. Removing the _cut_prompt call fails it, because the engine would then get the uncut string. CPU tests: 2856 passed.
@valar-cop re-review please.
…er-cap case Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| "prompt_starts_in_reasoning", | ||
| lambda text: seeds.append(text) or False, | ||
| ) | ||
| monkeypatch.setattr(api_server, "validate_output", lambda *a, **kw: None) |
There was a problem hiding this comment.
this patch is dead for the non-stream path, it calls validate_responses_output. patch that and assert status 200?
There was a problem hiding this comment.
Good catch. It now patches validate_responses_output (the one responses calls) and asserts 200 (25cb433). @valar-cop re-review please.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
valarLip
left a comment
There was a problem hiding this comment.
Re-read at 25cb433, by reading the code and running the affected tests (test_pd_messages_responses, test_api_server_helpers, test_engine_core_mgr_shared_state, test_api_compatibility, test_gc_utils: 281 passed); no GPU. Thanks for working through both rounds. The leak fix is right: nothing online reads outputs_queue (atomesh delivers by callback, asyncio_mode is never on). The render now runs off the loop via asyncio.to_thread. Non-streaming usage now uses the engine's counts, which makes it agree with streaming. That's a semantic change, not a bug.
What's left is mostly the over-length cut for /v1/messages and /v1/responses. Following my 11-03 item 1, valar-cop's seed-from-cut request and #2862's structured channel, it has grown into two mechanisms, so part of this is on me. Each item ends with the change I'm asking for.
A. One prompt preparation step instead of two cut mechanisms
1. Two cut paths, five helpers and a mutable out-parameter (api_server.py:1278-1347). With a schema, _cut_prompt cuts before the engine call. Without one, _preprocess_prompt cuts inside preprocess. To bridge them the PR adds _PromptCap (whose cut_text is filled in on the executor), _reseed_after_cut, and a prompt_cap parameter on both generate_async and setup_streaming_request. The write-back is spelled three ways (responses builds _reasoning then reseeds; anthropic does prompt = prompt_cap.cut_text or prompt), and the two paths must agree on every count. Three symptoms follow:
_cut_prompt(:1318) tokenizes with plaintokenizer.encode, bypassingio_processor.segment_tokenizer, the prefix cache built for agentic resends. When the prompt fits it returns the string, so the engine tokenizes it again. Every structured/v1/responsesrequest, not only over-long ones, pays a full encode twice; an 80k-token agentic turn pays it every turn._preprocess_prompt(:1336) builds a wholeSequencefirst: full tokenize,ship_prompt_hash_chain, a "Request arrived" log line, a seq id. Then it reaches intoengine.io_processor.requests.pop(seq.id)from the API layer and builds it again from the ids, with noprefix_encoding, so the hash chain is recomputed from scratch.- On
/v1/responses,_reasoningis built from the uncut prompt and replaced after the engine call (:2890). With a schema,_reseed_after_cutstill runs unconditionally (:2914streaming,:3034non-streaming), so if the two counts ever disagree, a structured response would loseliteral_answer. That's not reachable whilesegment_tokenizeris exact, but it comes from the duplication.
→ Change: make one "prepare the prompt" step on the executor that renders, tokenizes through the engine's own path (io_processor, segment cache included), cuts to the cap, and returns (token_ids, cut_text_or_None). Run it once before building the sampling params, so #2862's structured channel and the plain reasoning channel are both seeded from what the engine runs, and pass the ids to the engine so preprocess doesn't tokenize again. That also merges the two executor hops from my 11-03 note into one. Then delete _PromptCap, _cut_prompt, _reseed_after_cut, _preprocess_prompt's rebuild and the prompt_cap parameters.
2. The cap can be ≤ 0 (api_server.py:1306-1311). _prompt_headroom reserves at least min(max_tokens, 1024). With --max-model-len 512 and max_tokens=1024, tokens is ≤ 0, so every prompt counts as over-long and is cut to empty or has its tail dropped ([: negative]), instead of being refused. If cut_text is "", anthropic's cut_text or prompt falls back to the full text. The old code had this too, but these are the lines this PR rewrites.
→ Change: in the item 1 step, refuse with a 400 when the cap is < 1, and treat an empty cut as "refuse", not "use the full prompt".
B. The leak switch
3. keep_finished_outputs defaults to True, so every online front end has to remember False (engine_core_mgr.py:237). This is the shape I asked for on 11-03 (a construction-time field next to asyncio_mode, default kept for offline). Seeing it wired, the default is on the wrong side. This PR itself first missed atomesh. The multi-process front end in #2913, and any later one, has to pass it too, or the 3 GB → 26 GB/hour leak returns silently. test_gc_utils already greps source for the same kind of "every front end must remember" rule (freeze_gc_heap).
→ Change: default it to False, and have the readers of outputs_queue turn it on: LLMEngine.generate / step, and asyncio_mode. Then the online front ends pass nothing, and the create_engine / atomesh plumbing and their tests go away. Also read config.keep_finished_outputs directly instead of getattr(config, "keep_finished_outputs", True), so a missing field fails rather than silently leaking.
4. The _bp_fail_stalled_run docstring states an invariant without enforcing it (engine_core_mgr.py:1308-1319). It says only offline seqs are ever in _bp_pending because add_request keeps online ones out, and "if online seqs could wait there, this error would have to reach them through their callbacks". That answers my 11-03 note in prose, but nothing breaks if it stops being true, and the hypothetical branch is just text.
→ Change: replace the conditional paragraph with an assertion where seqs enter _bp_pending (no callback on a pooled seq), plus a test for it.
C. Tests and comments
5. The e2e cut tests check the fake, not _preprocess_prompt (tests/entrypoints/test_pd_messages_responses.py:~601). The fake engine_prompt() implements truncation by prompt_cap.tokens itself and counts any id list as 2 tokens. So deleting the cut from _preprocess_prompt leaves TestBothLegsTruncateAlike / TestStructuredResponsesCutFirst green, and the ids path's input_tokens is never asserted.
→ Change: let the fake do only what the engine does (tokenize and report the real count), and keep the cut in production code under test. With item 1, test the single prepare step directly.
6. The new tests sit in the wrong file (tests/test_engine_core_mgr_shared_state.py:222). Two pytest functions are appended after if __name__ == "__main__": unittest.main(), and they test arg_utils.create_engine and atomesh's initialize_engine, not CoreManager's shared state. test_create_engine_hands_keep_finished_outputs_to_the_config checks the kwargs handed to a fake LLMEngine, not the config. The atomesh case leaves the module globals engine / tokenizer set to "engine" / "tok".
→ Change: with item 3 these tests go away. Otherwise, move them to test_arg_utils_spec.py and the atomesh tests, name them for what they check, and restore the globals (monkeypatch.setattr).
7. The same explanation is written in many places (config.py:2106, engine_core_mgr.py:234, arg_utils.create_engine, atomesh/server.py, api_server.main, and the three to_thread call sites around api_server.py:1996). The "front ends read by callback, nothing drains the queue" rationale appears five times. Each to_thread site carries a near-identical "Off the loop..." note, and generate_async / setup_streaming_request gained prompt_cap docstring lines that item 1 removes.
→ Change: keep one constraint line on the Config field and delete the rest.
…ault - /v1/messages and /v1/responses: _prepare_prompt renders, tokenizes through the engine's own path (InputOutputProcessor.encode_prompt: segment cache and block-hash chain) and cuts to the context, once, on the executor, before the structured/reasoning channels are built. The engine gets the encoding and does not tokenize again. A context with no room for a prompt, or an empty cut, is a 400. _PromptCap, _cut_prompt, _reseed_after_cut, the preprocess rebuild and the prompt_cap parameters are gone. - Config.keep_finished_outputs defaults to False; its readers turn it on (LLMEngine.add_request, asyncio_mode). Online front ends pass nothing; the create_engine / atomesh plumbing and their tests are gone. Read directly from the config. - _bp_fail_stalled_run ends pending seqs that wait on a callback through it, as abort does, instead of a docstring promise. - Tests: the fakes only count what the engine is handed; the prepare step is tested directly; trimmed duplicate comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@valarLip thanks for the second read. Pushed 9786106 (on a merge of current main). Item by item: A1. One prompt-preparation step. A2. A cap < 1 raises B3.
Online front ends pass nothing. The B4. I replaced the conditional paragraph with code rather than an assertion. Main already treats a callback seq in the pool as possible: C5. The fakes only count what they are handed (
The PD truncation tests now check the length of the ids the engine got, and a new test asserts C6. Gone with B3. The remaining additions are in the files of what they test ( C7. One line on the CPU tests ( @valar-cop re-review please. |
valar-cop
left a comment
There was a problem hiding this comment.
a few small things..
ci failed: Pre Checkin, could not tell why yet..
| asyncio_mode: bool = False | ||
| # Queue finished sequences on outputs_queue; only its readers turn it on | ||
| # (LLMEngine.add_request for step / generate, and asyncio_mode). | ||
| keep_finished_outputs: bool = False |
There was a problem hiding this comment.
the PR description still describes the old design, please update summary and validation to match this head..
There was a problem hiding this comment.
Updated. The Summary now names both fixes as they are at this head. Validation now says plainly that its GPU numbers were measured on the 10-30 container with env-gated equivalents, before the review rework; this head has only been run on CPU.
| ) | ||
| token_ids = encoded.token_ids if isinstance(encoded, PrefixEncoding) else encoded | ||
| if len(token_ids) <= cap: | ||
| return encoded, prompt |
There was a problem hiding this comment.
widen these annotations to include PrefixEncoding? now they hide the third input
There was a problem hiding this comment.
Done (5cfc14e): generate_async / setup_streaming_request take str | list[int] | PrefixEncoding, and _prepare_prompt declares its tuple[PrefixEncoding | list[int], str] return. @valar-cop re-review please.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's #2912 made `CoreManager._init_shared_state` read `keep_finished_outputs` and `asyncio_mode`, which the hand-built namespace lacked. `atom_config_double` derives its fields from `dataclasses.fields(Config)`, so a field main adds next arrives with its default. Co-authored-by: Cursor <cursoragent@cursor.com>
Since ROCm/ATOM#2912, CoreManager puts finished sequences on outputs_queue only once a reader has asked for them, which LLMEngine.add_request does. The pump adds requests to CoreManager itself, so it never asked: every finished sequence was dropped, no group came back, and a fully async run waited forever for its first batch. The pump now asks when it starts. An older ATOM queues them anyway and never reads the flag. The test engine drops finished sequences until asked, as CoreManager does.
Summary
Two API-server costs that grow with agentic conversation length:
Sequences are kept forever on a queue nothing reads online. Fixed by making that queue opt-in for its readers (Config.keep_finished_outputs, off by default)./v1/messagesand/v1/responsesthe whole prompt was also tokenized there, up to three times. Fixed by rendering off the loop, and for those two endpoints by one executor step that renders, tokenizes the engine's way and cuts to the context.(An earlier revision also memoized
find_last_user_indexin DeepSeek'sencoding.py. Measured on the checkpoints' own files it gave nothing, so it was dropped; see Validation.)No new environment variables or flags.
Problem
On DSV4.1-Flash DEP4 + DSpark, MLPerf Endpoints agentic c512 (MI455X, 1 h run), throughput fell from ~12k to ~5k tok/s/GPU. The engine was idle: ~0 queued, KV 2–4% used, prefill and decode step times flat. Requests were waiting unread in the API server's socket receive queues, because the API server could not keep up as conversations grew (later turns ~80k tokens, ~135 messages, whole history resent each turn).
CoreManager's output thread runsoutputs_queue.put_nowait(seqs)for every finished batch. Only offlineLLMEngine.step()/generate()and the asyncio path read that queue, so online every finished request stays reachable.Sequenceobjects for ~470 in flight.stream_outputs_queue; this branch was missed.apply_chat_templateruns on the event loop in/v1/chat/completions,/v1/messagesand/v1/responses. Its cost grows with the conversation.Changes
Config.keep_finished_outputs(defaultFalse). Its readers turn it on:LLMEngine.add_request(forstep/generate/ rollout) andasyncio_mode. Online front ends pass nothing, and the output thread only queues finished sequences when it is on._bp_fail_stalled_runends pending seqs that wait on a stream callback through that callback, as abort does./v1/chat/completionsrenders withasyncio.to_thread./v1/messagesand/v1/responsesrun one executor step,_prepare_prompt: render, tokenize through the engine's own path (InputOutputProcessor.encode_prompt: segment cache and prefix block-hash chain), and cut to the context. The reasoning channel (and feat(api): constrain JSON output and expose deployment capabilities #2862's structured channel) is seeded from the text the engine runs, and the engine gets the encoding without tokenizing again. A context with no room for a prompt, or an empty cut, is a 400. Token counts in usage come from the engine.Validation
DeepSeek-V4-Flash/encoding/encoding_dsv4.pyandDeepSeek-V4.1-Flash/encoding/encoding.py, memoized vs raw: identical output, 0.97–1.03x time (1.6 / 2.6 ms at 302 messages).merge_tool_messagesfolds tool results into user turns, so the backward scan stays short and the O(n²) never shows up.tests/entrypoints/,test_engine_core_mgr_shared_state.py,test_dp_load_balance.py,test_dp_backpressure.py,test_gc_utils.py,test_distributed_store.py,test_required_channel.py,test_arg_utils_spec.py,test_llm_engine_add_request_fanout.py: 2938 passed, 99 skipped.🤖 Generated with Claude Code