Repository navigation
perf: flat API-process GC on long serving runs - #2680
ChengYao-amd wants to merge 1 commit into
Conversation
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
valarLip
left a comment
There was a problem hiding this comment.
Thanks — I reviewed HEAD 20f55ac49 and found no regression in the paths I checked: the top-k exchange via dcp_all_gather gives the same shape and, since aiter's custom all-gather copies bytes, bit-identical results; the call site is inside the registered sparse_attn_indexer custom op (outside Dynamo tracing); and the side stream runs only the shared experts, so it doesn't contend with the indexer for the IPC input pool. I haven't run the unit or GPU tests.
The main gap is that the outputs_queue leak fix doesn't cover the atomesh standalone service, and the reader is inferred from the flush hook rather than declared (inline at engine_core_mgr.py:785). The rest are small cleanups, plus two items on lines outside the diff:
atom/model_engine/engine_core_mgr.py:508: the worker-node early return still assignsself._asyncio_mode = False, although_init_shared_statenow owns this field. Two writers for one field is the drift_init_shared_state's docstring warns about, and a worker node has no output threads, so this line can go.docs/metrics_guide.md:106and:108: the table rows foratom:prefill_request_context_tokens/atom:decode_request_context_tokensdon't say they now requireATOM_METRICS_PER_REQUEST_CONTEXT=1; only the later section does. Users reading the table will expect them by default.
| # callbacks; queued for it, every finished Sequence | ||
| # would stay reachable for the life of the process | ||
| # and slow each gen-2 GC pass. | ||
| if self._flush_stream_batch_fn is None or self._asyncio_mode: |
There was a problem hiding this comment.
The atomesh standalone service still leaks. This guard uses "flush hook unset" as a stand-in for "someone reads outputs_queue", but AtomEngineService (entrypoints/atomesh/atom_standalone_service.py) also delivers through stream_callbacks (L394/439), calls core_mgr.add_request directly (L232/302), never sets _flush_stream_batch_fn (only api_server.py:3041 does), and never calls get_output/step(). So for atomesh _flush_stream_batch_fn stays None, every finished seq list still goes through outputs_queue.put_nowait(seqs) and is never consumed — the same gen-2 GC growth this PR fixes for the OpenAI server. The comment calling the API server "the one caller that wires the flush hook" already doesn't describe atomesh.
At the root, whether the queue has a reader should be declared by the readers, not inferred from the API server wiring a flush function: e.g. a consumes_outputs_queue flag that LLMEngine.step()/generate() and the asyncio handler opt into. The current inference is wrong in both directions — a callback-only entrypoint without the hook leaks (atomesh), and an entrypoint that sets the hook and later calls generate()/step() (warmup, rollout on the server) would block forever in outputs_queue.get().
There was a problem hiding this comment.
Agreed on both counts; fixed in 229f29f.
Whether the queue has a reader is now declared rather than inferred from the flush hook. CoreManager.consumes_outputs_queue is set in _init_shared_state, and the output thread checks only that flag before outputs_queue.put_nowait(seqs). The two callback-only entrypoints clear it as they wire up:
- the OpenAI server, in a new
_deliver_through_stream_callbacks(core_mgr)that sets the flush hook and clears the flag together, so the two can't drift apart again; AtomEngineService.__init__(atomesh standalone), which never sets the flush hook. This was the leak.
The flag defaults to True, so readers don't opt in; the two non-readers opt out. Reasons:
- The readers are
LLMEngine.step()/generate()/generate_multimodal(),generate_hidden_states()in rollout, and asyncio mode. If they had to opt in, each would need to set the flag before its firstadd_request, or the first finished batch is dropped andget_output()blocks. With True as the default, these paths behave exactly as before. - Only two entrypoints deliver through callbacks alone (
api_server.pyandAtomEngineService; nothing else callscore_mgr.add_requestwith stream callbacks), and each clears the flag at the single point where it takes over the engine.
The two failure directions you pointed out:
- Leak (callback-only entrypoint never reads): an entrypoint that forgets to clear the flag leaks the same way as before. That is why atomesh is now wired explicitly, and both wirings have tests.
- Hang (reader on a manager that doesn't queue):
get_output()andget_output_async()now raiseRuntimeErrorwhen the flag is False, so agenerate()/step()after the server has wired up fails immediately instead of blocking inoutputs_queue.get().
Tests (tests/test_engine_core_mgr_outputs_queue.py, which drives a real output thread):
- LLMEngine: queued, and
LLMEngine.step()reads it; - asyncio mode: queued;
- OpenAI server wiring: not queued;
AtomEngineService: not queued;- flag cleared:
get_output()/get_output_async()raise.
The last three fail on the previous head and pass now. The comment at this line no longer names the API server as the only hook owner; it points to the flag.
| @@ -1282,11 +1282,12 @@ def dcp_decode_candidate_exchange_fused( | |||
| ) | |||
|
|
|||
| # Half the payload of the (score, gid) exchange. Still moved as int32 so no | |||
There was a problem hiding this comment.
This comment ("Still moved as int32 so no float canonicalization can touch the bits") is no longer accurate: dcp_all_gather sends 4-byte payloads through _ag_custom_view_dtype → float32, and aiter's custom_all_gather also maps int32 to float32, so the custom path actually moves float32. It's bit-safe only because the aiter kernel is a pure memcpy; if that ever changes, the comment points at a protection that no longer exists.
There was a problem hiding this comment.
Right, the custom path moves float32, so the int32 view protected nothing. Rewritten in 4d3b997: the comment now says the result is bit-identical because both the custom all-gather and the pynccl fallback only copy bytes.
| gathered_sc = ( | ||
| get_dcp_group() | ||
| .all_gather(send.view(torch.int32), dim=0) | ||
| dcp_all_gather(get_dcp_group(), send.view(torch.int32), 0) |
There was a problem hiding this comment.
Related simplification: send.view(torch.int32) followed by gathered_sc.view(torch.float32) is now a no-op round trip — both the custom path and the NCCL fallback copy bytes — so gathering the float32 send directly does the same job and drops the two views plus the comment justifying them.
There was a problem hiding this comment.
Done in 4d3b997. The float32 send is gathered directly, and both views and the comment defending them are gone. send is already contiguous float32, so dcp_all_gather hands it to device_comm.all_gather with no view at all. The views were metadata-only before, so there is no change to kernels or graph nodes. A 4-GPU check that gathers random 32-bit patterns (NaN payloads, signalling NaNs, denormals) as GroupCoordinator.all_gather on int32, dcp_all_gather on int32 and dcp_all_gather on float32 is bit-identical across all three, eagerly and on CUDA-graph replay, on DCP4 on MI355X (both GPU halves, and with AITER_REUSE_IDENTICAL_COMM_GROUPS=1). Timing inside a graph of 24 gathers is the same before and after this change: 160 rows (1.25 MB/rank) 27.7 µs for both, vs 38.8 µs for GroupCoordinator.all_gather; 16 rows 6.6 vs 18.2 µs.
| @@ -586,7 +589,6 @@ def __init__(self, config: Config): | |||
| self._finalizer = weakref.finalize(self, self.close) | |||
| self.async_output_queue = asyncio.Queue() if config.asyncio_mode else None | |||
There was a problem hiding this comment.
async_output_queue reads config.asyncio_mode with strict attribute access, while _asyncio_mode (set in _init_shared_state) uses getattr(config, 'asyncio_mode', False), so the same fact has two sources with different defaults. With a config lacking the attribute, _asyncio_mode is False but this line raises; if they ever diverge, the output thread queues for a handler that has no async_output_queue, or the reverse. Suggest deriving one from the other.
There was a problem hiding this comment.
Fixed in 229f29f. _init_shared_state is now the only writer of _asyncio_mode (still getattr(config, "asyncio_mode", False)), and both CoreManager.__init__ and DisaggCoreManager.__init__ derive async_output_queue from it. DisaggCoreManager used to hard-code None regardless of asyncio_mode, which was the same disagreement; with asyncio_mode=True it now gets the queue. No entrypoint in the repo sets asyncio_mode=True today.
20f55ac to
4d3b997
Compare
|
Thanks for the review. All four inline comments are addressed; replies are on each thread. The two items outside the diff:
Rebase. Rebased onto main Validation after the rebase.
GPU checks ran on one 8 × MI355X node, with main
None of the review changes touch the paths measured in the description, so the full tables there are not re-run. The |
|
Please fix code conflicts. |
Under AgentX load, both API processes of an ATOM PD deployment spent ~45% of
the wall clock in stop-the-world gen-2 GC passes, and each pass grew with the
requests served (~0.65 s per pass after an hour). A pass freezes the event
loop, so admission, the prefill hand-back and SSE delivery all wait on it.
Two things in the API process grew with every completed request:
- CoreManager.outputs_queue. The output thread put every batch of finished
sequences on it, but only LLMEngine.step()/generate() and asyncio mode read
it; the OpenAI server and the atomesh standalone service deliver through
the per-sequence stream callbacks. So every finished Sequence stayed
reachable for the life of the process, as RequestOutputs did on
stream_outputs_queue before. Whether the queue has a reader is now
declared: consumes_outputs_queue starts True in _init_shared_state, both
callback-only entrypoints clear it as they wire up, and the output thread
queues only while it is set. get_output()/get_output_async() raise when it
is cleared instead of waiting on a queue nothing fills. _asyncio_mode is
set only in _init_shared_state, and both managers derive
async_output_queue from it.
- atom:{prefill,decode}_request_context_tokens: one series per request that
is never removed, and every /metrics render re-reads and parses all of
them. They are opt-in now (ATOM_METRICS_PER_REQUEST_CONTEXT=1); agentic CI
sets it on both service roles so its reports keep those panels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4d3b997 to
5bfc096
Compare
|
Rebased onto main
|
Two changes came out of GLM-5.2 PD tuning on MI355X (decode TP4 + DCP4), where they were first measured as patches (AMD-AGI/Infera#209). The second, the DCP top-k exchange, has since landed on main in #2776 with the same code, so this PR now carries only the first, as one commit.
1. The API process no longer slows down with every request it serves
Two things in the API process grow with every completed request and are never freed:
CoreManager.outputs_queue. The output thread puts every batch of finished sequences on it, but onlyLLMEngine.step()/generate()and asyncio mode read it; the OpenAI server and the atomesh standalone service deliver through the per-sequence stream callbacks. Online, every finishedSequencestays reachable for the life of the process (the same leak an earlier fix removed forstream_outputs_queue).atom:{prefill,decode}_request_context_tokens. One Gauge series per request, never removed. In multiprocess mode, every/metricsrender reads and parses all of them.Each gen-2 pass then walks a heap that grows with the requests served,
/metricsrenders get slower, and both freeze the event loop. Admission, streaming and the PD hand-back all wait on them.outputs_queuehas a reader is declared byCoreManager.consumes_outputs_queue, set in_init_shared_state. It defaults to True (offlineLLMEngine, rollout and asyncio mode read the queue and are unchanged). The OpenAI server andAtomEngineServiceclear it when they wire up, and the output thread queues only while it is set.get_output()/get_output_async()raise if it is cleared instead of blocking on a queue nothing fills._asyncio_modeis written only in_init_shared_state, and both managers deriveasync_output_queuefrom it.ATOM_METRICS_PER_REQUEST_CONTEXT=1(envs.py,docs/environment_variables.md,docs/metrics_guide.md).pd_server_atom.shsets it on both roles foraiperf_agentic, next toATOM_ENABLE_METRICS_DEVICE_TIMER, so the agentic reports keep their request-context panels.Behavior change:
atom:prefill_request_context_tokensandatom:decode_request_context_tokenshave no samples unlessATOM_METRICS_PER_REQUEST_CONTEXT=1is set. Callingget_output()/get_output_async()(i.e.LLMEngine.step()/generate()) on an engine that the OpenAI server or the atomesh standalone service has wired up now raisesRuntimeErrorinstead of blocking forever. Other metrics are unchanged, and offlineLLMEngine/ asyncio mode still get every finished sequence.2. DCP decode: the sparse-indexer top-k exchange uses the custom all-gather (now in main via #2776)
dcp_decode_candidate_exchange_fusedgathers each rank's local top-k (score, id) candidates over the DCP group once per indexer layer, 24 times per GLM-5.2 decode step with MTP3. It calledGroupCoordinator.all_gather, which is pynccl by default.dcp_all_gather, already used for the LSE and query-head gathers, takes aiter's IPC all-gather when the shape qualifies and pynccl otherwise. The float32 scores are gathered as they are. Both paths only copy bytes, so the result is bit-identical.#2776 made the same change on main, so the commit was dropped when this branch was rebased onto
ce46c7c8d. The "(2)" measurements below are kept for reference: they measured this change.Validation
Rebased onto main
c34e7d0b2after review, and again ontoce46c7c8don 10-30 (53 more commits; the only conflict was #2456's backpressure refill next to the guardedput_nowait, and both are kept). The GPU tables below were measured on the original basea1ebc608and are not re-run; a short regression onc34e7d0b2is listed under "After rebase".Main
a1ebc608and this branch, each mounted as the ATOM source inrocm/atom-dev:nightly_202610221638(aiter665a43dc4) on one 8 × MI355X node. GLM-5.2-MXFP4 runs the standalone DCP setup fromrecipes/Agentic-GLM-5.2.mdwithout LMCache (TP4 + DCP4, MTP3, block 64, FP4 index cache,--max-num-batched-tokens 16384, graphs up to 128). Main and this branch run at the same time on GPUs 0–3 and 4–7.Unit tests (CPU,
.github/scripts/run_unit_tests.sh, once46c7c8d): 10,160 passed, 1,085 skipped, 1 failed; main 10,152 passed, 1,085 skipped, 1 failed. The one failure,test_git_sha_of_checkout_with_different_owner, also fails on main with the local git 2.34.tests/test_engine_core_mgr_outputs_queue.pydrives a real output thread: finished sequences are queued forLLMEngineand asyncio mode, not behind the OpenAI server orAtomEngineService, andget_output()/get_output_async()raise when nothing fills the queue. Those last three cases fail on the previous head.test_request_context_gauges_are_opt_infails on main and passes here. Black and Ruff pass on the changed lines.Accuracy (GSM8K with CI's
lm_evalcommand: 3-shot, 65 concurrent, flexible-extract, real MTP acceptance): main 0.9158 ± 0.0076, this branch 0.9249 ± 0.0073.(1) API process over a long run. 20,000 requests of 2,048 random input and 64 output tokens at concurrency 64 (29 min, real MTP acceptance).
/metricsis scraped once a second, as a Prometheus job would, and the API process's gen-2 passes are timed with agc.callbackshook:/metricsp50/metricsp50/metricsended at 43,668 request-context series and a 6.5 MB body.(2) Top-k gather, microbenchmark.
[rows, 2048]int32 per rank on DCP4 (4 GPUs).GroupCoordinator.all_gather(main) is compared withdcp_all_gather(this branch). Both return every rank's exact bits, eagerly and on CUDA-graph replay. Time is per gather inside one graph of 24 gathers:At 40 sequences that is −0.26 ms per decode step. Results are identical with
AITER_REUSE_IDENTICAL_COMM_GROUPS=1.(2) End-to-end decode. Fresh servers with forced MTP acceptance 2.99 and random tokens with
--ignore-eos. Each pair runs twice, with the GPU halves swapped. One output chunk is one verify step, so the median inter-chunk latency (ITL p50) is the decode step:The step is 0.31–0.44 ms shorter on both halves (−0.8% to −0.9%). Throughput moves −0.4% to +1.3%, within run-to-run noise.
In the GLM-5.2 PD deployment where these were first measured (ATOM
661ef766with these changes as patches, AgentX at C64), both API processes had spent ~45% of the wall clock in gen-2 GC. The GC change raised throughput 1.9% and cut mean TTFT from 2.85 to 2.04 s; the top-k gather went from 54 to 31 µs in the decode trace.After rebase (main
c34e7d0b2vs this branch, same setup, imagerocm/atom-dev:nightly_202610251529):Not in this PR
Infera#209 also moves the DCP attention merge onto a custom all-to-all, and then into one aiter kernel together with the LSE combine and FP8 quant. Those need new aiter kernels (
custom_all_to_all,custom_a2a_combine_quant) that are not in aiter yet. They will follow once the aiter side lands.🤖 Generated with Claude Code