Python is a productive orchestration language for inference serving, but it is the wrong tool for the critical path of token generation. Large-model inference is bound by memory bandwidth and by per-operation latency, and the interpreter cannot meet either constraint. So frameworks like vLLM keep the control flow in Python and push the arithmetic into compiled C++ and CUDA kernels.
That split is not free. Every call has to cross the Python/C++ boundary, and that crossing involves an Application Binary Interface (ABI), a dispatcher, and the overhead of launching work on the GPU. This walks through how vLLM crosses that boundary: how kernels are registered with PyTorch, the ABI constraints that make the boundary fragile, the hardware-level decisions inside the kernels themselves, and how vLLM amortizes the per-call overhead with CUDA graphs.
The important feature of this path is that the bulk data, the multi-gigabyte tensors, never moves. Only metadata and pointers cross the boundary; the launch is asynchronous, so the CPU returns to Python and queues the next op while the GPU is still working on the last one.
The cost of crossing the Python/C++ boundary
When a Python script invokes a PyTorch operation, very little of the work happens in Python. The call crosses into a pre-compiled C++ backend, and the crossing has a fixed cost regardless of how small the tensors are. That cost comes from several places:
- The interpreter and the GIL. CPython evaluates the call frame with the Global Interpreter Lock held. Argument parsing, reference-count bookkeeping, and object unpacking all happen single-threaded.
- Argument marshaling. Python objects must be converted into their C++ counterparts. A torch.Tensor is unwrapped into an at::Tensor, integers and floats are unboxed, and lists become c10::IntArrayRef or std::vector.
- The dispatcher. PyTorch routes every operation through a C++ dispatcher that selects a backend implementation based on the operation's dispatch key. This indirection is what makes one Python call work transparently on CPU and CUDA, but it is several function hops per op.
- The kernel launch. Handing work to the GPU through cudaLaunchKernel costs on the order of single-digit microseconds of CPU time, before the kernel does any arithmetic.
None of these is large in isolation. The problem is the multiplier. Generating one token runs a tight sequence of operations, and a decode step can issue hundreds of ops. At a few microseconds of fixed overhead each, the boundary cost alone can rival the time the GPU spends on the math, especially at small batch sizes where each kernel is short. The two structural responses are to do more work per crossing (fused kernels) and to remove the crossings entirely on the hot path (CUDA graphs).
The integration layer: torch.library and the dispatcher
vLLM implements operations that PyTorch does not ship natively: PagedAttention, AWQ/GPTQ dequantization, fused RoPE, fused RMSNorm, and others. It has to make them first-class citizens of the dispatcher so they participate in autograd, torch.compile, and device routing like any built-in op.
The dispatcher is a routing table. Each operation has a schema (a typed signature), and for each dispatch key there can be a different registered implementation. Registration happens in C++ through the torch.library API:
#include <torch/library.h>
// The C++ wrapper that validates inputs and launches the CUDA kernel.
void paged_attention_v1(
torch::Tensor& out, // mutated output (marked Tensor! in the schema)
torch::Tensor const& query,
torch::Tensor const& key_cache,
torch::Tensor const& value_cache,
torch::Tensor const& block_tables,
torch::Tensor const& seq_lens,
int64_t block_size,
int64_t max_seq_len) {
TORCH_CHECK(query.is_cuda(), "query must be a CUDA tensor");
TORCH_CHECK(query.scalar_type() == at::kHalf ||
query.scalar_type() == at::kBFloat16,
"query must be fp16 or bf16");
TORCH_CHECK(out.is_contiguous(), "out must be contiguous");
// launch_paged_attention_v1(out.data_ptr<...>(), query.data_ptr<...>(), ...);
}
// Declare the schema. The `!` on `Tensor!` records that `out` is mutated.
TORCH_LIBRARY(vllm, m) {
m.def(
"paged_attention_v1(Tensor! out, Tensor query, Tensor key_cache, "
"Tensor value_cache, Tensor block_tables, Tensor seq_lens, "
"int block_size, int max_seq_len) -> ()");
}
// Bind the implementation to the CUDA dispatch key.
TORCH_LIBRARY_IMPL(vllm, CUDA, m) {
m.impl("paged_attention_v1", &paged_attention_v1);
}
Two details are worth dwelling on, because they are easy to get wrong:
- The mutation annotation. Writing the result into out in place avoids an allocation on the hot path, but it makes the op side-effecting. The schema records that with Tensor! (the ! marks an aliased, mutable argument). torch.compile functionalizes the graph, rewriting in-place mutations into a pure dataflow form so it can reorder and fuse, and it can only do this safely if the schema tells the truth about what is mutated. A missing ! here is a silent correctness bug under compilation, not a compile error.
- The Meta kernel. torch.compile traces a model with fake (meta) tensors that carry shape and dtype but no data. For a custom op to be traceable, vLLM also registers a _meta implementation that computes the output shape and dtype without running the kernel. The meta function must stay in lockstep with the real one; if it reports the wrong shape, the compiled graph is wrong in ways that only surface at runtime.
The ABI boundary itself
An ABI is the machine-level contract for how compiled code interoperates: how arguments are passed in registers or on the stack, how structs are laid out and aligned, how names are encoded into symbols, and how exceptions and destructors behave. The Python/C++ boundary in vLLM is really three nested contracts, and all three have to agree.
- Name mangling. C++ encodes parameter types into the symbol name, so paged_attention_v1(at::Tensor&, ...) becomes a long mangled symbol. The dispatcher sidesteps this: vLLM registers a function pointer into a runtime table keyed by the schema string. Python looks the operation up by name at runtime, so the mangled C++ symbol never has to be part of any stable interface.
- The libstdc++ dual ABI. This is the classic vLLM/PyTorch build failure. Since GCC 5.1, libstdc++ ships two incompatible implementations of std::string and std::list, selected by the _GLIBCXX_USE_CXX11_ABI macro. An extension compiled with the wrong setting either fails to link or, worse, links and then corrupts data when a std::string-bearing structure crosses the boundary with two different layouts on each side. This is why torch.utils.cpp_extension reads torch._C._GLIBCXX_USE_CXX11_ABI and forces the extension to match whatever the installed PyTorch used.
- libtorch version coupling. An at::Tensor is not a value; it is a reference-counted handle (c10::intrusive_ptr<TensorImpl>). Passing one across a shared-library boundary requires both sides to agree on the exact layout of TensorImpl, which means the extension must be compiled against the same libtorch headers as the runtime it loads into. That coupling is why a kernel built for one PyTorch version generally cannot be loaded by another, and why vLLM ships wheels pinned to specific PyTorch releases.
The direction of travel is to make this boundary stable. PyTorch 2.10+ exposes an ABI-stable LibTorch API built around torch::stable::Tensor and a C-style shim, so an extension can target one frozen interface and survive across PyTorch versions instead of being rebuilt for each. It trades a little expressiveness for the ability to ship a single wheel, the same bargain that the dispatcher already makes for name mangling, now extended to the type layout.
Hardware-level optimizations in custom CUDA kernels
Once the boundary is crossed and the wrapper launches, the constraints change entirely. The kernel's job is to keep the SMs fed and the memory subsystem busy. A few principles drive vLLM's kernel design, illustrated with PagedAttention and the quantized-dequant kernels.
Memory coalescing and alignment. When a warp (32 threads) accesses global memory, the hardware coalesces the accesses into the fewest possible transactions when the addresses are contiguous and aligned. vLLM lays out data so reads coalesce, and uses vectorized loads to move 128 bits per thread per instruction:
// Each thread loads 8 fp16 values (128 bits) in one instruction.
// Casting to uint4 makes the compiler emit a single 16-byte vectorized load.
const uint4* kv_vec = reinterpret_cast<const uint4*>(key_cache_ptr);
uint4 chunk = kv_vec[thread_offset]; // 1 transaction per aligned warp access
The alignment is a hard precondition, not a nicety: a uint4 load from a non-16-byte-aligned address is undefined behavior on the GPU, and even a merely uncoalesced (but legal) access splits one transaction into several, which is costly on a bandwidth-bound kernel. PagedAttention's block tables are sized and padded precisely so that each KV block starts on an aligned boundary.
Register pressure and occupancy. Registers are the fastest storage on the GPU and the scarcest. Each SM has a fixed register file shared by all resident threads, so a kernel that uses more registers per thread allows fewer thread blocks to be resident at once, which lowers occupancy and the hardware's ability to hide memory latency by switching warps. vLLM kernels use #pragma unroll deliberately rather than reflexively, and keep variable lifetimes short, because unrolling that inflates the live register set can reduce occupancy enough to erase the gain. Block dimensions are chosen to balance registers against shared-memory use for a target architecture such as Ampere or Hopper.
The Python/C++ boundary is a toll booth on every op. The toll is small, but a decode step passes through hundreds of booths. Fused kernels are carpooling, and CUDA graphs are a season pass: capture once, ride forever.
Amortizing the overhead: CUDA graphs
CUDA graphs capture a sequence of kernel launches and replay them without CPU involvement. The graph is captured once (the first time the model runs), and every subsequent decode step replays it. This removes the per-call crossing cost entirely from the hot path: no dispatcher, no marshaling, no launch overhead. The CPU hands the GPU a graph handle and the GPU replays the whole sequence.
The tradeoff is that the graph is fixed: the sequence of kernels, their shapes, and their memory addresses are frozen at capture time. This is why vLLM's graph capture happens per shape and why dynamic shapes break graph replay. It is also why the op IR and the dispatcher matter: they give the engine the flexibility to choose what goes into the graph and what stays dynamic.
The Python/C++ boundary is where inference serving's two worlds meet: orchestration and arithmetic. Every microsecond of crossing cost is a microsecond the GPU is not working.