main
py 564 lines 21 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Microbatching (DBO) helpers for the V2 GPU model runner."""
4
5 import threading
6 from collections.abc import Callable
7 from contextlib import ExitStack
8 from dataclasses import replace
9 from typing import Any, NamedTuple
10
11 import numpy as np
12 import torch
13
14 from vllm.config import CUDAGraphMode, VllmConfig
15 from vllm.distributed import get_dcp_group
16 from vllm.forward_context import (
17 DPMetadata,
18 ForwardContext,
19 create_forward_context,
20 override_forward_context,
21 )
22 from vllm.logger import init_logger
23 from vllm.sequence import IntermediateTensors
24 from vllm.utils.torch_utils import current_stream
25 from vllm.v1.kv_cache_interface import KVCacheConfig
26 from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
27 from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
28 from vllm.v1.worker.gpu.input_batch import InputBatch
29 from vllm.v1.worker.gpu.model_states.interface import ModelState
30 from vllm.v1.worker.ubatch_utils import (
31 UBatchSlice,
32 UBatchSlices,
33 create_sm_control_context,
34 maybe_create_ubatch_slices,
35 )
36 from vllm.v1.worker.ubatching import make_ubatch_contexts
37 from vllm.v1.worker.utils import AttentionGroup
38
39 logger = init_logger(__name__)
40
41
42 class UBatchState(NamedTuple):
43 """Per-microbatch inputs for one dual-batch-overlap step."""
44
45 slices: UBatchSlices
46 forward_contexts: list[ForwardContext]
47
48
49 def create_ubatch_slices(input_batch: InputBatch, num_ubatches: int) -> UBatchSlices:
50 """Split a DP-padded batch into the slices its microbatches run on.
51
52 Splitting at the midpoint of the DP-padded token count leaves the trailing
53 microbatch anywhere from full to entirely inside the padding. Clamping its
54 request slice onto the last request leaves it holding that request with
55 zero query tokens -- the shape a straddling request already takes -- so it
56 stays well-formed with no work to do, like a dummy run.
57 """
58 _, ubatch_slices = maybe_create_ubatch_slices(
59 True,
60 input_batch.num_scheduled_tokens,
61 input_batch.num_tokens_after_padding,
62 input_batch.num_reqs_after_padding,
63 num_ubatches,
64 )
65 assert ubatch_slices is not None
66 return [
67 UBatchSlice(
68 slice(
69 min(s.request_slice.start, input_batch.num_reqs - 1),
70 min(s.request_slice.stop, input_batch.num_reqs_after_padding),
71 ),
72 s.token_slice,
73 )
74 for s in ubatch_slices
75 ]
76
77
78 def _slice_input_batch(
79 input_batch: InputBatch,
80 ubatch_slice: UBatchSlice,
81 query_start_loc_buf: torch.Tensor,
82 seq_lens_buf: torch.Tensor,
83 dcp_local_seq_lens_buf: torch.Tensor | None = None,
84 *,
85 dcp_size: int = 1,
86 dcp_rank: int = 0,
87 cp_interleave: int = 1,
88 ) -> InputBatch:
89 """Build the sub-`InputBatch` a single microbatch runs on.
90
91 A request straddling the boundary appears in both microbatches, its query
92 truncated to the tokens each one owns.
93
94 Query offsets and sequence lengths (including DCP-local lengths) are
95 written to the caller's persistent buffers after truncation. Other GPU
96 inputs are views of the full batch's buffers.
97
98 The sub-batch describes the forward pass only. Sampling runs once over the
99 merged batch, so `logits_indices`, `cu_num_logits` and the draft-token
100 fields are carried over unchanged and must not be read off a sub-batch.
101 """
102 assert not ubatch_slice.is_empty(), f"Ubatch slice {ubatch_slice} is empty"
103
104 req_start = ubatch_slice.request_slice.start
105 req_stop = ubatch_slice.request_slice.stop
106 tok_start = ubatch_slice.token_slice.start
107 tok_stop = ubatch_slice.token_slice.stop
108
109 num_reqs_padded = req_stop - req_start
110 num_tokens_padded = tok_stop - tok_start
111 # The trailing microbatch may extend past the real batch into CUDA graph
112 # padding; the unpadded counts stop at the last real request/token.
113 num_reqs = max(0, min(req_stop, input_batch.num_reqs) - req_start)
114 num_tokens = max(0, min(tok_stop, input_batch.num_tokens) - tok_start)
115
116 query_start_loc = _slice_query_start_loc(
117 input_batch.query_start_loc,
118 query_start_loc_buf[: num_reqs_padded + 1],
119 req_start,
120 req_stop,
121 tok_start,
122 num_tokens_padded,
123 )
124 query_start_loc_np = np.clip(
125 input_batch.query_start_loc_np[req_start : req_stop + 1] - tok_start,
126 0,
127 num_tokens_padded,
128 ).astype(np.int32)
129
130 seq_lens = _slice_seq_lens(
131 input_batch.seq_lens,
132 seq_lens_buf[:num_reqs_padded],
133 input_batch.query_start_loc,
134 req_start,
135 req_stop,
136 tok_stop,
137 )
138 # Same truncation as `_slice_seq_lens`, on the host-side upper bound.
139 seq_lens_cpu_upper_bound = input_batch.seq_lens_cpu_upper_bound[
140 req_start:req_stop
141 ].clone()
142 tokens_truncated = max(0, int(input_batch.query_start_loc_np[req_stop]) - tok_stop)
143 if tokens_truncated:
144 seq_lens_cpu_upper_bound[-1] -= tokens_truncated
145
146 # Query lengths of the truncated requests, so consumers that derive
147 # max_query_len from this array see the microbatch's own lengths.
148 num_scheduled_tokens = np.diff(query_start_loc_np)[:num_reqs]
149
150 dcp_local_seq_lens = None
151 if dcp_size > 1:
152 assert dcp_local_seq_lens_buf is not None
153 dcp_local_seq_lens = prepare_dcp_local_seq_lens(
154 dcp_local_seq_lens_buf,
155 seq_lens,
156 num_reqs,
157 dcp_size,
158 dcp_rank,
159 cp_interleave,
160 num_reqs_padded=num_reqs_padded,
161 )
162
163 return replace(
164 input_batch,
165 req_ids=input_batch.req_ids[req_start : min(req_stop, input_batch.num_reqs)],
166 num_reqs=num_reqs,
167 num_reqs_after_padding=num_reqs_padded,
168 idx_mapping=input_batch.idx_mapping[req_start:req_stop],
169 idx_mapping_np=input_batch.idx_mapping_np[req_start:req_stop],
170 num_scheduled_tokens=num_scheduled_tokens,
171 num_tokens=num_tokens,
172 num_tokens_after_padding=num_tokens_padded,
173 query_start_loc=query_start_loc,
174 query_start_loc_np=query_start_loc_np,
175 seq_lens=seq_lens,
176 seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
177 dcp_local_seq_lens=dcp_local_seq_lens,
178 num_computed_tokens_np=input_batch.num_computed_tokens_np[req_start:req_stop],
179 prefill_len_np=input_batch.prefill_len_np[req_start:req_stop],
180 num_computed_prefill_tokens_np=input_batch.num_computed_prefill_tokens_np[
181 req_start:req_stop
182 ],
183 is_prefilling_np=input_batch.is_prefilling_np[req_start:req_stop],
184 prefill_runs_as_decode_np=(
185 None
186 if input_batch.prefill_runs_as_decode_np is None
187 else input_batch.prefill_runs_as_decode_np[req_start:req_stop]
188 ),
189 input_ids=input_batch.input_ids[tok_start:tok_stop],
190 positions=input_batch.positions[tok_start:tok_stop],
191 is_padding=input_batch.is_padding[tok_start:tok_stop],
192 prompt_lens=(
193 None
194 if input_batch.prompt_lens is None
195 else input_batch.prompt_lens[req_start:req_stop]
196 ),
197 max_seq_len_np=None,
198 fast_prefill=None,
199 )
200
201
202 def _slice_query_start_loc(
203 query_start_loc: torch.Tensor,
204 out: torch.Tensor,
205 req_start: int,
206 req_stop: int,
207 tok_start: int,
208 num_tokens_padded: int,
209 ) -> torch.Tensor:
210 """Rebase query_start_loc onto the microbatch's token range.
211
212 The clamp truncates the requests straddling either boundary to the tokens
213 this microbatch owns.
214 """
215 torch.sub(query_start_loc[req_start : req_stop + 1], tok_start, out=out)
216 return out.clamp_(0, num_tokens_padded)
217
218
219 def _slice_seq_lens(
220 seq_lens: torch.Tensor,
221 out: torch.Tensor,
222 query_start_loc: torch.Tensor,
223 req_start: int,
224 req_stop: int,
225 tok_stop: int,
226 ) -> torch.Tensor:
227 """Slice seq_lens, shortening the request truncated at the boundary.
228
229 Only tokens past the end of this microbatch are missing; tokens it owns in
230 an *earlier* microbatch are computed before this one reads them, so they
231 still count. Deriving the truncation from this microbatch's own query
232 lengths would subtract them twice. It is computed from tensors, not Python
233 ints, so this stays valid inside a CUDA graph.
234 """
235 out.copy_(seq_lens[req_start:req_stop])
236 last = req_stop - req_start - 1
237 out[last] -= (query_start_loc[req_stop] - tok_stop).clamp_(min=0)
238 return out
239
240
241 def slice_model_inputs(
242 model_inputs: dict[str, Any], token_slice: slice
243 ) -> dict[str, Any]:
244 """Narrow the model's per-token inputs to one microbatch."""
245 sliced = dict(model_inputs)
246 for key in ("input_ids", "inputs_embeds"):
247 value = model_inputs.get(key)
248 if value is not None:
249 sliced[key] = value[token_slice]
250
251 positions = model_inputs["positions"]
252 # M-RoPE carries a leading section dim.
253 sliced["positions"] = (
254 positions[:, token_slice] if positions.ndim == 2 else positions[token_slice]
255 )
256
257 intermediate_tensors = model_inputs.get("intermediate_tensors")
258 if intermediate_tensors is not None:
259 sliced["intermediate_tensors"] = intermediate_tensors[token_slice]
260 return sliced
261
262
263 def merge_ubatch_outputs(outputs: list[Any]) -> Any:
264 """Reassemble the full-batch model output from the per-microbatch ones.
265
266 Models return hidden states, a tuple of those plus auxiliary hidden states
267 (EAGLE3), or IntermediateTensors on non-final pipeline ranks.
268 """
269 first = outputs[0]
270 if isinstance(first, IntermediateTensors):
271 return IntermediateTensors(
272 {
273 key: torch.cat([out.tensors[key] for out in outputs], dim=0)
274 for key in first.tensors
275 }
276 )
277 if isinstance(first, tuple):
278 return tuple(torch.cat(parts, dim=0) for parts in zip(*outputs))
279 return torch.cat(outputs, dim=0)
280
281
282 class UBatchRunner:
283 """Runs the model on two or more microbatches that overlap each other.
284
285 Each microbatch gets a thread and its own forward context. Only one thread
286 holds the GPU at a time; they hand off to each other at the communication
287 points inside the model (see `vllm.v1.worker.ubatching`), so one
288 microbatch's expert all-to-all overlaps the other's compute.
289 """
290
291 def __init__(
292 self,
293 vllm_config: VllmConfig,
294 device: torch.device,
295 model_state: ModelState,
296 attn_groups: list[list[AttentionGroup]],
297 kv_cache_config: KVCacheConfig,
298 max_num_reqs: int,
299 ):
300 self.vllm_config = vllm_config
301 self.parallel_config = vllm_config.parallel_config
302 self.num_ubatches = self.parallel_config.num_ubatches
303 self.dcp_size = self.parallel_config.decode_context_parallel_size
304 self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_size > 1 else 0
305 self.cp_interleave = self.parallel_config.cp_kv_cache_interleave_size
306 self.device = device
307 self.model_state = model_state
308 self.attn_groups = attn_groups
309 self.kv_cache_config = kv_cache_config
310 # `query_start_loc` and `seq_lens` are rebased onto each microbatch's
311 # own token range, so they cannot be views of the full batch's buffers.
312 # Allocating up front keeps their addresses stable across replays.
313 self.ubatch_query_start_loc = [
314 torch.zeros(max_num_reqs + 1, dtype=torch.int32, device=device)
315 for _ in range(self.num_ubatches)
316 ]
317 self.ubatch_seq_lens = [
318 torch.zeros(max_num_reqs, dtype=torch.int32, device=device)
319 for _ in range(self.num_ubatches)
320 ]
321 self.ubatch_dcp_local_seq_lens = [
322 torch.zeros(max_num_reqs, dtype=torch.int32, device=device)
323 if self.dcp_size > 1
324 else None
325 for _ in range(self.num_ubatches)
326 ]
327 self.comm_stream = torch.cuda.Stream(device=device)
328 # Threads and graph capture must use the same compute stream.
329 self.capture_stream = torch.cuda.Stream(device=device)
330 # The microbatch threads plus the thread that starts them.
331 self.ready_barrier = threading.Barrier(self.num_ubatches + 1)
332 self._pending_finish: Callable[[], Any] | None = None
333 self.sm_control = create_sm_control_context(self.parallel_config)
334
335 def prepare(
336 self,
337 input_batch: InputBatch,
338 block_tables: tuple[torch.Tensor, ...],
339 slot_mappings: torch.Tensor,
340 cg_mode: CUDAGraphMode = CUDAGraphMode.NONE,
341 for_capture: bool = False,
342 ) -> UBatchState:
343 """Split the batch into the microbatches the step will run on.
344
345 Attention metadata is built per microbatch and carried in the forward
346 contexts the threads install, not in the caller's.
347
348 FULL uses padded sizes; `for_capture` refreshes metadata from dummy
349 block tables.
350 """
351 ubatch_slices = create_ubatch_slices(input_batch, self.num_ubatches)
352
353 attn_metadata = []
354 slot_mappings_by_layer = []
355 is_padding = []
356 for i, ubatch_slice in enumerate(ubatch_slices):
357 ubatch = _slice_input_batch(
358 input_batch,
359 ubatch_slice,
360 self.ubatch_query_start_loc[i],
361 self.ubatch_seq_lens[i],
362 self.ubatch_dcp_local_seq_lens[i],
363 dcp_size=self.dcp_size,
364 dcp_rank=self.dcp_rank,
365 cp_interleave=self.cp_interleave,
366 )
367 ubatch_slot_mappings = slot_mappings[:, ubatch_slice.token_slice]
368 ubatch_block_tables = tuple(
369 block_table[ubatch_slice.request_slice] for block_table in block_tables
370 )
371 attn_metadata.append(
372 self.model_state.prepare_attn(
373 ubatch,
374 cg_mode,
375 ubatch_block_tables,
376 ubatch_slot_mappings,
377 self.attn_groups,
378 self.kv_cache_config,
379 ubatch_idx=i,
380 for_capture=for_capture,
381 )
382 )
383 slot_mappings_by_layer.append(
384 build_slot_mappings_by_layer(ubatch_slot_mappings, self.kv_cache_config)
385 )
386 is_padding.append(ubatch.is_padding)
387
388 return UBatchState(
389 slices=ubatch_slices,
390 forward_contexts=self._make_forward_contexts(
391 ubatch_slices, attn_metadata, slot_mappings_by_layer, is_padding
392 ),
393 )
394
395 def _make_forward_contexts(
396 self,
397 ubatch_slices: UBatchSlices,
398 attn_metadata: list[dict[str, Any]],
399 slot_mappings_by_layer: list[dict[str, torch.Tensor]],
400 is_padding: list[torch.Tensor | None],
401 ) -> list[ForwardContext]:
402 """Build one forward context per microbatch.
403
404 The expert all-to-all runs once per microbatch, so each context's DP
405 metadata carries that microbatch's token count. Microbatching pads every
406 DP rank alike, so each rank can assume that count for the others too.
407 """
408 dp_size = self.parallel_config.data_parallel_size
409 forward_contexts = []
410 for i, ubatch_slice in enumerate(ubatch_slices):
411 num_tokens = ubatch_slice.num_tokens
412 num_tokens_across_dp = torch.full(
413 (dp_size,), num_tokens, dtype=torch.int32, device="cpu"
414 )
415 forward_contexts.append(
416 create_forward_context(
417 attn_metadata[i],
418 self.vllm_config,
419 dp_metadata=DPMetadata.make(
420 self.parallel_config, num_tokens, num_tokens_across_dp
421 ),
422 cudagraph_runtime_mode=CUDAGraphMode.NONE,
423 slot_mapping=slot_mappings_by_layer[i],
424 is_padding=is_padding[i],
425 )
426 )
427 return forward_contexts
428
429 def run(
430 self,
431 model: Any,
432 model_inputs: dict[str, Any],
433 ubatch_state: UBatchState,
434 ) -> Any:
435 finish = self.begin_capturable_run(model, model_inputs, ubatch_state)
436 return finish()
437
438 def begin_capturable_run(
439 self,
440 model: Any,
441 model_inputs: dict[str, Any],
442 ubatch_state: UBatchState,
443 for_capture: bool = False,
444 ) -> Callable[[], Any]:
445 """Start microbatch threads and return a callback to run and join them.
446
447 For capture, start threads outside the graph and call the callback inside.
448 `for_capture` selects the capture stream and initializes cuBLAS workspaces
449 before capture.
450
451 Call the callback exactly once on this thread, or call `abort_pending_run`
452 on failure to release parked threads.
453 """
454 ubatch_slices = ubatch_state.slices
455 assert len(ubatch_slices) == len(ubatch_state.forward_contexts)
456 assert len(ubatch_slices) == self.num_ubatches
457
458 compute_stream = self.capture_stream if for_capture else current_stream()
459 ubatch_contexts = make_ubatch_contexts(
460 num_micro_batches=self.num_ubatches,
461 comm_stream=self.comm_stream,
462 compute_stream=compute_stream,
463 forward_contexts=ubatch_state.forward_contexts,
464 ready_barrier=self.ready_barrier,
465 )
466
467 outputs: dict[int, Any] = {}
468 errors: dict[int, BaseException] = {}
469
470 @torch.inference_mode()
471 def run_ubatch(ubatch_context, inputs: dict[str, Any]) -> None:
472 try:
473 # New threads must select the worker's device.
474 torch.accelerator.set_device_index(self.device.index)
475 if for_capture:
476 # Allocate cuBLAS workspaces before capture.
477 for stream in (compute_stream, self.comm_stream):
478 with torch.cuda.stream(stream):
479 torch.cuda.current_blas_handle()
480 with ubatch_context:
481 outputs[ubatch_context.id] = model(**inputs)
482 except BaseException as e: # noqa: BLE001
483 # Recorded so the join below can report which microbatch broke.
484 # Note the siblings are not told to unwind: microbatches hand
485 # control to each other, so one that dies mid-forward leaves
486 # the others parked on a handoff that never comes and the step
487 # hangs. The V1 runner behaves the same way; fixing it means
488 # changing the shared handoff protocol in ubatching.py, which
489 # is deliberately out of scope here.
490 errors[ubatch_context.id] = e
491
492 # Threads own the forward context; keep it cleared and SMs reserved
493 # until finish joins them.
494 stack = ExitStack()
495 stack.enter_context(override_forward_context(None))
496 stack.enter_context(self.sm_control)
497
498 threads = []
499 for ubatch_context, ubatch_slice in zip(ubatch_contexts, ubatch_slices):
500 thread = threading.Thread(
501 target=run_ubatch,
502 args=(
503 ubatch_context,
504 slice_model_inputs(model_inputs, ubatch_slice.token_slice),
505 ),
506 )
507 threads.append(thread)
508 thread.start()
509
510 # Park all threads before the caller starts capture.
511 self.ready_barrier.wait()
512
513 def finish() -> Any:
514 try:
515 ubatch_contexts[0].cpu_wait_event.set()
516 for thread in threads:
517 thread.join()
518 finally:
519 self._pending_finish = None
520 stack.close()
521
522 if errors:
523 failed = min(errors)
524 raise RuntimeError(
525 f"Microbatch {failed} of {self.num_ubatches} failed"
526 ) from errors[failed]
527 return merge_ubatch_outputs([outputs[i] for i in range(self.num_ubatches)])
528
529 self._pending_finish = finish
530 return finish
531
532 def abort_pending_run(self) -> None:
533 """Release and join parked threads after a failed capture."""
534 if self._pending_finish is None:
535 return
536 try:
537 self._pending_finish()
538 except Exception:
539 logger.exception("Failed to join microbatch threads after an abort")
540 finally:
541 self._pending_finish = None
542
543
544 def maybe_build_ubatch_runner(
545 vllm_config: VllmConfig,
546 device: torch.device,
547 model_state: ModelState,
548 attn_groups: list[list[AttentionGroup]],
549 kv_cache_config: KVCacheConfig,
550 max_num_reqs: int,
551 ) -> UBatchRunner | None:
552 """Build the microbatch runner, or None when DBO is not in use.
553
554 Microbatching needs the DP handshake to agree on it, so it is only
555 available with more than one DP rank (as in the V1 runner).
556 """
557 parallel_config = vllm_config.parallel_config
558 if not parallel_config.use_ubatching or parallel_config.data_parallel_size <= 1:
559 return None
560
561 logger.info_once("Dual batch overlap is enabled.")
562 return UBatchRunner(
563 vllm_config, device, model_state, attn_groups, kv_cache_config, max_num_reqs
564 )