[1/N] Unify multiple-path encoder cuda graph support (#49934)
Signed-off-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent
c67fe497a2
commit
e2fa28594f
@@ -41,13 +41,13 @@ class BudgetGraphMetadata:
|
||||
|
||||
Budgets are auto-generated as power-of-2 levels from a model-provided range via `get_encoder_cudagraph_budget_range()`, with the maximum budget always included even if it does not fall on a power-of-2 boundary. Budgets can also be explicitly specified by the user via `encoder_cudagraph_token_budgets` in `CompilationConfig`.
|
||||
|
||||
When `EncoderCudaGraphConfig.enable_dual_path_graph` is `True`, the manager generates two independent budget lists — `global_token_budgets` (multiples of `global_token_per_image`) and `local_token_budgets` (multiples of `local_token_per_patch`) — and stores captured graphs under `budget_graphs["global"]` and `budget_graphs["local"]` respectively.
|
||||
Each entry in `EncoderCudaGraphConfig.paths` defines an independently captured encoder path. A path can provide its own minimum token budget and opt into zero-token batches; the manager generates and stores a separate budget graph set for every configured path.
|
||||
|
||||
### Greedy bin-packing at runtime
|
||||
|
||||
When a batch of images arrives, the manager sorts images by output token count (smallest first) and greedily packs as many images as possible into each sub-batch while staying within the **largest** token budget and the maximum batch size. Once a sub-batch is finalized (the next image would overflow either constraint), the manager finds the **smallest** budget that fits the sub-batch's total tokens and replays the corresponding CUDA Graph. This repeats until the batch is exhausted. Images that exceed all budgets fall back to eager execution.
|
||||
|
||||
For dual-path models, the manager routes to `_execute_local_dual_path()`, which constrains both global and local token budgets simultaneously during packing (see [Dual-Path graph capture](#dual-path-graph-capture)).
|
||||
For multi-path models, the same greedy packing loop constrains every configured path simultaneously (see [Multi-Path graph capture](#multi-path-graph-capture)).
|
||||
|
||||
For each graph replay:
|
||||
|
||||
@@ -56,38 +56,29 @@ For each graph replay:
|
||||
3. Replay the CUDA Graph.
|
||||
4. Clone outputs from `output_buffer` (cloning is necessary since the buffer is reused across replays).
|
||||
|
||||
### Dual-Path graph capture
|
||||
### Multi-Path graph capture
|
||||
|
||||
For two-tower vision encoders (e.g., DeepSeek-OCR), the `EncoderCudaGraphConfig` sets `enable_dual_path_graph=True` and provides `global_token_per_image` / `local_token_per_patch`. The manager captures two independent sets of CUDA graphs — one for the **global** image path and one for the **local** patch path — stored under `budget_graphs["global"]` and `budget_graphs["local"]` respectively.
|
||||
`EncoderCudaGraphConfig.paths` maps path names to `EncoderCudaGraphPathConfig` capture policies. For example, DeepSeek-OCR configures a **global** image path and a **local** patch path, which are captured independently under `budget_graphs["global"]` and `budget_graphs["local"]`.
|
||||
|
||||
**Budget generation.** Two separate budget lists are generated:
|
||||
**Budget generation.** Each path gets a separate budget list. For DeepSeek-OCR:
|
||||
|
||||
* `global_token_budgets` — power-of-2 multiples of `global_token_per_image` (e.g., `[272, 544, 1088, 2176, 4352, 8704, 13824]` for DeepSeek-OCR).
|
||||
* `local_token_budgets` — power-of-2 multiples of `local_token_per_patch` (e.g., `[0, 100, 200, 400, 800, 1600, 3200, 6400, 12800]` for DeepSeek-OCR). A budget of `0` is always included to handle images with no local patches (images ≤ 640×640 that produce only global features).
|
||||
* the `global` path — power-of-2 budgets starting at the global path minimum (e.g., `[272, 544, 1088, 2176, 4352, 8704, 13824]` for DeepSeek-OCR).
|
||||
* the `local` path — power-of-2 budgets starting at the local path minimum (e.g., `[0, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 13824]` for DeepSeek-OCR). A budget of `0` is included when `allow_zero_tokens=True` to handle images with no local patches (images ≤ 640×640 that produce only global features).
|
||||
|
||||
Both lists are capped at the same `max_budget`.
|
||||
|
||||
**Dual-path greedy packing.** Each `EncoderItemSpec` provides both `global_output_tokens` (constant per image) and `local_output_tokens` (proportional to the patch count). The dual-path packing algorithm constrains both budgets simultaneously:
|
||||
**Multi-path greedy packing.** Each `EncoderItemSpec` provides `path_output_tokens`, mapping each path to its contribution for that item. The packing algorithm constrains every path simultaneously:
|
||||
|
||||
* Sort images by total output tokens (global + local), smallest first.
|
||||
* Greedily pack images: an image is added to the current sub-batch only if both the accumulated global tokens ≤ `max_global_budget` **and** the accumulated local tokens ≤ `max_local_budget`, with the image count ≤ `max_batch_size`.
|
||||
* Once either constraint would overflow, finalize the sub-batch and find the smallest fitting budget **independently** for each path.
|
||||
* Greedily pack images: an image is added to the current sub-batch only if every accumulated path token count is within that path's maximum budget, with the image count ≤ `max_batch_size`.
|
||||
* Once any path constraint would overflow, finalize the sub-batch and find the smallest fitting budget **independently** for each path.
|
||||
* Repeat until all images are packed.
|
||||
|
||||
**Partial graph fallback.** After packing, each sub-batch falls into one of four execution scenarios:
|
||||
|
||||
| Global budget | Local budget | Execution |
|
||||
| :---: | :---: | --- |
|
||||
| Found | Found | Both paths use CUDA graph replay |
|
||||
| Found | `None` | Global graph replay + local path skipped (no patches) |
|
||||
| `None` | Found | Global eager fallback + local graph replay |
|
||||
| `None` | `None` | Both paths fall back to eager execution |
|
||||
|
||||
Note that the `0`-budget graph is never actually replayed for local — it signals that local patch processing should be skipped entirely.
|
||||
**Partial graph fallback.** For each non-empty path, the manager replays the smallest fitting graph or runs only that path eagerly when no graph fits. Paths with zero tokens are skipped; a `0`-budget graph is never captured or replayed.
|
||||
|
||||
**Buffer keys per path.** Global and local paths use different buffer keys. For DeepSeek-OCR, the global path uses `pixel_values` (full images, shape `[B, 3, 1280, 1280]`) while the local path uses `images_crop` (patches, shape `[P, 3, 1024, 1024]`). The manager iterates over each captured graph's own `input_buffers.keys()` rather than a shared `buffer_keys` list, so both paths can use different buffers.
|
||||
|
||||
**Post-processing.** The `postprocess_encoder_output` method receives a `local_output` parameter (a tensor or `None`) containing the local-path encoder output. The model is responsible for assembling global and local features into the final per-image embedding. For DeepSeek-OCR, this means reshaping the global output into `[B, 272, n_embed]`, the local output into `[P, 100, n_embed]`, assembling patch grids with newline tokens, and concatenating `[patches_grid, global, view_separator]` for each image.
|
||||
**Post-processing.** The `postprocess_encoder_output` method receives an `outputs` dictionary keyed by path name. The model is responsible for assembling global and local features into the final per-image embedding. For DeepSeek-OCR, it reshapes the global output into `[B, 272, n_embed]`, reshapes the local output into `[P, 100, n_embed]`, assembles patch grids with newline tokens, and concatenating `[patches_grid, global, view_separator]` for each image.
|
||||
|
||||
!!! note
|
||||
The dual-path design enables partial CUDA graph coverage — one path can hit while the other falls back to eager. This avoids wasted compute on zero-padded patch buffers for untiled images and avoids graph invalidation caused by variable `crop_shape` per image.
|
||||
@@ -111,13 +102,13 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
|
||||
|
||||
* `get_encoder_cudagraph_config()` — returns static configuration (supported modalities, buffer keys, output hidden size, padding logics, max frames per video).
|
||||
* `get_encoder_cudagraph_budget_range(vllm_config)` — returns `(min_budget, max_budget)` for auto-inference of token budgets.
|
||||
* `get_encoder_cudagraph_item_specs(mm_kwargs)` — returns `list[EncoderItemSpec]` describing each item with its input size, total output token count (`output_tokens`), and optionally per-path token counts (`global_output_tokens`, `local_output_tokens`) for dual-path models.
|
||||
* `get_encoder_cudagraph_item_specs(mm_kwargs)` — returns `list[EncoderItemSpec]` describing each item with its input size, total output token count (`output_tokens`), and per-path token counts (`path_output_tokens`) for multi-path models.
|
||||
* `select_encoder_cudagraph_items(mm_kwargs, indices)` — extracts a sub-batch of items by index, used during greedy packing and DP sharding.
|
||||
* `prepare_encoder_cudagraph_capture_inputs(..., path="default")` — creates dummy inputs for graph capture. The `path` parameter (`"global"` or `"local"`) tells the model which path to generate dummy inputs for. Returns `EncoderCudaGraphCaptureInputs` with a single `values: dict[str, torch.Tensor]` that contains all buffers to be recorded into the graph.
|
||||
* `prepare_encoder_cudagraph_replay_buffers(mm_kwargs, max_batch_size, max_frames_per_batch, path="default")` — computes buffer values from actual batch inputs. The `path` parameter selects which modality keys to extract from `mm_kwargs`. Returns `EncoderCudaGraphReplayBuffers` with a `values` dict whose keys match the captured graph's `input_buffers.keys()`.
|
||||
* `encoder_cudagraph_forward(inputs: dict[str, torch.Tensor], path="default")` — forward pass accepting only fixed-shaped input tensors (the captured `values` dict). Called during both capture and replay. The `path` parameter dispatches to the correct encoder sub-module (e.g., global vs. local path for DeepSeek-OCR).
|
||||
* `encoder_eager_forward(mm_kwargs, path="default")` — fallback eager forward when no graph fits. When `path` is `"global"` or `"local"`, runs only that encoder path without graph capture.
|
||||
* `postprocess_encoder_output(..., local_output=None)` — post-process encoder output. The `local_output` parameter receives the local-path encoder output tensor (or `None`), enabling dual-path models to assemble global and local features into the final per-image embedding.
|
||||
* `postprocess_encoder_output(outputs, ...)` — post-process encoder outputs keyed by path name, enabling multi-path models to assemble their path-specific features into the final per-item embedding.
|
||||
|
||||
!!! note
|
||||
The `SupportsEncoderCudaGraph` protocol is designed to be model-agnostic. New vision encoder models can opt-in by implementing the protocol methods without modifying the manager.
|
||||
@@ -146,14 +137,14 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
|
||||
|
||||
## Configuration
|
||||
|
||||
Three fields in `CompilationConfig` control encoder CUDA Graphs:
|
||||
Four fields in `CompilationConfig` control encoder CUDA Graphs:
|
||||
|
||||
* `cudagraph_mm_encoder` (`bool`, default `False`) — enable CUDA Graph capture for multimodal encoder. When enabled, captures the full encoder forward as a CUDA Graph for each token budget level.
|
||||
* `encoder_cudagraph_token_budgets` (`list[int]`, default `[]`) — token budget levels for capture. If empty (default), auto-inferred from model architecture as power-of-2 levels. User-provided values override auto-inference.
|
||||
* `encoder_cudagraph_max_vision_items_per_batch` (`int`, default `0`) — maximum number of images/videos per batch during capture. If 0 (default), auto-inferred as `max_budget // min_budget`.
|
||||
* `encoder_cudagraph_max_frames_per_batch` (`int`, default `None`) — maximum number of video frames per batch during capture. If `None` (default), auto-inferred as `encoder_cudagraph_max_vision_items_per_batch * max_frames_per_video` (`max_frames_per_video` is a model-specific value from `EncoderCudaGraphConfig`, computed by `get_max_frames_per_video()` on the model). If we limit the video count per prompt to `0`, it will also be set to `0` (i.e., fall back to image-only mode).
|
||||
|
||||
Dual-path mode is configured at the model level via `EncoderCudaGraphConfig` fields (`enable_dual_path_graph`, `global_token_per_image`, `local_token_per_patch`) — no additional user configuration is required. The manager automatically generates separate budget lists and routes to dual-path execution when the model opts in.
|
||||
Multi-path mode is configured at the model level through `EncoderCudaGraphConfig.paths`. Each `EncoderCudaGraphPathConfig` can set a path-specific minimum budget and whether zero-token batches are allowed. The manager automatically generates separate budget lists and uses the same execution loop for single- and multi-path models.
|
||||
|
||||
## Usage guide
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ def _make_manager_with_budgets(budgets: list[int]) -> EncoderCudaGraphManager:
|
||||
"""
|
||||
mgr = object.__new__(EncoderCudaGraphManager)
|
||||
mgr.token_budgets = sorted(budgets)
|
||||
mgr.path_token_budgets = {"default": mgr.token_budgets}
|
||||
mgr.max_batch_size = 16
|
||||
mgr.use_dp = False
|
||||
mgr.config = EncoderCudaGraphConfig(
|
||||
@@ -417,6 +418,7 @@ def _make_manager_for_gpu(
|
||||
"""Create EncoderCudaGraphManager bypassing VllmConfig for GPU tests."""
|
||||
mgr = object.__new__(EncoderCudaGraphManager)
|
||||
mgr.token_budgets = sorted(token_budgets)
|
||||
mgr.path_token_budgets = {"default": mgr.token_budgets}
|
||||
mgr.max_batch_size = max_batch_size
|
||||
mgr.max_frames_per_batch = (
|
||||
max_frames_per_batch if max_frames_per_batch is not None else max_batch_size * 2
|
||||
|
||||
@@ -65,6 +65,7 @@ from vllm.v1.sample.logits_processor import (
|
||||
from vllm.v1.worker.encoder_cudagraph_defs import (
|
||||
EncoderCudaGraphCaptureInputs,
|
||||
EncoderCudaGraphConfig,
|
||||
EncoderCudaGraphPathConfig,
|
||||
EncoderCudaGraphReplayBuffers,
|
||||
EncoderItemSpec,
|
||||
)
|
||||
@@ -697,9 +698,15 @@ class DeepseekOCRForCausalLM(
|
||||
modalities=["image"],
|
||||
buffer_keys=["pixel_values"],
|
||||
out_hidden_size=self.projector_config.n_embed,
|
||||
enable_dual_path_graph=True,
|
||||
global_token_per_image=self.global_image_output_token,
|
||||
local_token_per_patch=self.single_patch_output_token,
|
||||
paths={
|
||||
"global": EncoderCudaGraphPathConfig(
|
||||
min_token_budget=self.global_image_output_token
|
||||
),
|
||||
"local": EncoderCudaGraphPathConfig(
|
||||
min_token_budget=self.single_patch_output_token,
|
||||
allow_zero_tokens=True,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def get_encoder_cudagraph_budget_range(
|
||||
@@ -730,8 +737,10 @@ class DeepseekOCRForCausalLM(
|
||||
EncoderItemSpec(
|
||||
input_size=num_input_tokens,
|
||||
output_tokens=num_output_tokens,
|
||||
global_output_tokens=global_output_token,
|
||||
local_output_tokens=local_output_token,
|
||||
path_output_tokens={
|
||||
"global": global_output_token,
|
||||
"local": local_output_token,
|
||||
},
|
||||
)
|
||||
)
|
||||
return item_specs
|
||||
@@ -917,32 +926,34 @@ class DeepseekOCRForCausalLM(
|
||||
|
||||
def postprocess_encoder_output(
|
||||
self,
|
||||
output: torch.Tensor,
|
||||
outputs: dict[str, torch.Tensor],
|
||||
indices: list[int],
|
||||
per_item_out_tokens: list[int],
|
||||
dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
|
||||
clone: bool = False,
|
||||
batch_mm_kwargs: dict[str, Any] | None = None,
|
||||
local_output: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Assemble per-image embeddings from global and local encoder outputs.
|
||||
|
||||
``output`` contains global-image features with newlines already
|
||||
``output['global']`` contains global-image features with newlines already
|
||||
inserted (from CUDA graph replay or eager fallback):
|
||||
``[B * 272, n_embed]``.
|
||||
|
||||
``local_output`` contains local-patch features without
|
||||
``output['local']`` contains local-patch features without
|
||||
newlines (from CUDA graph replay or eager fallback):
|
||||
``[P * 100, n_embed]``. May be ``None`` if no patches in batch.
|
||||
|
||||
This method:
|
||||
1. Splits ``output`` into per-image global portions.
|
||||
2. Splits ``local_output`` into per-image patch groups.
|
||||
1. Splits ``output['global']`` into per-image global portions.
|
||||
2. Splits ``output['local']`` into per-image patch groups.
|
||||
3. For each image: assembles patch grid with newlines via
|
||||
``_assemble_patch_grid``, then concatenates
|
||||
``[local_tiled, global, view_seperator]``.
|
||||
"""
|
||||
output = outputs["global"]
|
||||
local_output = outputs.get("local")
|
||||
assert batch_mm_kwargs is not None
|
||||
bsz = len(indices)
|
||||
n_embed = output.shape[-1]
|
||||
|
||||
|
||||
@@ -1696,13 +1696,12 @@ class SupportsEncoderCudaGraph(Protocol):
|
||||
|
||||
def postprocess_encoder_output(
|
||||
self,
|
||||
output: torch.Tensor,
|
||||
outputs: dict[str, torch.Tensor],
|
||||
indices: list[int],
|
||||
per_item_out_tokens: list[int],
|
||||
dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
|
||||
clone: bool = False,
|
||||
batch_mm_kwargs: dict[str, Any] | None = None,
|
||||
local_output: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Post-process encoder output, directly call scatter_output_slices by default.
|
||||
@@ -1714,7 +1713,9 @@ class SupportsEncoderCudaGraph(Protocol):
|
||||
"""
|
||||
from vllm.model_executor.models.utils import scatter_output_slices
|
||||
|
||||
scatter_output_slices(output, indices, per_item_out_tokens, dest, clone)
|
||||
scatter_output_slices(
|
||||
outputs["default"], indices, per_item_out_tokens, dest, clone
|
||||
)
|
||||
|
||||
def prepare_encoder_cudagraph_capture_inputs(
|
||||
self,
|
||||
|
||||
@@ -725,6 +725,7 @@ class Step3VLForConditionalGeneration(
|
||||
def get_encoder_cudagraph_config(self):
|
||||
from vllm.v1.worker.encoder_cudagraph_defs import (
|
||||
EncoderCudaGraphConfig,
|
||||
EncoderCudaGraphPathConfig,
|
||||
)
|
||||
|
||||
return EncoderCudaGraphConfig(
|
||||
@@ -734,9 +735,15 @@ class Step3VLForConditionalGeneration(
|
||||
"patch_pixel_values",
|
||||
],
|
||||
out_hidden_size=self.config.hidden_size,
|
||||
enable_dual_path_graph=True,
|
||||
global_token_per_image=self.img_output_tokens,
|
||||
local_token_per_patch=self.patch_output_tokens,
|
||||
paths={
|
||||
"global": EncoderCudaGraphPathConfig(
|
||||
min_token_budget=self.img_output_tokens
|
||||
),
|
||||
"local": EncoderCudaGraphPathConfig(
|
||||
min_token_budget=self.patch_output_tokens,
|
||||
allow_zero_tokens=True,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def get_encoder_cudagraph_budget_range(
|
||||
@@ -771,8 +778,10 @@ class Step3VLForConditionalGeneration(
|
||||
output_tokens=(
|
||||
self.img_output_tokens + num_patch * self.patch_output_tokens
|
||||
),
|
||||
global_output_tokens=self.img_output_tokens,
|
||||
local_output_tokens=num_patch * self.patch_output_tokens,
|
||||
path_output_tokens={
|
||||
"global": self.img_output_tokens,
|
||||
"local": num_patch * self.patch_output_tokens,
|
||||
},
|
||||
)
|
||||
for num_patch in num_patches
|
||||
]
|
||||
@@ -876,19 +885,21 @@ class Step3VLForConditionalGeneration(
|
||||
|
||||
def postprocess_encoder_output(
|
||||
self,
|
||||
output: torch.Tensor,
|
||||
outputs: dict[str, torch.Tensor],
|
||||
indices: list[int],
|
||||
per_item_out_tokens: list[int],
|
||||
dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
|
||||
clone: bool = False,
|
||||
batch_mm_kwargs: dict[str, Any] | None = None,
|
||||
local_output: torch.Tensor | None = None,
|
||||
):
|
||||
"""CPU-side per-item merge after dual-path graph replay.
|
||||
|
||||
``output`` contains global-image features and ``local_output``
|
||||
``outputs['global']`` contains global-image features and ``outputs['local']``
|
||||
contains local-patch features (or ``None`` when there are no patches).
|
||||
"""
|
||||
output = outputs["global"]
|
||||
local_output = outputs.get("local")
|
||||
assert batch_mm_kwargs is not None
|
||||
num_patches = batch_mm_kwargs["num_patches"]
|
||||
hidden = output.shape[-1]
|
||||
bsz = len(indices)
|
||||
@@ -899,7 +910,7 @@ class Step3VLForConditionalGeneration(
|
||||
patch_tokens = total_patches * self.patch_output_tokens
|
||||
|
||||
global_part = output[:img_tokens].reshape(bsz, self.img_output_tokens, hidden)
|
||||
if total_patches > 0:
|
||||
if total_patches > 0 and local_output is not None:
|
||||
patch_part = local_output[:patch_tokens].reshape(
|
||||
-1, self.patch_output_tokens, hidden
|
||||
)
|
||||
|
||||
+104
-323
@@ -158,34 +158,33 @@ class EncoderCudaGraphManager:
|
||||
self.graph_misses = 0
|
||||
self.log_stats_interval = 100
|
||||
|
||||
if self.config.enable_dual_path_graph:
|
||||
max_budget = self.token_budgets[-1]
|
||||
self.global_token_budgets = self._generate_budgets(
|
||||
self.config.global_token_per_image,
|
||||
max_budget,
|
||||
)
|
||||
self.local_token_budgets = self._generate_budgets(
|
||||
self.config.local_token_per_patch,
|
||||
max_budget,
|
||||
)
|
||||
# When `image_width <= 640 and image_height <= 640`, the mm inputs
|
||||
# will only contain global image, without generating local patches.
|
||||
self.local_token_budgets.insert(0, 0)
|
||||
logger.info(
|
||||
"EncoderCudaGraphManager dual-path mode: "
|
||||
"global_budgets=%s, local_budgets=%s",
|
||||
self.global_token_budgets,
|
||||
self.local_token_budgets,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"EncoderCudaGraphManager initialized with "
|
||||
"budgets=%s, max_batch_size=%d, max_frames_per_batch=%s, use_dp=%s",
|
||||
self.token_budgets,
|
||||
self.max_batch_size,
|
||||
self.max_frames_per_batch,
|
||||
self.use_dp,
|
||||
)
|
||||
max_budget = self.token_budgets[-1]
|
||||
if not self.config.paths:
|
||||
raise ValueError("Encoder CUDA graph config must define at least one path")
|
||||
self.path_token_budgets: dict[str, list[int]] = {}
|
||||
for path, path_config in self.config.paths.items():
|
||||
min_path_budget = path_config.min_token_budget
|
||||
if min_path_budget is None:
|
||||
budgets = list(self.token_budgets)
|
||||
else:
|
||||
if min_path_budget <= 0 or min_path_budget > max_budget:
|
||||
raise ValueError(
|
||||
f"Invalid minimum budget {min_path_budget} for encoder "
|
||||
f"CUDA graph path {path!r}; max budget is {max_budget}"
|
||||
)
|
||||
budgets = self._generate_budgets(min_path_budget, max_budget)
|
||||
if path_config.allow_zero_tokens:
|
||||
budgets.insert(0, 0)
|
||||
self.path_token_budgets[path] = budgets
|
||||
|
||||
logger.info(
|
||||
"EncoderCudaGraphManager initialized with paths=%s, "
|
||||
"max_batch_size=%d, max_frames_per_batch=%s, use_dp=%s",
|
||||
self.path_token_budgets,
|
||||
self.max_batch_size,
|
||||
self.max_frames_per_batch,
|
||||
self.use_dp,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_budgets(min_budget: int, max_budget: int) -> list[int]:
|
||||
@@ -211,40 +210,29 @@ class EncoderCudaGraphManager:
|
||||
self.graph_pool = None
|
||||
|
||||
def capture(self, graph_pool: Any):
|
||||
"""Capture CUDA graphs for all token budgets."""
|
||||
"""Capture CUDA graphs for every configured path and token budget."""
|
||||
self.graph_pool = graph_pool
|
||||
|
||||
if self.config.enable_dual_path_graph:
|
||||
for token_budget in sorted(self.global_token_budgets, reverse=True):
|
||||
self._capture_budget_graph(token_budget, path="global")
|
||||
for token_budget in sorted(self.local_token_budgets, reverse=True):
|
||||
if token_budget == 0:
|
||||
continue
|
||||
self._capture_budget_graph(token_budget, path="local")
|
||||
logger.info(
|
||||
"Encoder CUDA graph capture complete. "
|
||||
"Captured %d global + %d local budget graphs.",
|
||||
len(self.budget_graphs["global"]),
|
||||
len(self.budget_graphs["local"]),
|
||||
)
|
||||
return
|
||||
|
||||
for token_budget in sorted(self.token_budgets, reverse=True):
|
||||
self._capture_budget_graph(token_budget)
|
||||
for path, budgets in self.path_token_budgets.items():
|
||||
for token_budget in sorted(budgets, reverse=True):
|
||||
if token_budget > 0:
|
||||
self._capture_budget_graph(token_budget, path=path)
|
||||
|
||||
logger.info(
|
||||
"Encoder CUDA graph capture complete. Captured %d budget graphs.",
|
||||
len(self.budget_graphs["default"]),
|
||||
"Encoder CUDA graph capture complete. Captured %d graphs across %d paths.",
|
||||
self.get_num_graphs_to_capture(),
|
||||
len(self.path_token_budgets),
|
||||
)
|
||||
|
||||
def get_num_graphs_to_capture(self) -> int:
|
||||
if self.config.enable_dual_path_graph:
|
||||
return len(self.global_token_budgets) + len(self.local_token_budgets)
|
||||
return len(self.token_budgets)
|
||||
return sum(
|
||||
token_budget > 0
|
||||
for budgets in self.path_token_budgets.values()
|
||||
for token_budget in budgets
|
||||
)
|
||||
|
||||
def _get_graph_set(self, path: str = "default") -> dict[int, BudgetGraphMetadata]:
|
||||
# Lazy init global/local graph sets for dual-path models, or default graph
|
||||
# set for single-path models.
|
||||
"""Return the captured graphs for one encoder path."""
|
||||
if path not in self.budget_graphs:
|
||||
self.budget_graphs[path] = {}
|
||||
return self.budget_graphs[path]
|
||||
@@ -331,7 +319,7 @@ class EncoderCudaGraphManager:
|
||||
Args:
|
||||
mm_kwargs: Multimodal inputs for the batch.
|
||||
token_budget: Token budget to use.
|
||||
path: Path for the graph. Should be one of ["default", "global", "local"].
|
||||
path: Configured encoder path.
|
||||
Returns:
|
||||
Encoder outputs, or None if graph not captured.
|
||||
"""
|
||||
@@ -351,9 +339,7 @@ class EncoderCudaGraphManager:
|
||||
path,
|
||||
)
|
||||
|
||||
# Copy replay buffers into graph input buffers. Iterate over the
|
||||
# graph's own buffer keys (which may differ per path for dual-path
|
||||
# models) rather than the global config.buffer_keys.
|
||||
# Copy replay values into the buffers recorded for this path.
|
||||
for key, buf in graph_meta.input_buffers.items():
|
||||
src = replay.values.get(key)
|
||||
if src is None:
|
||||
@@ -375,296 +361,91 @@ class EncoderCudaGraphManager:
|
||||
self,
|
||||
mm_kwargs: dict[str, Any],
|
||||
) -> list[torch.Tensor]:
|
||||
"""Execute encoder on local inputs using greedy-packed CUDA graphs.
|
||||
|
||||
Sort images by output token count (smallest first), then greedily pack
|
||||
as many images as possible into each batch while staying within
|
||||
max_budget tokens and max_batch_size. Once a batch is finalised (next
|
||||
image would overflow either constraint), find the smallest fitting
|
||||
budget once for that batch.
|
||||
|
||||
For dual-path models (``enable_dual_path_graph=True``), two independent
|
||||
graph sets are used: one for global images, one for local patches.
|
||||
Budgets are found independently per path; if only one path fits, the
|
||||
other falls back to eager via partial fallback.
|
||||
|
||||
By exchange argument, greedy smallest-first packing minimises eager
|
||||
fallbacks -- any other ordering yields a higher token sum in some batch,
|
||||
making that batch more likely to exceed the budget.
|
||||
|
||||
Stats note:
|
||||
graph_hits -- counted inside _run_budget_graph after successful replay.
|
||||
graph_misses -- counted here for single-image batches where the image
|
||||
exceeds max_budget. Batches split due to max_batch_size
|
||||
always satisfy total_tokens <= max_budget and therefore
|
||||
always find a valid budget (no miss).
|
||||
"""
|
||||
if self.config.enable_dual_path_graph:
|
||||
return self._execute_local_dual_path(mm_kwargs)
|
||||
return self._execute_local_single_path(mm_kwargs)
|
||||
|
||||
def _execute_local_single_path(
|
||||
self,
|
||||
mm_kwargs: dict[str, Any],
|
||||
) -> list[torch.Tensor]:
|
||||
"""Single-path greedy-packing execution (original behaviour)."""
|
||||
"""Execute locally using greedy packing across all configured paths."""
|
||||
item_specs = self._get_item_specs(mm_kwargs)
|
||||
num_items = len(item_specs)
|
||||
max_budget = self.token_budgets[-1]
|
||||
|
||||
paths = tuple(self.path_token_budgets)
|
||||
per_item_path_tokens = {
|
||||
path: [spec.get_path_output_tokens(path) for spec in item_specs]
|
||||
for path in paths
|
||||
}
|
||||
per_item_out_tokens = [spec.output_tokens for spec in item_specs]
|
||||
max_path_budgets = {path: max(self.path_token_budgets[path]) for path in paths}
|
||||
|
||||
# Sort ascending by output token count (smallest first)
|
||||
sorted_indices = sorted(range(num_items), key=lambda i: per_item_out_tokens[i])
|
||||
|
||||
# Greedy pack against max_budget and max_batch_size.
|
||||
# _find_smallest_fitting_budget_given_tokens is called once per
|
||||
# finalised batch, not per image.
|
||||
batches: list[tuple[list[int], int | None]] = []
|
||||
batches: list[tuple[list[int], dict[str, int | None]]] = []
|
||||
current_batch: list[int] = []
|
||||
current_batch_tokens = 0
|
||||
current_tokens = dict.fromkeys(paths, 0)
|
||||
|
||||
def append_current_batch() -> None:
|
||||
if not current_batch:
|
||||
return
|
||||
path_budgets = {
|
||||
path: (
|
||||
0
|
||||
if current_tokens[path] == 0
|
||||
else self._find_smallest_fitting_budget_given_tokens(
|
||||
current_tokens[path], self.path_token_budgets[path]
|
||||
)
|
||||
)
|
||||
for path in paths
|
||||
}
|
||||
batches.append((list(current_batch), path_budgets))
|
||||
|
||||
for orig_idx in sorted_indices:
|
||||
item_tokens = per_item_out_tokens[orig_idx]
|
||||
if (
|
||||
current_batch_tokens + item_tokens <= max_budget
|
||||
and len(current_batch) < self.max_batch_size
|
||||
):
|
||||
current_batch.append(orig_idx)
|
||||
current_batch_tokens += item_tokens
|
||||
else:
|
||||
if current_batch:
|
||||
batches.append(
|
||||
(
|
||||
current_batch,
|
||||
self._find_smallest_fitting_budget_given_tokens(
|
||||
current_batch_tokens
|
||||
),
|
||||
)
|
||||
)
|
||||
current_batch = [orig_idx]
|
||||
current_batch_tokens = item_tokens
|
||||
|
||||
if current_batch:
|
||||
batches.append(
|
||||
(
|
||||
current_batch,
|
||||
self._find_smallest_fitting_budget_given_tokens(
|
||||
current_batch_tokens
|
||||
),
|
||||
)
|
||||
item_tokens = {path: per_item_path_tokens[path][orig_idx] for path in paths}
|
||||
fits = len(current_batch) < self.max_batch_size and all(
|
||||
current_tokens[path] + item_tokens[path] <= max_path_budgets[path]
|
||||
for path in paths
|
||||
)
|
||||
if current_batch and not fits:
|
||||
append_current_batch()
|
||||
current_batch = []
|
||||
current_tokens = dict.fromkeys(paths, 0)
|
||||
|
||||
# outputs_by_orig_idx maps each original image index to its output
|
||||
# tensor. Needed because greedy packing reorders images; we restore
|
||||
# the original order before returning.
|
||||
outputs_by_orig_idx: dict[int, torch.Tensor] = {}
|
||||
current_batch.append(orig_idx)
|
||||
for path in paths:
|
||||
current_tokens[path] += item_tokens[path]
|
||||
|
||||
for batch_orig_indices, token_budget in batches:
|
||||
batch_mm_kwargs = self.model.select_encoder_cudagraph_items(
|
||||
mm_kwargs, batch_orig_indices
|
||||
)
|
||||
batch_out_tokens = sum(per_item_out_tokens[i] for i in batch_orig_indices)
|
||||
|
||||
if token_budget is None:
|
||||
# Single oversized image: item_tokens > max_budget.
|
||||
# graph_misses counted here for this eager fallback.
|
||||
logger.debug(
|
||||
"Encoder CUDA graph fallback to eager: no budget for "
|
||||
"%d tokens from %d images",
|
||||
batch_out_tokens,
|
||||
len(batch_orig_indices),
|
||||
)
|
||||
self.graph_misses += len(batch_orig_indices)
|
||||
with torch.inference_mode():
|
||||
raw = self.model.encoder_eager_forward(batch_mm_kwargs)
|
||||
scatter_output_slices(
|
||||
raw,
|
||||
batch_orig_indices,
|
||||
per_item_out_tokens,
|
||||
outputs_by_orig_idx,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Encoder CUDA graph: batch_size=%d, tokens=%d, "
|
||||
"budget=%d, waste=%.1f%%",
|
||||
len(batch_orig_indices),
|
||||
batch_out_tokens,
|
||||
token_budget,
|
||||
(token_budget - batch_out_tokens) / token_budget * 100,
|
||||
)
|
||||
|
||||
# graph_hits counted inside _run_budget_graph after replay.
|
||||
output = self._run_budget_graph(batch_mm_kwargs, token_budget)
|
||||
assert output is not None
|
||||
self.model.postprocess_encoder_output(
|
||||
output,
|
||||
batch_orig_indices,
|
||||
per_item_out_tokens,
|
||||
outputs_by_orig_idx,
|
||||
clone=True,
|
||||
batch_mm_kwargs=batch_mm_kwargs,
|
||||
)
|
||||
|
||||
# Return in original batch order (caller maps outputs to token positions)
|
||||
return [outputs_by_orig_idx[i] for i in range(num_items)]
|
||||
|
||||
def _execute_local_dual_path(
|
||||
self,
|
||||
mm_kwargs: dict[str, Any],
|
||||
) -> list[torch.Tensor]:
|
||||
"""Dual-path greedy-packing execution.
|
||||
|
||||
Each image contributes both global tokens (constant per image)
|
||||
and local tokens (patches * patch_tokens). Greedy packing
|
||||
respects both budgets independently, then selects the smallest
|
||||
fitting budget per path with partial eager fallback.
|
||||
"""
|
||||
item_specs = self._get_item_specs(mm_kwargs)
|
||||
num_items = len(item_specs)
|
||||
|
||||
max_global_budget = self.global_token_budgets[-1]
|
||||
max_local_budget = self.local_token_budgets[-1]
|
||||
|
||||
per_item_global_tokens = [spec.global_output_tokens for spec in item_specs]
|
||||
per_item_local_tokens = [spec.local_output_tokens for spec in item_specs]
|
||||
per_item_total_tokens = [spec.output_tokens for spec in item_specs]
|
||||
|
||||
# Sort ascending by total output tokens
|
||||
sorted_indices = sorted(
|
||||
range(num_items), key=lambda i: per_item_total_tokens[i]
|
||||
)
|
||||
|
||||
# Each batch is a tuple of (indices, global_budget, local_budget).
|
||||
batches: list[tuple[list[int], int | None, int | None]] = []
|
||||
current_batch: list[int] = []
|
||||
current_global_tokens = 0
|
||||
current_local_tokens = 0
|
||||
|
||||
for orig_idx in sorted_indices:
|
||||
global_token = per_item_global_tokens[orig_idx]
|
||||
local_token = per_item_local_tokens[orig_idx]
|
||||
if (
|
||||
current_global_tokens + global_token <= max_global_budget
|
||||
and current_local_tokens + local_token <= max_local_budget
|
||||
and len(current_batch) < self.max_batch_size
|
||||
):
|
||||
current_batch.append(orig_idx)
|
||||
current_global_tokens += global_token
|
||||
current_local_tokens += local_token
|
||||
else:
|
||||
if current_batch:
|
||||
batches.append(
|
||||
(
|
||||
current_batch,
|
||||
self._find_smallest_fitting_budget_given_tokens(
|
||||
current_global_tokens, self.global_token_budgets
|
||||
),
|
||||
self._find_smallest_fitting_budget_given_tokens(
|
||||
current_local_tokens, self.local_token_budgets
|
||||
),
|
||||
)
|
||||
)
|
||||
current_batch = [orig_idx]
|
||||
current_global_tokens = global_token
|
||||
current_local_tokens = local_token
|
||||
|
||||
if current_batch:
|
||||
batches.append(
|
||||
(
|
||||
current_batch,
|
||||
self._find_smallest_fitting_budget_given_tokens(
|
||||
current_global_tokens, self.global_token_budgets
|
||||
),
|
||||
self._find_smallest_fitting_budget_given_tokens(
|
||||
current_local_tokens, self.local_token_budgets
|
||||
),
|
||||
)
|
||||
)
|
||||
append_current_batch()
|
||||
|
||||
outputs_by_orig_idx: dict[int, torch.Tensor] = {}
|
||||
|
||||
for batch_orig_indices, global_budget, local_budget in batches:
|
||||
for batch_indices, path_budgets in batches:
|
||||
batch_mm_kwargs = self.model.select_encoder_cudagraph_items(
|
||||
mm_kwargs, batch_orig_indices
|
||||
)
|
||||
batch_global_tokens = sum(
|
||||
per_item_global_tokens[i] for i in batch_orig_indices
|
||||
)
|
||||
batch_local_tokens = sum(
|
||||
per_item_local_tokens[i] for i in batch_orig_indices
|
||||
mm_kwargs, batch_indices
|
||||
)
|
||||
graph_outputs: dict[str, torch.Tensor] = {}
|
||||
all_eager = True
|
||||
|
||||
both_eager = global_budget is None and local_budget is None
|
||||
for path in paths:
|
||||
path_tokens = sum(per_item_path_tokens[path][i] for i in batch_indices)
|
||||
if path_tokens == 0:
|
||||
continue
|
||||
|
||||
if both_eager:
|
||||
logger.debug(
|
||||
"Encoder CUDA graph dual-path full eager fallback: "
|
||||
"%d global + %d local tokens from %d images",
|
||||
batch_global_tokens,
|
||||
batch_local_tokens,
|
||||
len(batch_orig_indices),
|
||||
)
|
||||
self.graph_misses += len(batch_orig_indices)
|
||||
with torch.inference_mode():
|
||||
raw = self.model.encoder_eager_forward(batch_mm_kwargs)
|
||||
per_item_total = [
|
||||
per_item_global_tokens[i] + per_item_local_tokens[i] + 1
|
||||
for i in batch_orig_indices
|
||||
]
|
||||
scatter_output_slices(
|
||||
raw, batch_orig_indices, per_item_total, outputs_by_orig_idx
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Encoder CUDA graph dual-path: batch_size=%d, "
|
||||
"global=%d (budget=%s), local=%d (budget=%s)",
|
||||
len(batch_orig_indices),
|
||||
batch_global_tokens,
|
||||
global_budget,
|
||||
batch_local_tokens,
|
||||
local_budget,
|
||||
)
|
||||
|
||||
# Execute global path: graph or eager fallback
|
||||
if global_budget is not None:
|
||||
global_output = self._run_budget_graph(
|
||||
batch_mm_kwargs,
|
||||
global_budget,
|
||||
path="global",
|
||||
)
|
||||
assert global_output is not None
|
||||
else:
|
||||
with torch.inference_mode():
|
||||
global_output = self.model.encoder_eager_forward(
|
||||
batch_mm_kwargs, path="global"
|
||||
token_budget = path_budgets[path]
|
||||
if token_budget is None:
|
||||
with torch.inference_mode():
|
||||
output = self.model.encoder_eager_forward(
|
||||
batch_mm_kwargs, path=path
|
||||
)
|
||||
else:
|
||||
all_eager = False
|
||||
output = self._run_budget_graph(
|
||||
batch_mm_kwargs, token_budget, path=path
|
||||
)
|
||||
assert output is not None
|
||||
graph_outputs[path] = output
|
||||
|
||||
# Execute local path: graph or eager fallback
|
||||
if local_budget is not None and batch_local_tokens > 0:
|
||||
local_output = self._run_budget_graph(
|
||||
batch_mm_kwargs,
|
||||
local_budget,
|
||||
path="local",
|
||||
)
|
||||
assert local_output is not None
|
||||
elif batch_local_tokens > 0:
|
||||
with torch.inference_mode():
|
||||
local_output = self.model.encoder_eager_forward(
|
||||
batch_mm_kwargs, path="local"
|
||||
)
|
||||
else:
|
||||
local_output = None
|
||||
if all_eager:
|
||||
self.graph_misses += len(batch_indices)
|
||||
|
||||
self.model.postprocess_encoder_output(
|
||||
global_output,
|
||||
batch_orig_indices,
|
||||
per_item_global_tokens,
|
||||
graph_outputs,
|
||||
batch_indices,
|
||||
per_item_out_tokens,
|
||||
outputs_by_orig_idx,
|
||||
clone=True,
|
||||
batch_mm_kwargs=batch_mm_kwargs,
|
||||
local_output=local_output,
|
||||
)
|
||||
|
||||
return [outputs_by_orig_idx[i] for i in range(num_items)]
|
||||
|
||||
@@ -26,13 +26,28 @@ class EncoderItemSpec:
|
||||
"""Number of output tokens after encoder processing (e.g. after
|
||||
spatial merge)."""
|
||||
|
||||
global_output_tokens: int = 0
|
||||
"""Number of output tokens from the global image path.
|
||||
Only used when ``EncoderCudaGraphConfig.enable_dual_path_graph`` is True."""
|
||||
path_output_tokens: dict[str, int] = field(default_factory=dict)
|
||||
"""Per-path output token counts for multi-path encoders.
|
||||
|
||||
local_output_tokens: int = 0
|
||||
"""Number of output tokens from the local patch path.
|
||||
Only used when ``EncoderCudaGraphConfig.enable_dual_path_graph`` is True."""
|
||||
Single-path encoders leave this empty and use ``output_tokens`` for the
|
||||
default path.
|
||||
"""
|
||||
|
||||
def get_path_output_tokens(self, path: str) -> int:
|
||||
if path == "default" and not self.path_output_tokens:
|
||||
return self.output_tokens
|
||||
return self.path_output_tokens.get(path, 0)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncoderCudaGraphPathConfig:
|
||||
"""Capture policy for one independently replayable encoder path."""
|
||||
|
||||
min_token_budget: int | None = None
|
||||
"""Smallest capture budget, or the model default minimum when unset."""
|
||||
|
||||
allow_zero_tokens: bool = False
|
||||
"""Whether a batch may omit this path entirely."""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -68,17 +83,10 @@ class EncoderCudaGraphConfig:
|
||||
Only relevant when "video" is in ``modalities``.
|
||||
Image-only models can use the default of 1."""
|
||||
|
||||
enable_dual_path_graph: bool = False
|
||||
"""If True, the manager captures two independent graph sets
|
||||
(global + local) and runs dual-path graph selection during inference."""
|
||||
|
||||
global_token_per_image: int = 0
|
||||
"""Tokens per global image (e.g. 272 for DeepSeek-OCR).
|
||||
Only used when ``enable_dual_path_graph`` is True."""
|
||||
|
||||
local_token_per_patch: int = 0
|
||||
"""Tokens per local patch (e.g. 100 for DeepSeek-OCR).
|
||||
Only used when ``enable_dual_path_graph`` is True."""
|
||||
paths: dict[str, EncoderCudaGraphPathConfig] = field(
|
||||
default_factory=lambda: {"default": EncoderCudaGraphPathConfig()}
|
||||
)
|
||||
"""Independently captured encoder paths keyed by their forward name."""
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
Reference in New Issue
Block a user