[Performance] Offload multimodal rollout preprocessing from the asyncio event loop - #2363
Open
Qi2yU wants to merge 8 commits into
Open
[Performance] Offload multimodal rollout preprocessing from the asyncio event loop#2363Qi2yU wants to merge 8 commits into
Qi2yU wants to merge 8 commits into
Conversation
added 8 commits
September 6, 2026 07:47
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.
[Performance] Offload multimodal rollout preprocessing from the asyncio event loop
Prerequisite bug fix (
8d81326)This PR ships one prerequisite fix that is orthogonal to asynchronous preprocessing but
is required to run any multimodal rollout with
--apply-chat-template+--rollout-max-prompt-len.fix(rollout): avoid re-parsing chat-template prompt in multimodal length filterWhen
--apply-chat-templateis on,Sample.promptis stored as the renderedchat-template string. In
filter_long_prompt(slime/utils/data.py), the multimodalbranch then passed that string back into
process_vision_info(sample.prompt, ...), whichexpects the original message list.
qwen_vl_utilsiterates it as messages, hitsmessage["content"]on astr, and crashes — so multimodal length filtering wasunusable:
Fix: don't re-parse the prompt. The images/videos were already extracted once in
Dataset.__init__and stored onsample.multimodal_inputs; reuse them and only run theprocessor for the length check:
This is an existing bug independent of asynchronous preprocessing; it is included here
so the benchmark below (and any multimodal rollout with length filtering) can run at all.
Motivation
In multimodal RL rollout, every generation request must first transform its raw images
into the inputs required by SGLang: run the HF processor to build
prompt_ids/multimodal_train_inputs, and encode each image to a PNG/base64 string for therollout engine. Today both steps run synchronously on the asyncio event loop.
Problem
generate()is an async coroutine, but the two heaviest preprocessing pieces blockthe event loop:
_prepare_prompt_ids(CPU-heavy tensor building);encode_image_for_rollout_engine, run serially per image for every sample.While one sample's images are being processed and encoded, no other rollout coroutine can
make progress — the event loop is stalled. The more images a sample carries, and the
more concurrent requests in a batch, the worse this head-of-line blocking gets. On
image-rich workloads, rollout preprocessing can become a significant bottleneck.
Design
Move the blocking work off the event loop and parallelize per-image encoding, without
changing what gets sent to SGLang.
_prepare_prompt_ids→_prepare_prompt_ids_async(
slime/rollout/sglang_rollout.py): the HF processor runs vialoop.run_in_executor(...)on a dedicated thread pool, so it no longer blocks theevent loop. The existing synchronous
_prepare_prompt_idsstays in the tree as theground-truth reference.
encode_image_for_rollout_engine→async_encode_image_for_rollout_engine(slime/utils/processing_utils.py), andgenerate()now gathers all images of a sample concurrently:asyncio.gatherpreserves image order regardless of completion order.ThreadPoolExecutor(thread_name_prefix= "slime-multimodal")keeps this blocking work off both the event loop and asyncio'sdefault executor, so it can't starve other async I/O.
This is purely an execution-mode change (sync → async/threaded). It introduces
no new CLI flags and does not touch the request contents, so baseline and
candidate run with identical arguments; the only difference is the code version.
Correctness
Async preprocessing only changes how multimodal inputs are prepared, not what is
produced. We verified the change locally at both the preprocessing and rollout levels:
prompt_idsandmultimodal_train_inputson the multimodal, text-only, andexisting-token branches. Blocking work runs outside the event-loop thread, and
concurrent samples do not cross-contaminate processor outputs.
prompt_idsandmultimodal_train_inputsare identical(
torch.equal) to the synchronous reference, including under 64-way concurrency.generate()is identical to thesynchronous reference, including
input_ids,text, orderedimage_data, andsampling_params.--debug-rollout-only, using the same seed anddataset order, the saved rollout dumps match on the two groups async preprocessing
affects:
request(prompt-token prefix stored in each completed sample): identical (8/8)processor(multimodal_train_inputstensors): identical (8/8)responsediffers only where SGLang inference is non-deterministic (see note).Performance
E2E rollout time in
--debug-rollout-only(SGLang only), sweeping(rollout_batch_size, n_samples_per_prompt)from small to large so the number ofgeneration requests — and thus the image-processing pressure — grows from 4 to 128.
Setup
--rollout-num-gpus-per-engine 4)images total). Image-rich on purpose: preprocessing is a larger share of rollout here.
max_response_len=1024, near-greedy (--rollout-temperature 1e-6),--rollout-seed 42, no shuffle4c193f1); candidate = asynchronous preprocessing (4bb098a)Rollout time (mean, warm-up excluded)
The speedup grows with image-processing pressure: ~1.5× at small batches, 2.3–2.6× at
large batches. At the biggest point (bs=32, n=4, 128 requests) baseline spends ~141s/step
on synchronous preprocessing vs ~62s/step for asynchronous preprocessing.
Per-step raw
perf/rollout_time(warm-up first)Checklist
Contributors
Engine Architecture Group 5, Engine Infrastructure Department, Xiaohongshu
Yu Qi, Zhaokai Luo, Kaicheng Sun