This document is relevant for: Trn2, Trn3
Async Scheduling and Async Execution#
Table of Contents#
Overview#
Async scheduling overlaps CPU work (scheduling, input preparation, output processing) with Neuron device execution (NEFF forward pass) to reduce per-step latency. In synchronous mode, the CPU idles while the device runs the forward pass, and the device idles while the CPU prepares the next step. Async scheduling eliminates this idle time by:
Using a batch queue (depth 2) in the engine core to schedule one step ahead, so the worker always has work ready.
Feeding the previous step’s sampled token future directly as
input_idsinto the next forward pass (device-to-device, no CPU roundtrip), using the Neuron async runtime to enqueue NEFF executions ahead of time.Materializing tokens to CPU on a separate async output thread, overlapped with the engine core scheduling the next step.
When the batch composition changes (requests finish, new requests join), or
when the previous sampled-token future cannot be reused for the next decode
input shape, the async flow must be “broken” — the pending device future is
materialized on the critical path, batch state is updated, and CPU-built
input_ids is used for that step. If the request set is unchanged but the
batch order changed, the future can be remapped into the new order; because
the remap clones/forces the future on the worker main thread, that step is
accounted as a sync fallback. The async flow automatically reinstates on the
next steady-state decode step.
Async scheduling is on by default and requires on-device sampling. To
disable, use --no-async-scheduling.
When Does Async Scheduling Help Most?#
All workloads benefit from async scheduling, but these scenarios see the largest gains:
Smaller models (faster NEFF execution): The CPU overhead (scheduling, input preparation, output processing) is roughly constant regardless of model size. For smaller models where the NEFF forward pass is fast, the CPU overhead becomes a larger fraction of the total step time. Async scheduling hides this overhead by overlapping it with device execution, so the relative speedup is greater.
Longer average response length: Async scheduling only engages during steady-state decode (consecutive decode steps with no batch composition change). Longer responses mean more consecutive decode steps, so the async path is active for a larger fraction of the total generation. Short responses that finish quickly cause frequent batch composition changes, forcing sync fallbacks that reduce the benefit.
Custom Async Execution Integration with Async Scheduling#
The upstream vLLM GPU implementation uses CUDA streams for async execution — the GPU runtime provides stream semantics where operations on the same stream are ordered and the CPU can enqueue work without blocking. The CPU simply enqueues kernels and continues.
The Neuron runtime does not currently provide torch.cuda.Stream-style
semantics. Once native PyTorch support for Neuron is available, it will
provide stream semantics and this custom implementation can be simplified.
Until then, the runtime offers an async execution mode where:
A NEFF (compiled model) can be enqueued for execution with device tensor futures as inputs. The runtime guarantees the NEFF will wait for the input futures to resolve before executing.
Output tensors are also futures — accessing them (e.g.,
.to("cpu")) blocks until the NEFF completes.
This means we must explicitly manage the future lifecycle:
Hold futures across steps: Store the sampled token future in
async_execution_bufferand pass it asinput_idsto the next NEFF execution, avoiding the device→host→device roundtrip.Intentionally break the async flow: When the batch composition changes, or when a shape/dtype mismatch forces CPU-built inputs, we must materialize the future on the critical path (blocking
.to("cpu")) to update CPU-side state before proceeding. The GPU implementation handles this implicitly via stream synchronization; we must do it explicitly. When the request set is unchanged but the request order changes, we can remap the future on-device, but the remap still forces the future and is counted as a sync fallback step.Thread-safe materialization: Both the async output thread and the worker main thread may call
get_output()on the same future. A per-object lock prevents double-materialization.
Normal Flow (Synchronous Baseline)#
In synchronous mode (--no-async-scheduling), each inference step runs as a
strictly sequential pipeline. The engine core orchestrates the flow:
Scheduler selects which requests to run and produces a
SchedulerOutput.Engine core calls
worker.execute_model(scheduler_output).Inside the worker,
NeuronModelRunner.execute_model()does the heavy lifting:a.
_update_states()— applies the scheduler’s decisions (add/remove requests, update token counts, manage sequence IDs).b.
_prepare_model_input()— buildsinput_ids,positions,logits_indices, andattn_metadatafrom the currentInputBatchstate.c.
_execute_model_forward()— moves tensors to the Neuron device, runsmodel(**kwargs), and blocks onmodel_output_tensor.to("cpu")to copy the result back to host memory.d. Stores the result in
execute_model_stateand returnsNone.Engine core sees
Noneand callsworker.sample_tokens(grammar_output).NeuronModelRunner.sample_tokens()unpacks the stored state, runs the sampler (_sample()), updates batch state with the sampled tokens (_update_batch_state_with_samples()), and returns aModelRunnerOutput.Engine core passes the output to
scheduler.update_from_output()which processes finished requests, frees blocks, and prepares for the next step.
The critical property of this flow is that every tensor transfer is blocking: the CPU waits for the device-to-host copy of logits/sampled tokens to complete before proceeding. This means the CPU is idle during the entire model forward pass, and the device is idle during scheduling and input preparation.
Step N Step N+1
───────────────────────────────────────────── ──────────────────────
┌─────────────────────────────────────────────────────────────────────┐
│ Engine Core │
│ │
│ schedule() ──► execute_model() ──► sample_tokens() ──► ... │
│ │ │ │ │
└──────┼──────────────────┼─────────────────────┼─────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ NeuronModelRunner │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ ┌──────────┐ │
│ │ _update_ │ │ _execute_model_forward() │ │ _sample()│ │
│ │ states() │ │ │ │ │ │
│ │ │ │ ┌────────────────────────┐ │ │ │ │
│ │ _prepare_ │ │ │ model(**kwargs) │ │ │ │ │
│ │ model_ │ │ │ (Neuron device) │ │ │ │ │
│ │ input() │ │ └────────────────────────┘ │ │ │ │
│ │ │ │ output.to("cpu") ◄─ BLOCK │ │ │ │
│ └──────────────┘ └──────────────────────────────┘ └──────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
Timeline (single step):
CPU: ┃ schedule │ prepare_input │░░░░░ IDLE ░░░░░│ sample │ update ┃
Device:┃░░░░░░░░░░░░░░░░░░░░░░░░░│ model forward │░░░░░░░░░░░░░░░░┃
▲ ▲
│ │
.to(device) .to("cpu") ← blocking
The ░░░░░ IDLE regions are the latency cost of synchronous execution:
the CPU stalls while the device runs the forward pass, and the device sits
idle while the CPU schedules and prepares the next step. Async scheduling
exists to overlap these two phases.
Async Flow (Steady-State)#
Steady-state means consecutive decode steps where the batch composition does not change — no requests finish, no new requests join. When the batch changes, we leave steady-state and enter the “breaking” path described in later sections.
Key Idea#
In synchronous mode the CPU blocks on .to("cpu") to copy sampled tokens
back from the device before the next step can begin. Async scheduling
eliminates that blocking transfer from the critical path by:
Keeping the sampled token tensor on the device and feeding it directly as
input_idsinto the next forward pass (device-to-device, no CPU roundtrip). This is made possible by the Neuron async runtime: we can pass the future of a device tensor to a NEFF and enqueue the NEFF execution ahead of time. The runtime guarantees that the NEFF will wait for the tensor to be ready before executing.Materializing the tokens to CPU on a separate thread — the multiproc executor’s
async_output_busy_loopthread callsAsyncNeuronModelRunnerOutput.get_output(), which performs the.to("cpu")transfer and updates batch state. This runs concurrently with the engine core’s main thread scheduling the next step.
So the CPU does eventually see every token — but the materialization happens off the critical path, overlapped with scheduling and input preparation for the next step. This also guarantees that output tokens are streamed back to the client: the async output thread materializes and enqueues each step’s tokens as soon as they’re ready, so the client receives tokens incrementally rather than waiting for the full sequence to complete.
Step-by-Step Flow#
Consider two consecutive decode steps, Step N and Step N+1, with the same batch of requests.
Step N (produces futures):
execute_model()calls_update_states()and_prepare_model_input()as usual. On the very first async step,input_idscomes from CPU (there are no futures yet)._execute_model_forward()runsmodel(**kwargs)on the Neuron device. The model returnssampled_token_idsas a device tensor future.Unlike sync mode, the
.to("cpu")call is skipped — the output stays on device as a future.sample_tokens()takes a snapshot ofreq_idsvia_bookkeeping_sync()(so the next step’s_update_states()can freely mutateInputBatchwithout corrupting the current step’s output mapping), runs_sample(), and calls_generate_model_runner_output(). Note: there is no race condition here —execute_model()andsample_tokens()are dispatched sequentially on the same worker process, so the next step’s_update_states()is guaranteed not to run before the current step’s_bookkeeping_sync()completes._generate_model_runner_output()stores the device-side sampled token future intoasync_execution_buffer["futures_sampled_token_ids"]and wraps the output in anAsyncNeuronModelRunnerOutput.The
AsyncNeuronModelRunnerOutputis returned to the engine core. In the multiproc executor,handle_output()puts it on theasync_output_queue. A separateasync_output_busy_loopthread picks it up and callsget_output(), which blocks on.to("cpu")and writes the sampled tokens intotoken_ids_cpuandreq_state.output_token_idsvia_update_batch_state_with_samples(). This materialization runs concurrently with the engine core scheduling Step N+1.With async scheduling, the engine core uses
step_with_batch_queue()with a batch queue of size 2 (max_concurrent_batches). The engine core is always one step ahead: by the time Step N’sget_output()materializes, Step N+1 has already been scheduled and enqueued. Once Step N’s output is processed, the engine core schedules Step N+2 and then blocks waiting for Step N+1’s output to materialize.
Step N+1 (consumes futures):
execute_model()runs_update_states()and_prepare_model_input()._prepare_model_input()buildsinput_idsfromtoken_ids_cpuas usual — but these are stale (they don’t contain Step N’s sampled tokens, which are still on device).execute_model()checks whether the async swap is eligible: the buffer is populated, this is a decode step, and the batch composition did not change.It reads
async_execution_buffer["futures_sampled_token_ids"]— the device tensor future from Step N — and checks that its shape and dtype match the current padded decode input.Since the batch hasn’t changed, the shape and dtype match. If the request order is unchanged, the stale CPU-built
input_idsis replaced with the device-side future.If the request set is the same but
condense()changed slot order, the future is remapped into the current request order before it is used. This produces correctinput_ids, but the clone/remap forces the future on the worker main thread, so this step is counted as sync fallback rather than steady-state async.The future (still on device) is passed directly to
model(**kwargs)— no device→host→device roundtrip.The cycle repeats: new sampled token futures stay on device, get stored in the buffer, and feed into Step N+2.
Where the Overlap Happens#
While the device is executing Step N’s forward pass, the CPU is free to run
the scheduler for Step N+1, call _update_states(), and prepare inputs.
The CPU-built input_ids will be thrown away in favor of the device future,
but the scheduling and state bookkeeping work is done in parallel with the
device computation.
Steady-state async (batch unchanged):
Step N Step N+1
─────────────────────────── ───────────────────────────
Worker ┃ execute_model(N) │ sample(N) │ │ execute_model(N+1) │ sample(N+1) │
main ┃ (input_ids = │ │ │ (input_ids = │ │
thread: ┃ future(N-1)) │ │ │ future(N)) │ │
┃ │ │ │ │ │ │ │
Device: ┃ ══ forward(N) ═══════════════╪════╪══ forward(N+1) ══════════════════ ...
┃ waits for ◄───────┐ │ │ waits for ◄───────┐
┃ future(N-1) │ │ │ future(N) │
┃ │ │ │ │
┃ │ │ │ │
Worker ┃ get_output(N-1) │ │ │ get_output(N) │
async ┃ waits for ◄───────┘ │ │ waits for ◄───────┘
thread: ┃ future(N-1) │ │ future(N)
┃ .to("cpu") + update_batch │ │ .to("cpu") + update_batch
┃ │ │
Engine ┃ ◄── block ──► update(N-1) │ │ ◄── block ──► update(N)
core: ┃ schedule(N+1) │ schedule(N+2)
Both the device (NEFF execution) and the async thread (.to("cpu"))
wait on the same future — the sampled token future from the previous step.
The async_execution_buffer#
This dict is the bridge between consecutive async steps. In steady-state,
only futures_sampled_token_ids is consumed:
Key |
Value |
Purpose |
|---|---|---|
|
Device tensor future |
Sampled token future from the previous step, fed as |
|
Ordered |
Request order that produced the future. Its set is compared against the next step’s scheduled requests to detect composition changes; its ordering is used to remap same-set reordered futures. |
The buffer also stores async_output (AsyncNeuronModelRunnerOutput), but
this is only accessed when breaking the async flow — see
How Do We Break the Async Flow Safely?.
What Does “Breaking the Async Flow” Mean?#
“Breaking the async flow” means forcing the materialization of a previous step’s device tensor future on the worker main thread (the critical path), rather than letting it be consumed only by the async output thread and the next NEFF execution.
Why Does It Happen?#
Consider the steady-state async flow: step N is a decode step, and step N+1
has already been scheduled ahead of time (batch queue depth = 2). During step
N, one or more requests complete (EOS or max output tokens). We only discover
this after step N’s output is materialized — when the engine core’s
scheduler.update_from_output(N) checks stop conditions and marks the
request as finished. At that point, step N+1 is already in flight on the
worker.
After processing step N’s output, the engine core schedules step N+2. This
time, the SchedulerOutput reflects the updated state: the finished request
is gone, and the scheduler may have added a new prefill, filled the decode
batch to max batch size, or simply continued decode with fewer requests. The
batch composition has changed.
Why Can’t We Stay Async?#
In steady-state, the worker discards the CPU-built input_ids and replaces
them with the device future from the previous step. For step N+2, we cannot
always do that:
If the request set changed, the scheduler’s
input_idsmust be respected. Step N+2’sinput_idscome from the scheduler’s updated view of the world (with the finished request removed, possibly a new prefill added, etc.). We must use them as-is, not replace them with a stale device future.If the request set is unchanged but the padded input shape or dtype differs, the future cannot be passed to the next NEFF input. We must materialize the pending output, rebuild CPU inputs from refreshed state, and use those rebuilt inputs.
If the request set is unchanged but the request order changed, the future has the right shape but wrong slot ownership. We remap it before use; this keeps inputs correct but forces the future, so it is not counted as a steady-state async step.
For composition changes and shape/dtype mismatches, “breaking” means:
materialize step N+1’s future on the main thread (get_output()), update all
batch state, rebuild input_ids from the now-correct CPU state, and proceed
synchronously for this step. For same-set order changes, the worker remaps the
future into the current order; this does not rebuild CPU inputs, but it still
forces the future on the main thread and is accounted as sync fallback. The
async flow can resume once the batch stabilizes again.
How Do We Break the Async Flow Safely?#
Breaking the async flow happens on the worker main thread, on the critical path of step N+2’s execution. After breaking, the async flow is automatically reinstated at the end of the same step.
Note: the scheduler always schedules one step ahead — it is unaware of the
async/sync distinction. It is the worker (specifically NeuronModelRunner)
that detects when the async flow must be broken and is responsible for
materializing futures, adjusting batch state, and reinstating the async
pipeline.
Pre-_update_states Guard in execute_model()#
This is the first check, before any state mutation happens for step N+2.
# In execute_model(), before _update_states():
if self.use_async_scheduling:
if not self.async_execution_buffer:
self._batch_composition_changed = False
else:
curr_req_ids = frozenset(scheduler_output.num_scheduled_tokens.keys())
prev_req_ids_ordered = self.async_execution_buffer.get(
"prev_req_ids_ordered"
)
prev_req_ids = (
frozenset(prev_req_ids_ordered)
if prev_req_ids_ordered is not None
else None
)
composition_changed = (
prev_req_ids is None or curr_req_ids != prev_req_ids
)
if composition_changed:
self._materialize_pending_async_output("batch composition changed")
self._batch_composition_changed = True
else:
self._batch_composition_changed = False
What it does:
Detects batch composition changes by comparing the current step’s scheduled request IDs (
curr_req_ids) against the previous step’s (prev_req_ids, derived fromprev_req_ids_ordered, which is stored in the buffer when the future was produced). If the sets differ for ANY reason — requests finished, new requests joined, requests became unscheduled — it’s a composition change. This single set comparison is simpler and more robust than checking individual signals (finished_req_ids,scheduled_new_reqs, etc.) because it catches all cases including the max_tokens pipelining gap where a request disappears fromnum_scheduled_tokensone step before appearing infinished_req_ids.If composition changed, it calls
get_output()on the previous step’sAsyncNeuronModelRunnerOutput, which:Blocks until the device tensor future is ready
Copies sampled tokens to CPU via
.to("cpu")Writes them into
token_ids_cpuandreq_state.output_token_idsvia_update_batch_state_with_samples()
Sets
_batch_composition_changed = Trueso that the swap logic later knows not to replaceinput_idswith the stale device future.This ensures
_update_states()(called immediately after) sees correct token data when it callsinput_batch.add_request()for resumed/new requests —add_requestcopies fromreq_state.output_token_idsintotoken_ids_cpu, so the data must be up to date.After materialization,
_update_states()and_prepare_model_input()run against correct state, soinput_idsis built correctly from CPU and does not need rebuilding.
The prev_req_ids_ordered list is stored in _generate_model_runner_output()
as list(req_ids_output_copy) — the exact order of request IDs that
produced the future. The same list supports both composition detection (via
set comparison) and same-set reorder remapping (via index lookup).
Async/Sync Swap Decision in execute_model()#
After _update_states() and _prepare_model_input() have run, the swap
logic decides whether to use the device future or the CPU-built input_ids:
# In execute_model(), after _prepare_model_input():
if self.use_async_scheduling:
if (
self.async_execution_buffer
and self._is_decode()
and not self._batch_composition_changed
):
future = self.async_execution_buffer["futures_sampled_token_ids"]
if future.shape[0] == input_ids.shape[0] and future.dtype == input_ids.dtype:
input_ids, broke_async = self._maybe_remap_async_future(
future, input_ids
)
if broke_async:
self._sync_fallback_steps += 1
else:
self._async_steps += 1
else:
# Shape/dtype mismatch: materialize pending output, rebuild inputs,
# and use refreshed CPU-built input_ids.
if self._materialize_pending_async_output(
"async future shape/dtype mismatch"
):
input_ids, positions, logits_indices, attn_metadata, spec_decode_metadata = (
self._prepare_model_input(scheduler_output)
)
self._sync_fallback_steps += 1
else:
# Broken: use CPU-built input_ids as-is.
self._sync_fallback_steps += 1
The async path is used only when all four conditions are met:
async_execution_bufferis non-empty (not the first iteration)_is_decode()returnsTrue(all requests are in decode phase — note: this must be checked after_update_states()so it reflects the current step’s batch, not the previous one)_batch_composition_changedisFalse(no requests finished/added/resumed)The future shape and dtype match
input_ids—_prepare_model_input()applies decode batch padding (e.g., 1 request padded to bucket size 4), and the future must match this padded shape. On the first decode step after a prefill, the future has shape[num_reqs](from the prefill output) whileinput_idshas shape[padded_bucket_size], causing a mismatch and a one-step sync fallback. The fallback first materializes the pending async output, then rebuilds model inputs from refreshed CPU state. This prevents stale CPUinput_idsfrom being used after the latest sampled token still lived only in a device future. From the second decode step onward, the model output (and thus the future) already has the padded shape, so the shapes match and async resumes.If the request set is unchanged but the current batch order differs from
prev_req_ids_ordered,_maybe_remap_async_future()remaps real request slots into the current order and leaves padding slots unchanged. This keeps token ownership correct aftercondense()reorder. Because remap usesclone()/gather and forces the future on the main thread, the step is counted as_sync_fallback_steps, not_async_steps.
When any condition fails, input_ids from _prepare_model_input() is used
as-is — it was already built from correct CPU state (either because the
pre-_update_states guard materialized, because the shape/dtype mismatch
path materialized and rebuilt it, or because this is a prefill/first iteration
where no stale futures exist).
Reinstatement#
The async flow is automatically reinstated at the end of every step, regardless of whether it was broken:
_execute_model_forward()always skips.to("cpu")whenuse_async_schedulingis True — the model output stays on device as a future._generate_model_runner_output()always storessampler_output.sampled_token_ids(the new device future) intoasync_execution_buffer["futures_sampled_token_ids"].sample_tokens()always wraps the output inAsyncNeuronModelRunnerOutputand stores it inasync_execution_buffer["async_output"].
So even after a sync fallback step, the buffer is repopulated with fresh futures. The next step will use the async path if it’s a decode step with no composition change and matching shapes, and the async flow resumes.
Development and Debugging#
get_output() Call Patterns Across TP Ranks#
Only the output rank (TP0) has its AsyncNeuronModelRunnerOutput processed
by the async output thread. This is because the multiproc executor’s
worker_busy_loop only calls handle_output(output) when
self.rank == output_rank — non-output ranks discard their output.
This means:
TP0:
get_output()is called on every step by the async output thread (viaenqueue_output). Additionally, the worker main thread calls it when breaking the async flow. With the threading lock, only one thread materializes; the other hits the “already actualized” else branch.TP1–TP7:
get_output()is only called by the worker main thread when breaking the async flow (the async output thread never sees their objects). In steady-state, theirAsyncNeuronModelRunnerOutputobjects are created and stored in the buffer but never materialized viaget_output()— they are simply overwritten by the next step’s output.
This document is relevant for: Trn2, Trn3