main
md 615 lines 21.8 KB
Rendered Raw
1 # vLLM IR: Functional Intermediate Representation
2
3 ## Motivation
4
5 vLLM IR is a **functional intermediate representation (IR)** that fills the gap between
6 low-level `torch` ops and vLLM layers like `RMSNorm` and quantization operators,
7 By separating operator **semantics** from the **implementation** and **dispatching**,
8 vLLM IR simplifies both compilation and kernel registration & dispatching simultaneously.
9 It operates as a **dialect** in the torch FX representation, allowing full interoperability
10 with “regular” torch ops & custom torch ops/kernels, as well as a piecewise migration from
11 the previous `CustomOp` approach.
12
13 Key design principles:
14
15 - **Eager-compile consistency**: identical behavior (barring minor numerics) in eager and compiled modes
16 - **Simple, transparent, yet powerful kernel selection**: good visibility and control allowing easy debugging
17 - **Convention over configuration**: near-zero boilerplate required to register ops and implementations
18 - **Extensibility**: ops and implementations can be registered anywhere, in-tree or out-of-tree
19 - **Interoperability**: fully compatible with “regular” torch ops & custom torch ops/kernels,
20 reducing developer friction and allowing piecewise migration
21
22 The clean semantics/implementation separation enables a unified and extensible dispatching mechanism,
23 allowing multiple kernels per-platform and powerful kernel selection. The separation also facilitates
24 cleaner testing and benchmarking, removing much of the boilerplate standard for legacy approaches.
25
26 By delaying kernel selection until late in the compilation process, the compiler can operate on
27 a higher-level representation, which has the following main benefits:
28
29 - Pattern matching in fusion/transformation passes only requires a single, simple pattern per op
30 - OOT compiler backends can lower from the higher-level representation (in-progress)
31 - The compiler can autotune over available implementations (future feature)
32
33 ## Quick Overview
34
35 ### Declaring an IR Operation
36
37 IR operations are declared using the `@register_op` decorator with a native PyTorch implementation that defines the op's semantics:
38
39 ```python
40 # vllm/ir/ops/layernorm.py
41 from torch import Tensor
42 from vllm.ir import register_op
43
44 @register_op
45 def rms_norm(x: Tensor, weight: Tensor | None, epsilon: float, variance_size: int | None = None) -> Tensor:
46 """Weighted root-mean-square layer normalization"""
47 orig_dtype = x.dtype
48 x = x.to(torch.float32)
49 x_var = x if variance_size is None else x[..., :variance_size]
50 variance = x_var.pow(2).mean(dim=-1, keepdim=True)
51 x = x * torch.rsqrt(variance + epsilon)
52 x = x.to(orig_dtype)
53 if weight is not None:
54 x = x * weight
55 return x
56 ```
57
58 The native implementation serves three purposes:
59
60 1. **Semantic definition**: Specifies the exact semantics of the operation, including shapes and strides
61 2. **Default implementation**: Used when no other (better) implementation is available
62 3. **Reference for testing**: Other implementations must match these semantics
63
64 ### Registering Implementations
65
66 Kernel implementations are registered using the `register_impl` decorator on the IR op object:
67
68 ```python
69 # vllm/kernels/vllm_c.py
70 from vllm import ir
71
72 rms_norm_no_var = lambda x, weight, epsilon, variance_size=None: variance_size is None
73
74 @ir.ops.rms_norm.register_impl("vllm_c", supports_args=rms_norm_no_var, supported=current_platform.is_cuda_alike())
75 def rms_norm(x: Tensor, weight: Tensor | None, epsilon: float, variance_size: int | None = None) -> Tensor:
76 output = torch.empty_like(x)
77 torch.ops._C.rms_norm(output, x, weight, epsilon)
78 return output
79 ```
80
81 Implementations can specify:
82
83 - `supported`: Static boolean indicating if this implementation is available
84 - `supports_args`: Function checking if the implementation supports specific arguments
85 - `inplace`: Whether this implementation reuses input memory for outputs
86
87 ### Using IR Operations in Models
88
89 IR operations are imported and called directly in model code:
90
91 ```python
92 # vllm/model_executor/layers/layernorm.py
93 from vllm import ir
94
95 class RMSNorm(nn.Module):
96 def __init__(self, hidden_size: int, eps: float = 1e-6):
97 super().__init__()
98 self.weight = nn.Parameter(torch.ones(hidden_size))
99 self.variance_epsilon = eps
100
101 def forward(self, x: Tensor, residual: Tensor | None = None):
102 if residual is None:
103 return ir.ops.rms_norm(x, self.weight, self.variance_epsilon)
104
105 # Use maybe_inplace overload to allow implementation to reuse input memory for outputs
106 # (using x or residual after this call is undefined behavior)
107 return ir.ops.fused_add_rms_norm.maybe_inplace(
108 x, residual, self.weight, self.variance_epsilon
109 )
110 ```
111
112 ### Configuring Kernel Selection
113
114 Kernel selection is controlled via priority lists in the configuration.
115 Priority lists specify the order in which implementations are considered,
116 with the first supported implementation being selected.
117 This includes the static support check (`supported=...`) and
118 the dynamic arg support check (`supports_args=...`).
119
120 #### Command Line Configuration
121
122 Use `--ir-op-priority.<op_name>=<provider1>,<provider2>,...`:
123
124 ```bash
125 # CUDA: Use vllm_c implementation for rms_norm
126 vllm serve meta-llama/Llama-3.2-1B \
127 --ir-op-priority.rms_norm=vllm_c
128
129 # ROCm: Try aiter first, fall back to vllm_c, then native
130 vllm serve meta-llama/Llama-3.2-1B \
131 --ir-op-priority.rms_norm=aiter,vllm_c,native
132
133 # Configure multiple operations
134 vllm serve meta-llama/Llama-3.2-1B \
135 --ir-op-priority.rms_norm=vllm_c \
136 --ir-op-priority.fused_add_rms_norm=vllm_c
137 ```
138
139 #### Python Configuration
140
141 ```python
142 from vllm import LLM
143 from vllm.config import VllmConfig, KernelConfig
144
145 llm = LLM(
146 model="meta-llama/Llama-3.2-1B",
147 vllm_config=VllmConfig(
148 kernel_config=KernelConfig(
149 ir_op_priority={
150 "rms_norm": ["vllm_c", "native"],
151 "fused_add_rms_norm": ["vllm_c", "native"],
152 }
153 )
154 )
155 )
156 ```
157
158 #### Platform Defaults
159
160 Each platform provides default priority lists that are automatically applied:
161
162 ```python
163 # CUDA/XPU/ROCm platform defaults (when compiling with Inductor)
164 {
165 "rms_norm": ["native"], # Native torch is default
166 "fused_add_rms_norm": ["native"],
167 }
168
169 # CUDA platform defaults (eager or Dynamo-only)
170 {
171 "rms_norm": ["vllm_c", "native"],
172 "fused_add_rms_norm": ["vllm_c", "native"],
173 }
174
175 # ROCm platform defaults (future - currently same as CUDA)
176 {
177 "rms_norm": ["aiter", "vllm_c", "native"],
178 "fused_add_rms_norm": ["aiter", "vllm_c", "native"],
179 }
180
181 # XPU platform defaults (eager or Dynamo-only)
182 {
183 "rms_norm": ["xpu_kernels", "native"],
184 "fused_add_rms_norm": ["xpu_kernels", "native"],
185 }
186 ```
187
188 User-specified priorities are prepended to platform defaults,
189 so you only need to specify the out-of-order implementations,
190 other implementations are appended automatically.
191
192 ## Compilation Pipeline
193
194 vLLM IR heavily customizes the `torch.compile`-based compilation process to allow custom compile
195 passes to operate on high-level IR while still producing efficient low-level code at the end.
196 The compilation pipeline consists of several stages:
197
198 ### 1. Dynamo Tracing
199
200 When `torch.compile` traces the model's forward pass, vLLM IR operations appear as custom operations
201 in the `vllm_ir` torch library. These operations are opaque to Dynamo, meaning they appear directly
202 in the FX graph without decomposition:
203
204 ```python
205 # Python code (epsilon=1e-5)
206 x1 = ir.ops.rms_norm(x, weight, epsilon)
207 x2, residual_out = ir.ops.fused_add_rms_norm.maybe_inplace(x1, residual, weight, epsilon)
208
209 # FX graph after Dynamo tracing
210 x1 = torch.ops.vllm_ir.rms_norm.default(x, weight, 1e-5); x = None
211 out = torch.ops.vllm_ir.fused_add_rms_norm.maybe_inplace(x1, residual, weight, 1e-5); x1 = residual = None
212 x2 = out[0]
213 residual_out = out[1]
214 ```
215
216 ### 2. AOTAutograd and Functionalization
217
218 AOTAutograd functionalizes the graph, converting any mutating operations to functional equivalents.
219 For vLLM IR operations with `maybe_inplace` overloads, we perform this manually before AOTAutograd,
220 converting them to the functional `default` overload using the pre-grad custom pass hook.
221
222 ```python
223 # After functionalization
224 x1 = torch.ops.vllm_ir.rms_norm.default(x, weight, 1e-5); x = None
225 out = torch.ops.vllm_ir.fused_add_rms_norm.default(x1, residual, weight, 1e-5); x1 = residual = None
226 x2 = out[0]
227 residual_out = out[1]
228 ```
229
230 The pass also tracks which inputs were "donated" (passed to `maybe_inplace`),
231 storing this information in vLLM's `PassContext` for later use in clone elimination.
232
233 ### 3. IR Fusion and Transformation Passes
234
235 After functionalization, custom vLLM passes operate on the functional FX graph containing high-level IR operations.
236 These passes can perform fusion, distribute operations for sequence parallelism, and other transformations:
237
238 ```python
239 # Example: Sequence Parallelism (see SequenceParallelismPass)
240 # Before SP pass
241
242 all_reduce = torch.ops.vllm.all_reduce(x, "tp:0")
243 rms_norm = torch.ops.vllm_ir.rms_norm(all_reduce, weight, 1e-5)
244
245 # after SP pass
246 reduce_scatter = torch.ops.vllm.reduce_scatter(x, "tp:0")
247 rms_norm = torch.ops.vllm_ir.rms_norm(all_reduce, weight, 1e-5)
248 all_gather = torch.ops.vllm.all_gather(x, "tp:0")
249 ```
250
251 Fusion passes benefit from the high-level representation: they don't need to match against low-level PyTorch operations,
252 handle different kernel implementations separately, or deal with functionalization of custom kernels.
253
254 ### 4. IR Lowering
255
256 The lowering pass (`VllmIRLoweringPass`) replaces each vLLM IR operation with its selected implementation.
257 The implementation is chosen based on the priority list and support predicates,
258 using the **fake tensors** in the graph's metadata in place of op arguments:
259
260 ```python
261 # Implementation selection, same in eager dispatch and compile lowering
262 def dispatch(*args) -> IrOpImpl:
263 for provider in priority_list: # e.g., ["vllm_c", "native"]
264 impl = ir_op.impls[provider]
265 if not impl.supported:
266 continue
267 if impl.supports_args and not impl.supports_args(*args):
268 continue
269 return impl
270
271 # make_fx uses torch.fx.symbolic_trace
272 impl_graph = make_fx(selected_impl.impl_fn)
273 # Replace IR op node with impl_graph's nodes
274 match.replace_by_example(selected_impl.impl_fn, node.args)
275 ```
276
277 For example, lowering `rms_norm` with the `vllm_c` implementation:
278
279 ```python
280 # Before lowering (IR op)
281 rms_norm = torch.ops.vllm_ir.rms_norm.default(x, weight, 1e-5)
282
283 # After lowering (vllm_c implementation traced)
284 # Note: Lowering does not currently functionalize, this will likely change in the future.
285 empty = torch.ops.aten.empty.memory_format(x.shape, ...)
286 rms_norm = torch.ops._C.rms_norm(empty, x, weight, 1e-5)
287 ```
288
289 When lowering an implementation that mutates inputs (`inplace=True`),
290 the lowering pass inserts clones to preserve functional semantics:
291
292 ```python
293 # vllm_c implementation for fused_add_rms_norm mutates its first two arguments
294 # Lowered with clones for safety
295 clone_default = torch.ops.aten.clone.default(x)
296 clone_default_1 = torch.ops.aten.clone.default(residual)
297 fused_add_rms_norm = torch.ops._C.fused_add_rms_norm.default(clone_default, clone_default_1, weight, 1e-5)
298 ```
299
300 ### 5. Clone Cleanup
301
302 After lowering, the clone elimination pass (`UnsafeCloneEliminationPass`) removes unnecessary clones introduced during lowering.
303 This pass is essential for achieving zero-copy behavior when using in-place kernels with `maybe_inplace`.
304 The pass removes a clone if:
305
306 - the cloned input is created in the graph and not used again in the graph
307 - the cloned input is a graph parameter, marked as donated
308
309 ```python
310 # After cleanup (donated inputs, no subsequent uses)
311 fused_add_rms_norm = torch.ops._C.fused_add_rms_norm.default(x, residual, weight, 1e-5)
312 ```
313
314 The combination of inplace functionalization (tracking donated inputs) and clone cleanup enables the compiler to safely
315 use in-place kernels without adding redundant copies or increasing the memory usage.
316
317 ### 6. Inductor Optimization and Codegen
318
319 After IR lowering and cleanup, the graph contains only standard PyTorch operations and platform-specific custom ops.
320 Inductor then performs its standard codegen:
321
322 - **Inductor lowering and pointwise fusion**: Fusing element-wise operations, reductions, etc.
323 - **Memory planning**: Determining buffer allocation and reuse
324 - **Kernel generation**: Generating Triton or C++ code for fused operations
325 - **Autotuning**: Selecting the best kernel configurations
326
327 ### Pipeline Summary
328
329 ```text
330 Model Forward Pass
331 ↓
332 [Dynamo Tracing] → FX Graph with vllm_ir.* ops
333 ↓
334 [Pre-grad: Inplace Functionalization] → maybe_inplace → default, track donated inputs
335 ↓
336 [AOTAutograd] → Functionalization
337 ↓
338 [Post-grad: IR Fusion Passes] → Fuse high-level IR ops (e.g., rms_norm + quant)
339 ↓
340 [Post-grad: IR Lowering] → vllm_ir.* ops → impl ops (with clones if needed)
341 ↓
342 [Post-grad: Clone Cleanup] → Remove unnecessary clones using donated input info
343 ↓
344 [Inductor] → Pattern matching, fusion, memory planning, codegen
345 ↓
346 Compiled Code
347 ```
348
349 ## Core vLLM IR Concepts
350
351 ### Operation Declaration
352
353 Operations are declared with the `@register_op` decorator, which creates an `IrOp` object:
354
355 ```python
356 @register_op(
357 name=None, # Operation name (defaults to function name)
358 activations=None, # List of activation parameters (defaults to params starting with 'x')
359 allow_inplace=False, # Whether to create a maybe_inplace overload
360 )
361 def op_name(...):
362 ...
363 ```
364
365 **Parameters:**
366
367 - `activations`: List of parameter names considered "activations" (typically consumed by `maybe_inplace`). Defaults to parameters starting with `x`.
368 - `allow_inplace`: Creates a `maybe_inplace` overload for memory-efficient execution (see below).
369
370 ### The `maybe_inplace` Overload
371
372 The `maybe_inplace` overload is a critical feature for memory efficiency in LLM inference.
373 It signals that the caller doesn't need to preserve the activation inputs after the operation,
374 allowing in-place implementations to reuse input memory for outputs.
375
376 #### Semantics and Usage
377
378 ```python
379 # Standard usage: inputs are preserved
380 out, res_out = ir.ops.fused_add_rms_norm(x, residual, weight, epsilon)
381 # x and residual are unchanged, out and res_out are new tensors
382
383 # maybe_inplace: inputs may be modified
384 out, res_out = ir.ops.fused_add_rms_norm.maybe_inplace(x, residual, weight, epsilon)
385 # x and residual may be modified (undefined behavior to use them after this)
386 # out and res_out may alias x and residual
387 ```
388
389 Using an activation input after passing it to `maybe_inplace` is **undefined behavior**:
390
391 ```python
392 # WRONG: Using x after donating it
393 out, res_out = ir.ops.fused_add_rms_norm.maybe_inplace(x, residual, weight, epsilon)
394 result = out + x # ERROR: x was donated!
395 ```
396
397 If you need to preserve an input, either use the default overload or clone manually:
398
399 ```python
400 # Option 1: Use default overload
401 out, res_out = ir.ops.fused_add_rms_norm(x, residual, weight, epsilon)
402 result = out + x # OK: x is preserved
403
404 # Option 2: Clone before maybe_inplace
405 out, res_out = ir.ops.fused_add_rms_norm.maybe_inplace(x.clone(), residual, weight, epsilon)
406 result = out + x # OK: x is preserved, clone was donated
407 ```
408
409 #### Compilation Behavior
410
411 During compilation, the inplace functionalization pass validates that donated inputs are
412 not used again and converts `maybe_inplace` to the functional `default` overload:
413
414 ```python
415 # Inplace functionalization pass (pre-grad)
416 for node in graph.nodes:
417 if node.target == torch.ops.vllm_ir.fused_add_rms_norm.maybe_inplace:
418 # Check that activation inputs aren't used after this node
419 for activation_arg in activation_inputs:
420 for user in activation_arg.users:
421 if user appears after node:
422 raise ValueError(f"Input {activation_arg} donated but used again")
423
424 # Convert to default overload
425 node.target = torch.ops.vllm_ir.fused_add_rms_norm.default
426
427 # Track donated graph inputs for later clone elimination
428 for i, arg in enumerate(node.args):
429 if arg.op == "placeholder" and i in activation_indices:
430 pass_context.donated_input_ids.add(node_to_idx[arg])
431 ```
432
433 The donated input information is then used by the clone cleanup pass to eliminate
434 unnecessary copies when in-place kernels are lowered.
435
436 #### Eager Mode Behavior
437
438 In eager mode (without `torch.compile`), `maybe_inplace` enables **maximally memory-efficient**
439 execution by allowing the IR operation to dispatch directly to in-place implementations:
440
441 ```python
442 # Eager dispatch logic for maybe_inplace
443 impl: IrOpImpl = ir_op.dispatch(*args)
444 return impl.impl_fn(*args)
445
446 # Eager dispatch logic for default:
447 impl: IrOpImpl = ir_op.dispatch(*args)
448 if impl.inplace:
449 args = [
450 arg.clone() if i in ir_op.activations else arg
451 for i, arg in enumerate(args)
452 ]
453 return impl.impl_fn(*args)
454 ```
455
456 The combination of `maybe_inplace` in model code and in-place kernel implementations provides optimal memory efficiency
457 in both eager and compiled modes, with identical semantics in both cases.
458
459 #### Memory Savings Example
460
461 Consider a transformer layer with residual connections:
462
463 ```python
464 # Without maybe_inplace (2 allocations per layer)
465 hidden_states = self.attention(input)
466 normed, residual = ir.ops.fused_add_rms_norm(hidden_states, input, weight, eps)
467 # Memory: input (preserved), hidden_states (preserved), normed (new), residual (new)
468
469 # With maybe_inplace (0 allocations per layer when using in-place kernel)
470 hidden_states = self.attention(input)
471 normed, residual = ir.ops.fused_add_rms_norm.maybe_inplace(hidden_states, input, weight, eps)
472 # Memory: normed (reuses hidden_states), residual (reuses input)
473 ```
474
475 ### Implementation Registration
476
477 Implementations are registered using the `register_impl` method:
478
479 ```python
480 @ir.ops.op_name.register_impl(
481 provider="provider_name", # Unique identifier (e.g., "vllm_c", "aiter", "triton")
482 supported=True, # Static availability check
483 supports_args=None, # Dynamic argument support check
484 )
485 def impl_fn(...):
486 ...
487 ```
488
489 **Provider naming conventions:**
490
491 - `native`: Reserved for the native torch implementation (declared with `@register_op`)
492 - `vllm_c`: C++/CUDA kernels via `torch.ops._C`
493 - `aiter`: AMD AITER library
494 - `xpu_kernels`: SYCL/SYCLTLA kernels implemented in `vllm-xpu-kernels`
495 - `triton_*`: Triton kernels
496 - Platform/library names for other implementations
497
498 **Support checking:**
499
500 - `supported`: Static boolean, checked once at import time (e.g., `HAS_TRITON`, `is_cuda_alike()`)
501 - `supports_args`: Function `(*args, **kwargs) -> bool` checking argument compatibility
502 - Called with **fake tensors** during compilation for zero-cost checking
503 - Called with **real tensors** during eager mode dispatch
504 - Should NOT check batch sizes or add guards based on values
505
506 Example support predicate:
507
508 ```python
509 def aiter_rms_norm_supports(x, weight, epsilon, variance_size=None):
510 # Check dtype (OK: doesn't depend on batch size)
511 if x.dtype not in [torch.float16, torch.bfloat16]:
512 return False
513 # Check optional parameter (OK: static check)
514 if variance_size is not None:
515 return False
516 return True
517
518 @ir.ops.rms_norm.register_impl("aiter", supports_args=aiter_rms_norm_supports)
519 def rms_norm(...):
520 ...
521 ```
522
523 Batch-invariant kernels are automatically selected when `VLLM_BATCH_INVARIANT=1` is set.
524
525 ### Eager Mode vs Compile Mode
526
527 vLLM IR operations behave identically in eager and compile modes:
528
529 **Eager mode:**
530
531 - Direct dispatch to implementation based on priority list
532 - Support checked with real tensor arguments
533 - Minimal overhead (can be optimized further if needed)
534
535 **Compile mode:**
536
537 - IR ops appear in FX graph as `torch.ops.vllm_ir.*` custom ops
538 - Lowering selects implementation using fake tensors
539 - Full integration with Inductor optimizations
540
541 This consistency enables:
542
543 - Prototyping in eager mode with confidence
544 - Debugging by disabling compilation
545 - Gradual migration from eager to compiled execution
546
547 ## Other Topics
548
549 ### Out-of-Tree Implementations
550
551 External platforms can register implementations without modifying vLLM:
552
553 ```python
554 # In external package
555 from vllm import ir
556
557 @ir.ops.rms_norm.register_impl("my_platform", supported=is_my_platform())
558 def rms_norm(x, weight, epsilon, variance_size=None):
559 return my_platform.rms_norm(x, weight, epsilon)
560 ```
561
562 Then configure priority to use your implementation:
563
564 ```python
565 class MyPlatform(Platform):
566 def get_default_ir_op_priority(self):
567 return IrOpPriorityConfig(rms_norm=['my_platform', 'native'])
568
569 # Users can still override priority in the same way
570 llm = LLM(ir_op_priority=IrOpPriorityConfig(rms_norm=['custom_oot_kernel']))
571 ```
572
573 ### Debugging and Observability
574
575 !!! note
576 Please let us know how observability can be improved for your use-case!
577
578 Enable debug logging to see kernel selection:
579
580 ```bash
581 VLLM_LOGGING_LEVEL=DEBUG vllm serve ...
582 ```
583
584 This logs:
585
586 - Which implementations are selected for each operation
587 - Why implementations were rejected (unsupported, args not supported)
588 - Compilation cache hits/misses
589 - IR lowering statistics
590
591 Check selected implementations in compiled graphs:
592
593 ```python
594 # After compilation, inspect the lowering pass
595 lowering_pass = backend.lowering_pass
596 print(lowering_pass.selected_impls)
597 # Output: {'rms_norm': {'node_123': 'vllm_c', 'node_456': 'vllm_c'}}
598 ```
599
600 ## Migration from CustomOp
601
602 vLLM IR is designed to coexist with and gradually replace `CustomOp`:
603
604 1. **Op declaration**: Convert `CustomOp` class `PluggableLayer` and move `forward_native` to `@register_op` function
605 2. **Implementation registration**: Use `@ir.ops.op_name.register_impl` instead of overriding methods
606 3. **Layer usage**: Replace `self.op(...)` with `ir.ops.op_name(...)`
607 4. **Configuration**: Migrate `--compilation-config.custom-ops` to `--ir-op-priority`
608
609 The migration can be done incrementally, one operation at a time.
610
611 ## See Also
612
613 - [torch.compile Integration](torch_compile.md) - General compilation infrastructure
614 - [Fusions](fusions.md) - Custom fusion and transformation passes in vLLM
615 - [Custom Operations](custom_op.md) - Legacy custom op system