Repository navigation
[None][perf] bound multimodal request CPU memory in trtllm-serve - #19800
Draft
yechank-nvidia wants to merge 2 commits into
Draft
yechank-nvidia wants to merge 2 commits into
yechank-nvidia wants to merge 2 commits into
Conversation
Add opt-in --max_multimodal_cpu_bytes and --max_multimodal_cpu_bytes_per_request options to trtllm-serve. The per-request limit makes the media tracker count unique CPU tensor, NumPy, and PIL storage as items finish decoding; once the limit is exceeded, the remaining loads are cancelled and the request fails with HTTP 413. The total limit reserves one worst-case request slot per frontend process before any media load starts and returns it once the request has been handed to the engine. If only the total limit is set, the per-request limit defaults to it, so one request with media is preprocessed at a time; the server logs the slot count at startup. Text-only requests and servers without the options keep the existing control flow. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
When --max_multimodal_cpu_bytes_per_request is set, a pure ASGI middleware rejects /v1/chat/completions, /v1/messages, and /v1/responses bodies above the limit with HTTP 413 before FastAPI buffers and parses them, for both declared and chunked bodies. The received byte count seeds the per-request decoded-media ledger, and the decoded media plus the processed multimodal CPU tensors and shared CPU handles are checked again before the request is handed to the engine. A request rejected by that check rebuilds and drops the shared CPU handles preprocessing exported, since no worker will consume them; otherwise their shared memory would stay allocated until the process exits. The OpenAI routes report 413 with error type RequestTooLargeError; /v1/messages uses the Anthropic request_too_large type. Once a request is handed off, openai_chat drops its references to the decoded media so that a non-streaming request does not keep them alive until generation completes. Signed-off-by: yechank <161688079+yechank-nvidia@users.noreply.github.com>
This was referenced Nov 2, 2026
Open
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Problem.
trtllm-servedecodes the media of a multimodal chat request in the frontend process.MultimodalDataTracker.retrieve_all_async()gathers every item of a request at once. The media worker pool (--num_media_load_workers) limits how many decodes run at the same time, not how much decoded data a request or the process holds: every finished item stays referenced until the whole request is decoded. The decoded data and the processed tensors stay referenced until the request reaches the engine, and for a non-streaming requestopenai_chatkeeps them in local variables until the response is complete. There is no per-request or per-process byte limit for this. Inline base64 media is not covered by the remote-download size limit, and FastAPI buffers and parses the whole request body before any handler runs.The effect on memory, measured against
95cb50c358(method in the first table below): one request with 16 / 64 / 128 inline 1024x1024 JPEGs grows frontend RSS by +468 / +1060 / +1829 MiB.Mechanism. This PR adds two opt-in options to
trtllm-serve, bothprototypeand disabled by default (items 1 and 2), plus one small change that always applies (item 3).--max_multimodal_cpu_bytes_per_requestcaps one request:POST /v1/chat/completions,/v1/messages, and/v1/responseschecks the request body before FastAPI parses it.Content-Lengthis above the cap, it returns 413 without reading the body.LLM.preprocess, the decoded media plus the processed multimodal tensors are checked once more, beforegenerate_async. Shared CPU handles count by their storage size; CUDA handles are not counted.LLM.preprocesshas already exported the processed CPU tensors to shared memory by then, and only a consumer that rebuilds the tensors frees that memory. So when this check rejects a request, the server rebuilds and drops those CPU handles before it returns 413. Without this step, each rejected request would leave its processed tensors in/dev/shmuntil the frontend process exits.--max_multimodal_cpu_bytescaps one frontend process:max_multimodal_cpu_bytes // max_multimodal_cpu_bytes_per_requestrequest slots and logs the count at startup.generate_asyncaccepts the request, or on any error.generate_asynchas accepted a chat request,openai_chatdrops its local references to the decoded media and the processed inputs. Without this, a non-streaming request would keep them until generation finished, after its slot had already been returned, so any number of admitted requests could each hold up to a per-request cap of decoded media in the frontend. Streaming requests already dropped them. This runs with or without the options; its memory effect was not measured separately.On the OpenAI routes, every 413 has error type
RequestTooLargeError(the middleware's body uses the sameErrorResponseenvelope as the chat path). On the server's/v1/messages, every 413 uses the Anthropicrequest_too_largeerror type. The middleware answers in that envelope, and the route's translation of chat-path errors maps status 413 to it; other 4xx statuses stayinvalid_request_erroras before. With--grpc, the options raise an error instead of being silently ignored.Why this is safe.
asyncio.gatherpath (oneNonecheck added);openai_chatonly does a few attribute checks per request, plus dropping its local references to the inputs aftergenerate_async(item 3 above), which does not change what is sent to the engine./v1/completions, embeddings, the multimodal encoder server (mm_embedding_serve), and VisualGen are unchanged.finallyon every exit path. The end-to-end run shows that requests after several 413s, and six concurrent requests through two slots, all succeed./dev/shm/torch_<pid>_*files after any of these rejections.Measured impact. This PR bounds memory; it does not make anything faster, so there is no throughput claim.
RSS of one many-image request (CPU only). All runs used PR revision
ee506fbe54against main baseline95cb50c358, in the TRT-LLM dev container (PostMerge build of that commit), each row in a fresh process:ImageMediaIO(format="pt"), so each decoded item is a 12 MiB float32 tensor;MultimodalDataTracker.retrieve_all_async();With the cap, decoded output stops at the cap: the 64- and 128-item requests keep only 22–23 decoded items.
The same experiment on an earlier development branch (2026-09-01, older main) gave matching results:
RSS stays well above the counted bytes because decoder temporaries, allocator high-water, and decodes still running are not counted. The cap bounds counted storage; it is not an RSS limiter.
Overhead on an accepted request. The 16-item case (192 MiB, under a 256 MiB cap) ran 4 times each in alternating fresh processes. The median was 124.3 ms off versus 125.2 ms on, with a spread of 122–134 ms, so the difference is within noise.
End-to-end check. Setup:
trtllm-servefrom this branch with--max_batch_size 8 --max_num_tokens 32768 --max_seq_len 32768, KV-cache memory fraction 0.2, CUDA graphs off;--max_multimodal_cpu_bytes 128MiB --max_multimodal_cpu_bytes_per_request 64MiB(two slots);--max_multimodal_cpu_bytes 80MiB --max_multimodal_cpu_bytes_per_request 40MiB(two slots). With these limits, one 2048x2048 image passes the decode check but fails the processed-tensor check.Results:
RequestTooLargeError) during decode at 76,027,401 bytes, before preprocessing.RequestTooLargeError) before parsing, both withContent-Lengthand chunked. On/v1/messages, the 413 uses the Anthropicrequest_too_largeenvelope./v1/messagesgets 413 with typerequest_too_large;/dev/shm/torch_<pid>_*files after each of these requests, after 5 s idle, and after the successful requests that follow;/dev/shmafter such rejections; that pre-fix run was not saved, so no counts are given here.Not measured: serving throughput or latency under load with the options on. When the total cap is reached, requests with media wait for a slot.
Scope and limitations.
--max_multimodal_cpu_bytesuses uniform worst-case slots, not a weighted byte ledger, so a small request reserves a full per-request share.request.messages) stays resident in the frontend until the response is complete. Its raw size is charged to the per-request cap during admission, but it is no longer covered by a slot after the slot is returned./v1/responsesgets only the raw-body cap: no decoded or processed accounting, no slot./v1/messageskeep their existing error mapping, so a 413 from the chat path shows up there asinvalid_request_error.mm_embedding_servedoes not expose the options.--num_serve_frontends N, the total is N times the cap.Test Coverage
Tests
tests/unittest/llmapi/apps/test_openai_server_multimodal_admission.py, 12 CPU-only cases, registered intests/integration/test_lists/test-db/l0_cpu.yml. It covers:MultimodalParams.to_handle: counted once per storage, and their shared-memory file removed by the release step;Content-Lengthrejection on both the chat and/v1/messagespaths, chunked rejection, staying 413 with typeRequestTooLargeErrorthrough a real FastAPI app, recording the byte count, and leaving non-multimodal routes alone;tests/unittest/api_stability/references/trtllm_serve_cli.yamllists the two newprototypeoptions.Commands run (all in the TRT-LLM dev container, PostMerge build of
95cb50c358):pytest -v tests/unittest/llmapi/apps/test_openai_server_multimodal_admission.py tests/unittest/api_stability/test_serve_cli.py: 16 passed (12 admission cases, 4 CLI stability cases).pytest -m cpu_onlyon all 22unittest/llmapi/appsentries inl0_cpu.yml, plus:tests/unittest/disaggregated/test_openai_disagg_service.py,test_disagg_internal_auth.py,test_openai_server_info.py, andtest_openai_server_stream_errors.py;tests/unittest/llmapi/apps/test_openai_server_iteration_stats.pyandtests/unittest/llmapi/test_rl_control_auth.py;tests/unittest/api_stability;tests/unittest/inputs.Result: 1275 passed, 11 deselected (not
cpu_only).pytestwithout a marker filter on the Anthropic route and batch tests,test_openai_chat_disagg_multimodal.py,test_serving_extensions.py,test_chat_utils.py,test_openai_server_iteration_stats.py, the new admission tests,test_rl_control_auth.py, and the four disaggregated server tests above: 334 passed.The end-to-end
trtllm-servecheck described above (Qwen3-VL-2B-Instruct, one H200), run on the final head of this branch: all cases as listed.The fresh-process RSS runs described above.
pre-commit run --files <changed files>: passed.