The GPU executes each training step, but the CPU prepares and submits nearly all of that work. A CUDA kernel launch normally returns without waiting for the kernel to finish, allowing the CPU to submit later operations while the GPU executes earlier ones (NVIDIA 2026f; PyTorch Contributors 2026b). This asynchronous execution is essential for high utilization: the GPU should find its next operation already waiting when the current operation finishes. The GPU can run out of queued work if the CPU submits operations too slowly or blocks while waiting for a device-produced value (Figure 29). Slow submission calls for less host work or more device work per launch. A device-to-host wait instead calls for an interface that keeps device results off the host’s steady-state path; initialization, compilation and warmup, and buffer allocation may still synchronize.
Figure 29Interactive · conceptual, no measured quantities · figure time units
Two ways the GPU runs out of queued work
- CPU submission
- GPU execution
- GPU idle
- CPU blocked
- Drag, tap or use ← → to move the cursor
CPU at the cursor
submitting b4
GPU at the cursor
idle: waiting for b4’s submission
Queued blocks
0
Per block: one submission every 1.55 vs execute 1.00 (figure units)
If Tsubmit ≥ Tdevice, the GPU eventually reaches the end of the submitted queue and waits for the host.
Section 8.1
Panel (a) follows the figure’s proportions: a block is submitted every 1.55 figure units, each GPU block takes 1.00, and b1 starts after the lead already queued. The slider re-times the same blocks with one rule: a block starts when its submission is complete and the GPU is free. Panel (b) is drawn as in the report; its numbered steps are the phrases of caption (b).
Original report figure
MoE training is especially sensitive to both problems. Routing introduces many short metadata operations around the expert GEMMs, and the number of tokens assigned to each rank and expert is data dependent. A direct implementation may therefore combine small GPU work units with host-visible counts, variable allocations, and variable communication schedules. These costs are part of the systems tax introduced in Section 1.3; they are not counted by the model-FLOP estimate.
In this section, synchronization-free has a narrow meaning: the steady-state training path contains no avoidable host-blocking synchronization for routing, allocation, communication, or expert scheduling. It does not mean that execution has no ordering. CUDA stream dependencies, collective completion, autograd dependencies, and optimizer-step boundaries are still required. In the MoE path examined here, the all-to-all and grouped GEMM required host-visible counts, which cost one host wait per routed block. Section 8.5 describes how Olmo removes that wait without introducing a new one.
8.1 The CPU Must Stay Ahead of the GPU
Consider a repeated region such as one transformer block. Let \(T_{\mathrm{submit}}\) be the CPU time required to submit the region and \(T_{\mathrm{device}}\) be the time required by its GPU work. If \(T_{\mathrm{submit}} < T_{\mathrm{device}}\), the CPU can usually move ahead while the GPU executes. If \(T_{\mathrm{submit}} \geq T_{\mathrm{device}}\), the GPU eventually reaches the end of the submitted queue and waits for the host. Dependencies and concurrent streams make this comparison a diagnostic, not a complete performance model, but it identifies the relevant imbalance.
We call the amount of submitted but not-yet-completed GPU work the submission headroom. Headroom is a function of wall-clock time: it jumps up by a kernel’s device duration when the CPU completes that kernel’s submission, drains at unit rate while the GPU executes, and the GPU is idle exactly while it is zero. Large GEMMs, scaled dot-product attention, fused kernels, compiled regions, and C++ execution can build headroom because they submit substantial device work with relatively little host work. Python call stacks, dispatcher and compilation-cache lookups, metadata bookkeeping, and many short launches consume it. Submission headroom is an interpretation of a CPU/GPU timeline, not a CUDA metric: the CPU’s lead over GPU execution shrinks to zero when the device runs out of queued work. Figure 30a draws headroom as this quantity, and Table 12 summarizes which common operations build or consume it. Figure 31 then contrasts a dense block sequence that sustains that lead with a routed one in which a per-block synchronization point removes it.
Backward passes usually rebuild headroom. A layer’s backward launches roughly twice the GPU work of its forward—the input-gradient and weight-gradient GEMMs (Dgrad and Wgrad, Section 9)—while the autograd engine submits that work with less per-launch overhead than the Python-driven forward. The lead earned during one microbatch’s backward then carries into the next microbatch: the first forward blocks can still run with positive headroom, while each submission-bound block spends part of the lead and leaves less for the blocks behind it. Figure 30b shows this drain-and-rebuild cycle within one microbatch.
Figure 30Interactive · conceptual, curves computed from the drawn boxes · figure time units
Scrub the timeline: headroom is the work already waiting on the GPU
- CPU submission (full host cost)
- GPU kernel
- GPU idle
- Headroom H(t)
- Drag, tap or use ← → to step through events
CPU at the cursor
submitting k7 (host-heavy)
GPU at the cursor
idle: k7 not yet submitted
Headroom at the cursor = queued, not-yet-executed device work
0.00 figure units
k4–k7: submit 2.00 vs execute 0.70
after the lead reaches zero the GPU waits for k7’s submission
caption (a)
The curve applies the caption’s rule to the report’s boxes. The slider changes one submission cost and re-runs the same rule (a kernel starts when it is submitted and the GPU is free); the report’s value is marked.
Original report figure
| Operations | Effect | Why |
|---|---|---|
| Large dense and grouped GEMMs | Builds | Much device work per launch |
| Scaled dot-product attention | Builds | Same |
| Fused kernels and compiled regions | Builds | Many operations submitted as one launch |
| Autograd-engine backward | Builds | Roughly twice forward’s device work, submitted from C++ with low per-launch overhead |
| Python call stacks and dispatcher overhead | Consumes | Host time passes while little or no device work is queued |
| Compilation-cache lookups and metadata bookkeeping | Consumes | Same |
| Routing metadata and other short kernels | Consumes | Little device work per launch |
| Device-to-host synchronization | Stops submission | The host blocks until the device produces the value; the queue drains and the lead must be rebuilt (Section 8.4) |
The distance between an API launch on the CPU and the corresponding GPU start is useful, but it must be interpreted with stream dependencies. A long distance can mean that useful work is already queued, or that the operation is waiting on another stream. The decisive observation is whether the GPU has runnable work when the preceding operation completes. GPU utilization and power are too coarse to establish this cause by themselves.
Figure 31Interactive · conceptual, order only · no measured duration
Scrub both timelines: how far ahead can the CPU get?
- CPU submits a block
- GPU executes it
- Sync wait (red dot: start; black dot: end, at the GPU’s sync point)
- Blocks ahead, dense
- Blocks ahead, MoE
- Drag, tap or use ← → to step
Dense model
CPU on a later block · GPU on Block 1
CPU ahead by 3 blocks
MoE model
CPU waits at Block 2’s sync point until the GPU reaches it
CPU ahead by 1 block
Why it matters
Submission headroom cannot accumulate beyond one block, so the two processors advance in lock step and any GPU stall is exposed.
Figure caption
Boxes and dots are placed as in the report figure. “Blocks ahead” counts how many blocks the CPU’s current block is ahead of the GPU’s: the dense CPU runs several blocks ahead (three in the drawn span, counting the “…” block), the MoE CPU never more than one.
Original report figure
Captured profiles of the eight-layer development model show both timeline shapes, one per expert-parallel path (Figure 74 in Appendix B.5). In the synchronized trace, a host wait inside every routed block keeps the CPU and GPU spans of each forward block aligned, and the kernel rows show idle gaps at the block boundaries. In the rowwise trace, the CPU submits an entire microbatch while the GPU is still executing earlier work, and the GPU then executes the submitted blocks back to back.
8.2 Small Work Units Expose Submission Overhead
For a fixed operator sequence, a simple host-time approximation is
\[ T_{\mathrm{submit}} \approx N_{\mathrm{launch}}\,t_{\mathrm{launch}} + T_{\mathrm{framework}} + T_{\mathrm{bookkeeping}}, \tag{11} \]
whereas the GPU time of its GEMMs and attention generally grows with the number of tokens. Reducing the microbatch can therefore shorten device execution without reducing host work in proportion. This is one reason that a kernel-level speedup may disappear when the same kernels are invoked through an eager training stack.
Routed blocks add host submission work to the transformer operations shared with a dense model: each block constructs routing metadata, counts expert assignments, attaches router losses, permutes or maps rows, launches expert communication, and schedules grouped GEMMs. Expert parallelism can also divide the local tokens among many experts, making each GEMM shorter. The resulting sequence of short GPU operations leaves less time for the CPU to prepare the next group of launches, even when each operation is cheap in isolation.
The same reasoning applies to very fast normalization, elementwise, and reduction kernels. Their device time can be shorter than the framework and launch path around them. The appropriate remedies are then larger work units, fusion, compilation, or a lower-overhead C++ interface. These remedies reduce ordinary submission cost; they do not remove an explicit device-to-host wait. The balance between submission and execution sets the microbatch-size crossover discussed next.
8.3 Critical Microbatch Size
This host–device balance defines a systems critical microbatch size. It is unrelated to the optimization-theoretic critical batch size discussed in Section 16. Let \(b\) denote the rank-local amount of work in one microbatch—for example, the number of sequences at fixed sequence length—and compare the CPU submission time \(T_{\mathrm{submit}}(b)\) with the corresponding GPU execution time \(T_{\mathrm{device}}(b)\). The crossover occurs near
\[ T_{\mathrm{device}}(b_{\mathrm{crit}}) \approx T_{\mathrm{submit}}(b_{\mathrm{crit}}). \tag{12} \]
Below this point, the GPU completes the submitted work faster than the CPU can prepare and launch the next work, so the GPU can run out of queued operations and become idle. At or above the crossover, each submitted region contains enough device work for the CPU to stay ahead. This threshold is not a model constant: it depends on the operator mix, fusion and compilation boundaries, software stack, CPU, GPU, and sequence length.
The crossover must be identified from the CPU/GPU profile of the selected configuration. Figure 32 illustrates how it can move; it does not estimate a critical size for a production run.
Figure 32Interactive · conceptual, qualitative schematic · no measured values
Move the microbatch: where does each GPU stop running dry?
Each marked crossover is where device time meets submission time, the condition of Equation 12. Curves are the schematic functions of the report’s plotting script (H100, B200 and B300 execution time; CPU submission time, eager and with CUDA Graph replay as one way to reduce overhead), in the script’s colours. The H100 and B200 curve ends mark the drawn VRAM limits.
Original report figure
This trend matters as accelerators improve. Moving from Hopper to Blackwell can shorten the device side of the same operator sequence without reducing Python, dispatcher, routing-bookkeeping, or launch work by the same factor. The crossover can therefore shift to a larger microbatch. A configuration that kept an H100 continuously supplied may become host-submission limited on a faster GPU even though every individual GPU kernel improved.
Memory-saving techniques can keep the GPU supplied by making a larger microbatch feasible: lower-precision activation storage and activation recomputation reduce the per-token memory cost, as discussed in Sections 11 and 13. Cheaper submission instead moves \(b_{\mathrm{crit}}\) to the left: fusion, compiled regions, and C++ execution reduce repeated host work in the production path, and CUDA Graph replay provides a low-host-overhead reference (NVIDIA 2026g; PyTorch Contributors 2026b). Larger microbatches consume more activation memory, while static PyTorch capture/replay imposes address, shape, and buffer-retention constraints; their value depends on whether CPU submission or GPU execution limits the measured step at the selected microbatch size.
8.4 Device-to-Host Synchronization Drains the Queue
A device-to-host (D2H) synchronization occurs when host execution cannot continue until a value computed on the GPU is available to the CPU. The CPU stops submitting later work while the GPU completes all dependencies needed to produce that value. The wait consumes submission headroom, but the GPU becomes idle only if it outlasts useful independent work that was already submitted. After the value arrives, the host must rebuild its lead over the device.
Block all-to-all EP (Section 5.3) provides a concrete example. Routing produces the number of tokens sent to each destination on the GPU. In Olmo-core’s synchronized path, ranks exchange token counts on the GPU, then copy the send, receive, and local-expert counts to CPU memory and wait. The send and receive counts become the Python split lists used to launch the data exchange. A fully dynamic receive buffer has the same problem when its allocation shape is derived from device counts. Some legacy grouped-GEMM backends1 likewise require host-visible expert sizes, although that dependency can occur without EP. The simplified pseudocode shows one host copy and wait serving both host dependencies:
peer_sizes_gpu = count_routes_per_peer(routes)
expert_sizes_gpu = count_routes_per_expert(routes)
peer_sizes, expert_sizes = copy_to_cpu_and_wait( # host sync
peer_sizes_gpu, expert_sizes_gpu)
# All-to-all requires host-side split sizes.
all_to_all(x, split_sizes=peer_sizes)
# Legacy grouped GEMM also requires host-side expert sizes.
grouped_gemm(x, expert_sizes)
Both dependencies arise when an interface turns device-produced counts into host inputs. A receive buffer allocated from the current route count creates the same dependency even if the transfer itself accepts device metadata. Section 8.5 follows the changes to communication, allocation, and grouped-GEMM scheduling that keep these counts on the GPU.
Figure 33Interactive · conceptual, order only · no measured duration
Where the all-to-all waits: follow the route counts
- CPU launch
- GPU work
- CPU waits
- GPU idle
- Drag, tap or use ← → to step through events
CPU at the cursor
submitting All-To-All
GPU at the cursor
idle: nothing runnable is queued
All-to-all
launched only after the counts are host-visible
If the queue drains at this boundary, the GPU becomes idle until the host receives the counts and submits the all-to-all.
Figure caption
Boxes are placed as in the report figure (draw.io geometry). The “device route map” view is a conceptual sketch of the rowwise path in Section 8.5, not a report figure: counts, offsets and the dispatch input stay CUDA tensors, and the count exchange stays on the GPU. Required device ordering remains in both views.
Original report figure
Independent work submitted before the host wait can occupy some or all of this interval. In Figure 34, shared-expert compute runs while the CPU waits for the route counts. This scheduling hides the GPU bubble but does not remove the synchronization or allow the all-to-all to launch before its split sizes reach the host.
Figure 34Interactive · conceptual, order only · no measured duration
Cover the wait, not remove it: slide independent work into the bubble
- CPU launch
- GPU work
- CPU waits
- GPU idle
- Drag, tap or use ← → to step through events
CPU at the cursor
submitting All-To-All
GPU at the cursor
executing compute to overlap (shared expert)
All-to-all
launched only after the counts are host-visible
Covered, as drawn in the report: the GPU remains busy while the host waits
Figure caption
This scheduling hides the GPU bubble but does not remove the synchronization or allow the all-to-all to launch before its split sizes reach the host.
Section 8.4
Slide to zero to recover Figure 33; the report figure’s shared-expert work is marked. Above zero only the GPU lane changes; at zero the CPU lane returns to Figure 33’s (no shared-expert launch before the wait). Gaps narrower than the report’s own gaps between boxes count as busy, as in the report’s GPU BUSY bar.
Original report figure
Dynamic routing itself does not require a host synchronization. The synchronization is introduced by an interface that asks the CPU to interpret a device result. Table 13 separates definite host-visible operations from operations that are often blamed incorrectly.
| Pattern | Host wait | Reason |
|---|---|---|
x.item() |
Yes | Materializes a CUDA scalar as a Python value. |
x.cpu() followed by host use |
Yes | The host-visible copy must wait for the required source data. |
torch.nonzero(x) on CUDA |
Yes | PyTorch documents a host–device synchronization for this dynamic-output operator. |
torch.empty([int(x)]) |
Yes, before allocation | The conversion of the CUDA scalar to a Python integer synchronizes; empty itself is not the cause. |
| Device indexing, gather, or scatter | Not inherently | These remain GPU operations when indices, shapes, and results stay on the device. |
PyTorch explicitly documents the synchronization behavior of nonzero() on CUDA (PyTorch Contributors 2026k). Scalar extraction and host-visible copies have the same fundamental dependency: Python cannot use a value that the device has not produced. This does not imply that every dynamic operator or tensor allocation synchronizes. Whether a hot-path call blocks depends on where its shape and control data reside.
CUDA stream events illustrate the distinction between required device ordering and host blocking. A stream can wait on an event while the CPU continues to submit independent work. Calling a host synchronization routine waits on the CPU and therefore changes the submission path. The goal is to retain necessary device ordering without making the host participate in data-dependent scheduling.
8.5 How to Become Synchronization-Free
Device-resident route metadata and device-side grouped-GEMM offsets remove the host-visible counts that the all-to-all and grouped GEMM required, and with them the per-block host wait, from Olmo’s rowwise MoE path. Capacity-shaped storage prevents route counts from becoming host allocation decisions. The selected routes remain dynamic; buffer capacities are fixed before the training loop.
The rowwise mechanics are those of Sections 5.4 and 5.7; this subsection isolates their host-facing consequence. Table 14 lists the host dependency removed by each mechanism and its associated cost. The first two rows remove the all-to-all dependency; the third removes the grouped-GEMM dependency. Each removal has a focused illustration: Figures 15 and 16 contrast the synchronized block all-to-all with the rowwise path whose route metadata never leaves the GPU, and Figures 36 and 37 contrast the host-loop grouped GEMM with the device-scheduled interface that consumes offsets where they are produced.
| Mechanism | Host dependency removed | Cost accepted |
|---|---|---|
| Device-resident route metadata: route maps, counts, and offsets stay CUDA tensors | Python split lists interpreted per microbatch | Metadata kernels in every routed layer |
| Capacity-shaped buffers with a valid-row prefix | Data-dependent allocation shapes | Reserved capacity memory; possible route drops |
| Device-side cumulative offsets for grouped GEMM | item() per expert count and a host GEMM loop |
Backend must accept device offsets |
Dynamic Routes, Fixed Buffer Capacities
Keep route counts dynamic on the GPU; fix buffer capacities before the training loop.
Device-resident route metadata.
The router produces route maps, destination ranks and rows, per-rank counts, and expert-group offsets as CUDA tensors. The rowwise dispatch and combine kernels consume those tensors directly (Figure 16). Olmo currently uses NVSHMEM because it supports GPU-initiated one-sided PUT and GET operations over symmetric memory (NVIDIA 2026s). The host still launches kernels and establishes the communication resources; it does not retrieve a variable split list for each microbatch. Per-rank count exchange, stream waits, and NVSHMEM barriers still provide required device ordering; their route-dependent values remain CUDA tensors and are not consumed by the host. The count exchange is one all-gather over the EP-MP group. This is the rowwise NVSHMEM path analyzed in this section.
Capacity-shaped storage.
Dispatch, combine, scale, and metadata buffers are allocated to a configured capacity. A route map identifies which rows are valid in the current microbatch. This separates a static upper bound, which the allocator needs, from the dynamic valid-row count, which remains on the device. The distinction matters because unwritten capacity rows may contain stale data; expert compute and combine therefore consume only the rows marked valid for the current microbatch.
Device-side grouped-GEMM scheduling.
Current Olmo expert compute uses a grouped-GEMM interface with a CUDA int32 cumulative-offset tensor (Figure 37). The backend, rather than a Python loop, identifies the row range assigned to each local expert. This removes the need to call item() on each expert count and also reduces the number of GEMM launches. A CPU-loop implementation would instead copy offsets to the host and serialize group launches, recreating the synchronization and launch gaps that the device-offset interface removes.
Capacity trades memory for the removed stall.
A fixed envelope can reserve more memory than the valid routes require and can drop routes if the configured capacity is exceeded. Current grouped GEMMs use device offsets and stop at the valid expert rows (Section 9), but capacity tails still occupy buffer memory and can be touched by non-GEMM elementwise work; a tail-aware SwiGLU kernel that applies the activation only to the valid prefix avoids touching the tails on forward-only paths. Other implementations may also communicate padded rows. Useful-token FLOPs must exclude those rows, while physical memory, transferred bytes, and executed kernel work must include them. Capacity overhead is therefore the cost of removing the host synchronization.
Reducing remaining submission overhead.
Fusion, compiled regions, and C++ execution reduce the number and cost of ordinary launches after dynamic metadata has been kept on the device. These techniques solve a different problem from D2H synchronization. A faster Python or C++ call stack cannot repair an API that fundamentally requires a GPU value in Python. In the current implementation, the custom symmetric-memory transport remains a deliberate C++/CUDA boundary of the compiled block; compilation reduces surrounding work but does not capture the complete rowwise path as one graph.
CUDA Graph replay could remove much of this remaining submission cost, but its stable-address and fixed-shape requirements conflict with live pipeline microbatches, and the production path is not graph captured. Section 15.6 analyzes that trade and uses replay only as a diagnostic ceiling that separates device service time from eager submission cost.
Host-wait behavior depends on the running configuration. Appendix B.6 records the verification checklist and a graph-replay microbenchmark that isolates the submission tax on short expert operations. In the captured rowwise timeline of Appendix B.5, no host wait interrupts submission for the eight-layer BF16 EP8 development model with a shared expert. This is a single-rank captured instance of the mechanism, not a guarantee for every configuration or execution phase; initialization, compilation, warmup, and buffer allocation remain outside the steady-state scope.
Capacity memory and padding remain costs of this design. Once the count-dependent wait is removed, ordinary submission overhead still determines whether the CPU can keep the GPU supplied.
In Olmo-core, the legacy grouped-GEMM fallback used before the PyTorch device-offset fast path takes host-side split sizes.↩︎
