main
py 380 lines 13.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import inspect
5 import os
6 from collections.abc import Callable, Hashable, Iterator
7 from contextlib import AbstractContextManager, contextmanager, nullcontext
8 from contextvars import ContextVar
9 from itertools import accumulate
10 from math import prod
11 from typing import TypeVar, cast
12
13 import torch
14
15 import vllm.envs as envs
16 from vllm.logger import init_logger
17 from vllm.utils.math_utils import round_up
18 from vllm.v1.worker.ubatching import dbo_current_ubatch_id
19
20 logger = init_logger(__name__)
21 T = TypeVar("T")
22
23
24 def _compute_bytes(shape: tuple[int, ...], dtype: torch.dtype) -> int:
25 return prod(shape) * dtype.itemsize
26
27
28 # Constants
29 _MB = 1024**2
30 _GiB = 1024**3
31
32 # Global workspace manager instance
33 _manager: "WorkspaceManager | None" = None
34 _workspace_lane: ContextVar[int] = ContextVar("vllm_workspace_lane", default=0)
35
36
37 @contextmanager
38 def use_workspace_lane(lane: int) -> Iterator[None]:
39 """Select an independent workspace owner for this execution context."""
40 if lane < 0:
41 raise ValueError(f"Workspace lane must be non-negative, got {lane}.")
42 token = _workspace_lane.set(lane)
43 try:
44 yield
45 finally:
46 _workspace_lane.reset(token)
47
48
49 class WorkspaceManager:
50 """Manager for workspace allocation.
51
52 Manages shared scratch and keyed persistent resources per ``(ubatch, lane)``.
53 Can be locked to prevent growth or new resources during execution.
54 """
55
56 def __init__(
57 self,
58 device: torch.device,
59 num_ubatches: int | None = None,
60 num_lanes: int = 1,
61 alloc_context: Callable[[], AbstractContextManager] = nullcontext,
62 ):
63 self._device = device
64 self._alloc_context = alloc_context
65 # Cache num ubatches at init based on configuration (default to 1)
66 self._num_ubatches = num_ubatches if num_ubatches is not None else 1
67 if num_lanes < 1:
68 raise ValueError(f"num_lanes must be at least one, got {num_lanes}.")
69 self._num_lanes = num_lanes
70 self._current_workspaces: list[torch.Tensor | None] = [None] * (
71 self._num_ubatches * self._num_lanes
72 )
73 self._persistent_resources: list[dict[Hashable, object]] = [
74 {} for _ in self._current_workspaces
75 ]
76 self._locked: bool = False
77
78 @staticmethod
79 def _workspace_size_bytes(workspace: torch.Tensor | None) -> int:
80 """Get size of workspace in bytes."""
81 if workspace is None:
82 return 0
83 return workspace.numel() * workspace.element_size()
84
85 def lock(self) -> None:
86 """Lock the workspace to prevent growth or new persistent resources.
87
88 Larger scratch requests and new persistent resources raise an assertion
89 error. Existing resources retain their storage throughout execution.
90 """
91 self._locked = True
92 if envs.VLLM_DEBUG_WORKSPACE:
93 logger.info(
94 "[WORKSPACE DEBUG] Workspace locked. Current sizes: %s",
95 [
96 self._workspace_size_bytes(ws) / _MB
97 for ws in self._current_workspaces
98 if ws is not None
99 ],
100 )
101
102 def unlock(self) -> None:
103 """Unlock the workspace to allow growth.
104
105 This is used during elastic EP scaling when the workspace size
106 needs to grow due to changes in the number of experts.
107 """
108 self._locked = False
109 if envs.VLLM_DEBUG_WORKSPACE:
110 logger.info(
111 "[WORKSPACE DEBUG] Workspace unlocked. Current sizes: %s",
112 [
113 self._workspace_size_bytes(ws) / _MB
114 for ws in self._current_workspaces
115 if ws is not None
116 ],
117 )
118
119 def is_locked(self) -> bool:
120 """Check if workspace is locked."""
121 return self._locked
122
123 def _get_workspace_id(self) -> int:
124 lane = _workspace_lane.get()
125 if lane >= self._num_lanes:
126 raise RuntimeError(
127 f"Workspace lane {lane} is not configured; manager has "
128 f"{self._num_lanes} lane(s)."
129 )
130 return dbo_current_ubatch_id() * self._num_lanes + lane
131
132 def get_persistent_resource(self, key: Hashable, factory: Callable[[], T]) -> T:
133 """Return the resource cached for ``key`` in the current ubatch and lane.
134
135 The first request calls ``factory`` and stores its result for the lifetime
136 of this manager. Later requests return the same object without calling
137 the factory again. Each ubatch/lane pair has a separate cache. Once the
138 manager is locked, requesting a key missing from that cache raises an
139 AssertionError.
140
141 Keys must identify the resource type and configuration. Callers must not
142 resize or replace tensor storage held by a cached resource. Uses of the
143 same resource must not overlap; include the CUDA stream in the key when
144 streams can execute concurrently.
145
146 With torch.compile, call this inside a runtime custom op so resource
147 selection happens during execution.
148 """
149 resources = self._persistent_resources[self._get_workspace_id()]
150 if key not in resources:
151 if self._locked:
152 raise AssertionError(
153 f"Workspace is locked but {key!r} was not allocated during "
154 "warmup for the current ubatch and lane."
155 )
156 resources[key] = factory()
157 return cast(T, resources[key])
158
159 def get_persistent(
160 self,
161 key: Hashable,
162 shape: tuple[int, ...],
163 dtype: torch.dtype,
164 *,
165 zero_init: bool = False,
166 ) -> torch.Tensor:
167 """Get a fixed-shape tensor, optionally zeroed on first allocation only."""
168 factory = torch.zeros if zero_init else torch.empty
169 workspace = self.get_persistent_resource(
170 (torch.Tensor, key),
171 lambda: factory(shape, dtype=dtype, device=self._device),
172 )
173 if workspace.shape != shape or workspace.dtype != dtype:
174 raise ValueError(
175 f"Persistent workspace {key!r} has shape={workspace.shape}, "
176 f"dtype={workspace.dtype}; requested shape={shape}, dtype={dtype}."
177 )
178 return workspace
179
180 def get_simultaneous(
181 self, *shapes_and_dtypes: tuple[tuple[int, ...], torch.dtype]
182 ) -> list[torch.Tensor]:
183 """Get multiple workspace tensors simultaneously from a single allocation.
184
185 Args:
186 *shapes_and_dtypes: One or more (shape, dtype) tuples.
187
188 Returns:
189 List of tensor views into the workspace buffer, one per shape/dtype pair.
190
191 """
192 actual_bytes = [_compute_bytes(s, d) for s, d in shapes_and_dtypes]
193 aligned_bytes = [round_up(actual, 256) for actual in actual_bytes]
194 total_bytes = sum(aligned_bytes)
195
196 # Calculate cumulative offsets using itertools.accumulate
197 offsets = list(accumulate([0] + aligned_bytes[:-1]))
198
199 current_workspace = self._ensure_workspace_size(total_bytes)
200
201 return [
202 current_workspace[offsets[i] : offsets[i] + actual_bytes[i]]
203 .view(shapes_and_dtypes[i][1])
204 .reshape(shapes_and_dtypes[i][0])
205 for i in range(len(shapes_and_dtypes))
206 ]
207
208 def _ensure_workspace_size(self, required_bytes: int) -> torch.Tensor:
209 """Ensure workspace is allocated and large enough, return current workspace.
210
211 Args:
212 required_bytes: The number of bytes required.
213
214 Returns:
215 The current workspace tensor.
216
217 """
218 workspace_id = self._get_workspace_id()
219 current_workspace = self._current_workspaces[workspace_id]
220 current_size = self._workspace_size_bytes(current_workspace)
221
222 if current_size < required_bytes:
223
224 def get_caller_info() -> str:
225 """Find first frame outside WorkspaceManager."""
226 curr_frame = inspect.currentframe()
227 if curr_frame is None:
228 return "unknown"
229 # Walk up the stack skipping WorkspaceManager frames
230 curr_frame = curr_frame.f_back
231 while curr_frame is not None:
232 # TODO: This only catches instance methods (self), missing
233 # classmethods and staticmethods. Once Python 3.11+ is the
234 # minimum supported version, use co_qualname instead:
235 # qualname = curr_frame.f_code.co_qualname
236 # if qualname.startswith("WorkspaceManager."):
237 if isinstance(curr_frame.f_locals.get("self"), WorkspaceManager):
238 curr_frame = curr_frame.f_back
239 continue
240 filename = os.path.basename(curr_frame.f_code.co_filename)
241 return (
242 f"{filename}:{curr_frame.f_lineno}:{curr_frame.f_code.co_name}"
243 )
244 return "unknown"
245
246 if self._locked:
247 raise AssertionError(
248 f"Workspace is locked but allocation from '{get_caller_info()}' "
249 f"requires {required_bytes / _MB:.2f} MB, current size is "
250 f"{current_size / _MB:.2f} MB. "
251 "Workspace growth is not allowed after locking."
252 )
253
254 # Only resize the requesting ubatch/lane workspace. Other slots
255 # resize lazily on their next get_simultaneous call.
256 # Resizing all ubatches here would orphan the other ubatch's
257 # old tensor when it still holds views into it (DBO leak).
258 self._current_workspaces[workspace_id] = None
259 del current_workspace
260 # Release the freed segment back to CUDA so the caching
261 # allocator can reuse the GPU memory for the larger
262 # allocation below. Without this, each resize may leave a
263 # dead segment in reserved memory which can cause higher peak
264 # memory usage.
265 torch.accelerator.empty_cache()
266 # In sleep mode, entering the pool also trims outgrown buffers.
267 with self._alloc_context():
268 self._current_workspaces[workspace_id] = torch.empty(
269 (required_bytes,), dtype=torch.uint8, device=self._device
270 )
271 current_workspace = self._current_workspaces[workspace_id]
272
273 if envs.VLLM_DEBUG_WORKSPACE:
274 logger.info(
275 "[WORKSPACE DEBUG] Resized workspace from '%s': %.2f MB -> "
276 "%.2f MB (ubatch %d, lane %d)",
277 get_caller_info(),
278 current_size / _MB,
279 required_bytes / _MB,
280 workspace_id // self._num_lanes,
281 workspace_id % self._num_lanes,
282 )
283
284 return current_workspace
285
286
287 def is_workspace_manager_initialized() -> bool:
288 """Check if workspace manager has been initialized.
289
290 Returns:
291 True if workspace manager is initialized, False otherwise.
292
293 """
294 return _manager is not None
295
296
297 def current_workspace_manager() -> "WorkspaceManager":
298 """Get the current workspace manager instance.
299
300 Raises:
301 AssertionError: If workspace manager has not been initialized.
302
303 """
304 assert _manager is not None, (
305 "WorkspaceManager not initialized. Call init_workspace_manager() "
306 "with a device before using workspace functions."
307 )
308 return _manager
309
310
311 def init_workspace_manager(
312 device: torch.device,
313 num_ubatches: int | None = None,
314 num_lanes: int = 1,
315 alloc_context: Callable[[], AbstractContextManager] = nullcontext,
316 ) -> None:
317 """Initialize the workspace manager with a device.
318
319 Must be called before using any workspace functions. Typically called
320 from GPUModelRunner.__init__.
321
322 Args:
323 device: The device to allocate workspace on.
324 num_ubatches: Number of workspace ubatch slots. Defaults to 1.
325 num_lanes: Number of independent execution lanes per ubatch. Defaults to 1.
326 alloc_context: Factory for the context scratch is allocated in.
327
328 """
329 global _manager
330 if _manager is not None:
331 logger.warning(
332 "WorkspaceManager already initialized on device %s, "
333 "reinitializing on device %s",
334 _manager._device,
335 device,
336 )
337 _manager = WorkspaceManager(device, num_ubatches, num_lanes, alloc_context)
338
339
340 def lock_workspace() -> None:
341 """Lock the workspace to prevent growth or new persistent resources.
342
343 Larger scratch requests and new persistent resources raise an AssertionError.
344 All required resources must be initialized in each execution slot during
345 warmup, so their storage remains fixed during execution.
346
347 Example:
348 # During initialization
349 init_workspace_manager(device)
350 reserve_workspace(shape1, dtype1)
351 reserve_workspace(shape2, dtype2)
352
353 # Lock after warmup/profiling
354 lock_workspace()
355
356 # Now all get_workspace calls must fit in pre-allocated size
357
358 """
359 current_workspace_manager().lock()
360
361
362 def unlock_workspace() -> None:
363 """Unlock the workspace to allow growth.
364
365 This is used during elastic EP scaling when the workspace size
366 needs to grow due to changes in the number of experts.
367 After scaling operations complete, lock_workspace() should be
368 called again to prevent unexpected allocations.
369 """
370 current_workspace_manager().unlock()
371
372
373 def reset_workspace_manager() -> None:
374 """Reset the workspace manager to uninitialized state.
375
376 This is primarily intended for testing purposes to allow tests
377 to reinitialize the workspace manager cleanly.
378 """
379 global _manager
380 _manager = None