[FullCG] Support chunked cached-prefix prefill (#30825)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
paulzhang-tm
2026-07-28 11:20:25 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 1eee8fbdcc
commit 4e5a05148a
10 changed files with 746 additions and 37 deletions
@@ -105,6 +105,27 @@ class AttentionBackend(ABC):
# object during capture, and refresh its dynamic fields before each replay.
use_captured_forward_metadata_for_breakable_cuda_graph: bool = False
# Chunked-prefix FullCG capture has a second model topology and stable
# prefix buffers. Backends must opt in explicitly so the runner does not
# assume that generic ForwardBatch metadata is sufficient for every
# attention implementation.
supports_full_cuda_graph_chunked_prefix: bool = False
def prepare_full_cuda_graph_chunked_prefix(
self,
forward_batch: ForwardBatch,
*,
in_capture: bool,
) -> None:
"""Prepare backend-private metadata for chunked-prefix FullCG.
Only called for backends that set
``supports_full_cuda_graph_chunked_prefix``; the runner validates the
flag up front. The runner owns and refreshes the shared ForwardBatch
prefix buffers. Backends that need wrappers or other derived metadata
should override this hook for both capture and replay.
"""
def init_cuda_graph_state(self, max_bs: int, max_num_tokens: int):
"""Init the global shared states for cuda graph."""
raise NotImplementedError()
@@ -136,6 +136,10 @@ class FlashAttentionBackend(AttentionBackend):
needs_cpu_seq_lens: bool = False
supports_ragged_verify_graph: bool = True
# Chunked-prefix attention reads the stable ForwardBatch cu-seqlens and
# KV-index buffers directly, so it needs no backend-private replay state.
supports_full_cuda_graph_chunked_prefix = True
def __init__(
self,
model_runner: ModelRunner,
@@ -63,6 +63,20 @@ class HybridAttnBackend(AttentionBackend):
else:
return self.prefill_backend
@property
def supports_full_cuda_graph_chunked_prefix(self) -> bool:
return self.prefill_backend.supports_full_cuda_graph_chunked_prefix
def prepare_full_cuda_graph_chunked_prefix(
self,
forward_batch: ForwardBatch,
*,
in_capture: bool,
) -> None:
self.prefill_backend.prepare_full_cuda_graph_chunked_prefix(
forward_batch, in_capture=in_capture
)
def init_forward_metadata_out_graph(
self,
forward_batch: ForwardBatch,
@@ -66,12 +66,20 @@ ALLOWED_BACKENDS_PER_PHASE = {
# Per-phase settings schema. Keys other than backend are runner-level
# (read by any backend in that phase); tc_compiler is the lone
# backend-specific knob (only meaningful when backend == tc_piecewise).
# For prefill, bs carries the captured shape size (token count for
# For prefill, bs carries the captured shape size (token count for Full and
# tc_piecewise, request count for breakable) — one shape knob per phase.
# full_prefill_max_req is prefill-only and only meaningful when backend == full.
# full_prefill_max_req and full_prefill_prefix_chunk_tokens are prefill-only and
# only meaningful when backend == full.
ALLOWED_KEYS_PER_PHASE = {
Phase.DECODE: ("backend", "max_bs", "bs", "tc_compiler"),
Phase.PREFILL: ("backend", "max_bs", "bs", "tc_compiler", "full_prefill_max_req"),
Phase.PREFILL: (
"backend",
"max_bs",
"bs",
"tc_compiler",
"full_prefill_max_req",
"full_prefill_prefix_chunk_tokens",
),
}
@@ -90,6 +98,12 @@ class PhaseConfig:
# batches fall back to eager. Ignored by BCG (bs=1 only) and TC_PIECEWISE
# (bs-invariant via torch.compile). None auto-derives chunked_prefill_size // 512.
full_prefill_max_req: Optional[int] = None
# Only meaningful for Full prefill CUDA graphs that capture a distinct
# cached-prefix topology: aggregate cached-prefix tokens represented by one
# fixed-capacity chunk across all request slots. FullCG captures 1/2/4/8/16
# chunk variants and chooses the smallest one covering a batch. None uses
# the scheduler's aggregate chunked_prefill_size token budget.
full_prefill_prefix_chunk_tokens: Optional[int] = None
def default_prefill_backend() -> str:
@@ -41,11 +41,15 @@ import copy
import inspect
import logging
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import torch
import tqdm
from sglang.kernels.ops.kvcache.kv_indices import (
create_chunked_prefix_cache_kv_indices,
)
from sglang.srt.distributed.parallel_state import graph_capture
from sglang.srt.layers.attention.dsa.utils import is_dsa_enable_prefill_cp
from sglang.srt.layers.dp_attention import (
@@ -118,6 +122,17 @@ logger = logging.getLogger(__name__)
# lists can otherwise turn the lower launch overhead into substantially more
# model work than an exact-shape eager forward.
_MAX_PREFILL_CUDA_GRAPH_PADDING_FACTOR = 2
# Prefix attention adds one loop body per chunk to the captured topology, so
# capture a small geometric set and round each replay up to the nearest one.
_CHUNKED_PREFIX_VARIANTS = (1, 2, 4, 8, 16)
def _chunked_prefix_variant(num_chunks: int) -> str:
return f"chunked_prefix:{num_chunks}"
def _ceil_div(a: int, b: int) -> int:
return -(-a // b)
def _slice_output_rows(output: Any, num_tokens: int) -> Any:
@@ -139,6 +154,22 @@ def _slice_output_rows(output: Any, num_tokens: int) -> Any:
raise TypeError(f"Unsupported full prefill CUDA graph output: {type(output)}")
@dataclass(frozen=True)
class _ChunkedPrefixCaptureBuffers:
"""Runner-owned tensors shared by every prefix and token-bucket variant.
``starts`` / ``starts_cpu`` are constant (chunk ``c`` always starts at
``c * prefix_chunk_len``) and filled once at allocation.
"""
starts: torch.Tensor # (max_chunks, req_slots)
seq_lens: torch.Tensor # (max_chunks, req_slots)
cu_seq_lens: torch.Tensor # (max_chunks, req_slots + 1)
starts_cpu: torch.Tensor
seq_lens_cpu: torch.Tensor
kv_indices: torch.Tensor # (max_chunks, prefix_chunk_capacity)
def prefill_failure_msg(backend_name: str) -> str:
"""Render PREFILL_CUDA_GRAPH_CAPTURE_FAILED_MSG with a backend-specific
numbered suggestion list. The runner is only constructed for BREAKABLE
@@ -308,7 +339,6 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
# Auto: scale request slots with the chunked prefill size.
max_req = max(model_runner.server_args.chunked_prefill_size // 512, 1)
self._capture_req_slots = min(max_req, self.max_bs)
# BCG/Full record LoRA kernels, so the metadata they read must live in
# static buffers refreshed in place per batch; unsupported LoRA
# configs were already routed to the eager runner.
@@ -331,24 +361,62 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
)
self._capture_req_slots = lora_max_bs
if self._is_full_backend:
self._full_cg_seq_lens_cpu = torch.zeros(
(self._capture_req_slots,), dtype=torch.int64, device="cpu"
self._full_cg_seq_lens_cpu = (
torch.zeros((self._capture_req_slots,), dtype=torch.int64, device="cpu")
if self._is_full_backend
else None
)
# This flag controls whether the model dispatches through the distinct
# chunked-prefix topology; backend capability is validated separately.
self._capture_chunked_prefix = (
self._is_full_backend
and not model_runner.server_args.disable_chunked_prefix_cache
)
self._prefix_chunk_len = 0
self._prefix_chunk_capacity = 0
self._prefix_max_len = 0
self._prefix_capture_variants: tuple[int, ...] = ()
self._prefix_capture_batches: Dict[ShapeKey, ForwardBatch] = {}
self._prefix_capture_buffers: Optional[_ChunkedPrefixCaptureBuffers] = None
if self._capture_chunked_prefix:
attn_backend = model_runner.attn_backend
assert attn_backend.supports_full_cuda_graph_chunked_prefix, (
f"{type(attn_backend).__name__} does not support chunked-prefix "
"Full prefill CUDA graphs"
)
prefix_config = model_runner.server_args.cuda_graph_config.prefill
(
self._prefix_chunk_len,
self._prefix_chunk_capacity,
) = self._resolve_prefix_chunk_shape(model_runner, self._capture_req_slots)
self._prefix_max_len = self._max_addressable_prefix_len(model_runner)
max_real_chunks = _ceil_div(self._prefix_max_len, self._prefix_chunk_len)
# Variants are geometric, so n // 2 is the next-smaller variant; keep
# n only if the smaller variant does not already cover the max prefix.
self._prefix_capture_variants = tuple(
n for n in _CHUNKED_PREFIX_VARIANTS if n // 2 < max_real_chunks
)
self._prefix_capture_buffers = self._create_chunked_prefix_buffers()
logger.info(
"Full prefill CUDA graph cached-prefix chunks: "
"%d aggregate tokens/chunk (%d/request x %d slots), "
"variants=%s (%s)",
self._prefix_chunk_capacity,
self._prefix_chunk_len,
self._capture_req_slots,
self._prefix_capture_variants,
(
"configured"
if prefix_config.full_prefill_prefix_chunk_tokens is not None
else "auto from chunked_prefill_size"
),
)
if isinstance(self.backend, (BreakableCudaGraphBackend, FullCudaGraphBackend)):
with torch.device(self.device):
self._prefill_static_buffers = {
name: torch.zeros((self.max_bs,), dtype=torch.int64)
for name in _PREFILL_STATIC_FIELDS
}
elif isinstance(self.backend, BreakableCudaGraphBackend):
self._full_cg_seq_lens_cpu = None
with torch.device(self.device):
self._prefill_static_buffers = {
name: torch.zeros((self.max_bs,), dtype=torch.int64)
for name in _PREFILL_STATIC_FIELDS
}
else:
self._full_cg_seq_lens_cpu = None
# Static hidden_states buffer giving the captured graph a stable
# address; load_batch refreshes it from live spec_info at replay.
@@ -649,6 +717,197 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
int(num_tokens) == 0 for num_tokens in global_num_tokens
)
@staticmethod
def _has_prefix_hit(forward_batch: ForwardBatch) -> bool:
prefix_lens = forward_batch.extend_prefix_lens_cpu
return prefix_lens is not None and any(
int(length) > 0 for length in prefix_lens
)
@staticmethod
def _max_addressable_prefix_len(model_runner) -> int:
table_width = model_runner.req_to_token_pool.req_to_token.shape[1]
configured_context = model_runner.server_args.context_length
return (
min(table_width, configured_context)
if configured_context is not None and configured_context > 0
else table_width
)
@staticmethod
def _resolve_prefix_chunk_shape(
model_runner, capture_req_slots: int
) -> tuple[int, int]:
"""Resolve per-request length and aggregate capacity of one chunk."""
prefix_config = model_runner.server_args.cuda_graph_config.prefill
requested_capacity = prefix_config.full_prefill_prefix_chunk_tokens
if requested_capacity is None:
requested_capacity = model_runner.server_args.chunked_prefill_size
if requested_capacity is None or requested_capacity <= 0:
requested_capacity = prefix_config.max_bs
if requested_capacity is None or requested_capacity <= 0:
raise ValueError("full_prefill_prefix_chunk_tokens must be positive")
max_addressable_prefix_tokens = (
PrefillCudaGraphRunner._max_addressable_prefix_len(model_runner)
* capture_req_slots
)
requested_capacity = min(requested_capacity, max_addressable_prefix_tokens)
# Divide before allocating so increasing the number of request slots
# does not multiply the aggregate workspace represented by one chunk.
prefix_chunk_len = max(requested_capacity // capture_req_slots, 1)
return prefix_chunk_len, prefix_chunk_len * capture_req_slots
def _select_prefix_capture_chunks(
self, forward_batch: ForwardBatch
) -> Optional[int]:
"""Smallest captured variant covering the batch's max prefix, or None."""
max_prefix_len = max(
int(length) for length in forward_batch.extend_prefix_lens_cpu
)
real_n = _ceil_div(max_prefix_len, self._prefix_chunk_len)
return next((n for n in self._prefix_capture_variants if n >= real_n), None)
def _shape_key(self, num_tokens: int, forward_batch: ForwardBatch) -> ShapeKey:
variant = None
if self._capture_chunked_prefix and self._has_prefix_hit(forward_batch):
captured_n = self._select_prefix_capture_chunks(forward_batch)
assert captured_n is not None, "prefix batch has no captured FullCG variant"
variant = _chunked_prefix_variant(captured_n)
return ShapeKey(size=num_tokens, variant_label=variant)
def _create_chunked_prefix_buffers(self) -> _ChunkedPrefixCaptureBuffers:
"""Allocate the stable chunk-metadata tensors shared by all variants."""
max_chunks = max(self._prefix_capture_variants)
bs = self._capture_req_slots
starts_cpu = (
(torch.arange(max_chunks, dtype=torch.int32) * self._prefix_chunk_len)
.unsqueeze(1)
.repeat(1, bs)
)
return _ChunkedPrefixCaptureBuffers(
starts=starts_cpu.to(self.device, copy=True),
seq_lens=torch.zeros(
(max_chunks, bs), dtype=torch.int32, device=self.device
),
cu_seq_lens=torch.zeros(
(max_chunks, bs + 1), dtype=torch.int32, device=self.device
),
starts_cpu=starts_cpu,
seq_lens_cpu=torch.zeros((max_chunks, bs), dtype=torch.int32),
kv_indices=torch.zeros(
(max_chunks, self._prefix_chunk_capacity),
dtype=torch.int32,
device=self.device,
),
)
def _prepare_chunked_prefix_capture(
self,
forward_batch: ForwardBatch,
shape_key: ShapeKey,
captured_n: int,
) -> None:
"""Attach fixed-capacity, runner-owned prefix buffers for capture."""
assert self._prefill_static_buffers is not None
buffers = self._prefix_capture_buffers
assert buffers is not None
bs = self._capture_req_slots
prefix_len = min(self._prefix_chunk_len * captured_n, self._prefix_max_len)
prefix_lens_cpu = [prefix_len] * bs
self._prefill_static_buffers["extend_prefix_lens"][:bs].fill_(prefix_len)
self._populate_chunked_prefix_buffers(
captured_n=captured_n, prefix_lens_cpu=prefix_lens_cpu
)
forward_batch.extend_prefix_lens_cpu = prefix_lens_cpu
forward_batch.prefix_chunk_len = self._prefix_chunk_len
forward_batch.num_prefix_chunks = captured_n
forward_batch.prefix_chunk_idx = -1
forward_batch.prefix_chunk_starts = buffers.starts
forward_batch.prefix_chunk_starts_cpu = buffers.starts_cpu
forward_batch.prefix_chunk_seq_lens = buffers.seq_lens
forward_batch.prefix_chunk_seq_lens_cpu = buffers.seq_lens_cpu
forward_batch.prefix_chunk_cu_seq_lens = buffers.cu_seq_lens
forward_batch.prefix_chunk_max_seq_lens = [self._prefix_chunk_len] * captured_n
# Replay may pad request slots with zero-length prefixes. Capture the
# conservative branch needed by backends that fix up zero-KV rows.
forward_batch.prefix_chunk_has_zero_kv = [True] * captured_n
forward_batch.prefix_chunk_num_tokens = [
self._prefix_chunk_capacity
] * captured_n
forward_batch.prefix_chunk_kv_indices = list(
buffers.kv_indices[:captured_n].unbind(0)
)
self._prefix_capture_batches[shape_key] = forward_batch
self.model_runner.attn_backend.prepare_full_cuda_graph_chunked_prefix(
forward_batch, in_capture=True
)
def _populate_chunked_prefix_buffers(
self,
*,
captured_n: int,
prefix_lens_cpu: list[int],
) -> None:
"""Refresh shared metadata/KV indices and zero rounded-up chunks."""
assert self._prefill_static_buffers is not None
buffers = self._prefix_capture_buffers
assert buffers is not None
bs = self._capture_req_slots
assert len(prefix_lens_cpu) == bs
real_n = _ceil_div(max(prefix_lens_cpu), self._prefix_chunk_len)
assert real_n <= captured_n
# Chunk starts are constant; only the lengths change. Compute once on
# CPU and copy into the stable CPU/GPU buffers the graph reads.
prefix_lens = torch.tensor(prefix_lens_cpu, dtype=torch.int32)
seq_lens = (prefix_lens - buffers.starts_cpu[:captured_n]).clamp(
min=0, max=self._prefix_chunk_len
)
cu_seq_lens = torch.zeros((captured_n, bs + 1), dtype=torch.int32)
cu_seq_lens[:, 1:] = seq_lens.cumsum(dim=1)
buffers.seq_lens_cpu[:captured_n].copy_(seq_lens)
buffers.seq_lens[:captured_n].copy_(seq_lens)
buffers.cu_seq_lens[:captured_n].copy_(cu_seq_lens)
req_to_token = self.model_runner.req_to_token_pool.req_to_token
# The kernel reads all request slots, so use the slot-padded static
# buffer (arange at capture, live indices + zeroed tail at replay).
req_pool_indices = self._prefill_static_buffers["req_pool_indices"][:bs]
buffers.kv_indices[:captured_n].zero_()
for chunk_idx in range(real_n):
create_chunked_prefix_cache_kv_indices[(bs,)](
req_to_token,
req_pool_indices,
buffers.starts[chunk_idx],
buffers.seq_lens[chunk_idx],
buffers.cu_seq_lens[chunk_idx],
buffers.kv_indices[chunk_idx],
req_to_token.shape[1],
)
def _prepare_chunked_prefix_replay(
self, shape_key: ShapeKey, forward_batch: ForwardBatch
) -> None:
"""Refresh stable buffers for the selected rounded-up chunk variant."""
capture_batch = self._prefix_capture_batches[shape_key]
raw_bs = forward_batch.batch_size
prefix_lens_cpu = [
int(length) for length in forward_batch.extend_prefix_lens_cpu[:raw_bs]
] + [0] * (self._capture_req_slots - raw_bs)
self._populate_chunked_prefix_buffers(
captured_n=capture_batch.num_prefix_chunks,
prefix_lens_cpu=prefix_lens_cpu,
)
# Kept in sync for backend diagnostics; replay kernels read the stable
# GPU/CPU chunk tensors above, not this Python list.
capture_batch.extend_prefix_lens_cpu = prefix_lens_cpu
self.model_runner.attn_backend.prepare_full_cuda_graph_chunked_prefix(
capture_batch, in_capture=False
)
def _init_forward_metadata_for_capture(
self, forward_batch: ForwardBatch, num_tokens: int
) -> None:
@@ -765,9 +1024,17 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
padded_num_tokens = self._pad_to_bucket(num_tokens, self.capture_num_tokens)
if padded_num_tokens > num_tokens * _MAX_PREFILL_CUDA_GRAPH_PADDING_FACTOR:
return False
# No exact-shape check here: load_batch bucket-pads to the nearest
# captured shape. The factor above only rejects replays whose padded
# model work is disproportionate to the useful token count.
# Other backends and non-MLA FullCG keep using their normal graph with
# replay-refreshed metadata; only this extra topology has a prefix cap.
if (
self._capture_chunked_prefix
and self._has_prefix_hit(forward_batch)
and self._select_prefix_capture_chunks(forward_batch) is None
):
return False
# load_batch bucket-pads to the nearest captured shape. The factor
# above rejects replays whose padded model work is disproportionate
# to the useful token count.
#
# Multi-req replay is supported by body-capture backends via the
# layer_model.forward monkey-patch in replay(): the captured graph runs
@@ -944,8 +1211,11 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
f"Capturing num tokens ({num_tokens=} {avail_mem=:.2f} GB)"
)
self.capture_one_shape(num_tokens)
if self._capture_chunked_prefix:
for captured_n in self._prefix_capture_variants:
self.capture_one_shape(num_tokens, prefix_num_chunks=captured_n)
def capture_one_shape(self, size: int) -> None:
def capture_one_shape(self, size: int, *, prefix_num_chunks: int = 0) -> None:
"""Per-shape capture: build dummy ForwardBatch + run_once,
delegate to backend. size is the prefill token count.
"""
@@ -961,8 +1231,27 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
"limits; the graph would read stale LoRA metadata at replay."
)
lora_manager.prepare_lora_batch(forward_batch)
shape_key = ShapeKey(
size=num_tokens,
variant_label=(
_chunked_prefix_variant(prefix_num_chunks)
if prefix_num_chunks
else None
),
)
if prefix_num_chunks:
self._prepare_chunked_prefix_capture(
forward_batch, shape_key, prefix_num_chunks
)
if self._is_full_backend:
attn_backend.init_forward_metadata_out_graph(forward_batch, in_capture=True)
if not prefix_num_chunks:
attn_backend.init_forward_metadata_out_graph(
forward_batch, in_capture=True
)
# The prefix variant intentionally reuses the capture-stable
# metadata object initialized by the suffix-only variant above.
# Reaching into a backend-specific metadata cache here would make
# this path incompatible with the OSS FlashAttention backend.
else:
self._init_forward_metadata_for_capture(forward_batch, num_tokens)
@@ -984,7 +1273,7 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
else:
post_warmup_hook = getattr(attn_backend, "on_after_cuda_graph_warmup", None)
self.backend.capture_one(
ShapeKey(size=num_tokens),
shape_key,
run_once,
# DP padding can install capture-only tensors on this dummy batch;
# BCG retains it so their recorded addresses remain valid.
@@ -1172,7 +1461,6 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
forward_batch, static_forward_batch, static_num_tokens
)
self._static_num_tokens = static_num_tokens
return static_forward_batch
def _execute_body_capture(
@@ -1181,13 +1469,12 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
static_forward_batch: ForwardBatch,
static_num_tokens: int,
raw_num_tokens: int,
shape_key: ShapeKey,
**kwargs,
):
# BCG / Full: replay the captured body, run the LM head +
# logits_processor eagerly.
shape_key = ShapeKey(size=self._static_num_tokens)
full_path = self._is_full_backend
static_n = self._static_num_tokens
ie_idx = self._input_embeds_arg_idx
def replay_layer_forward(*args, **layer_kwargs):
@@ -1205,7 +1492,7 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
ie = args[ie_idx]
if ie is not None:
self.buffer_registry.get_slot("input_embeds").slice_for(
1, static_n
1, static_num_tokens
)[: ie.shape[0]].copy_(ie)
hs = self.backend.replay(shape_key, static_forward_batch, **kwargs)
return _slice_output_rows(hs, raw_num_tokens) if full_path else hs
@@ -1244,7 +1531,7 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
raw_num_tokens=raw_num_tokens,
):
return self.backend.replay(
ShapeKey(size=self._static_num_tokens),
ShapeKey(size=static_num_tokens),
static_forward_batch,
**kwargs,
)
@@ -1298,6 +1585,10 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
static_forward_batch = self.load_batch(forward_batch, **kwargs)
static_num_tokens = len(static_forward_batch.input_ids)
raw_num_tokens = self.raw_num_tokens
shape_key = self._shape_key(static_num_tokens, forward_batch)
# The only variants this runner records are chunked-prefix ones.
if shape_key.variant_label is not None:
self._prepare_chunked_prefix_replay(shape_key, forward_batch)
if self._uses_eager_prefill_tail():
output = self._execute_body_capture(
@@ -1305,6 +1596,7 @@ class PrefillCudaGraphRunner(BaseCudaGraphRunner):
static_forward_batch,
static_num_tokens,
raw_num_tokens,
shape_key,
**kwargs,
)
else:
@@ -27,8 +27,9 @@ class ShapeKey:
- prefill: num_tokens
- decode: bs
stream_idx: pdmux stream index, or None for single-stream runners.
variant_label: LoRA-variant label ("lora" / "nolora"), or None
for runners that don't record per-variant graphs.
variant_label: optional execution variant (for example, "lora",
"nolora", or "chunked_prefix"), or None for runners that don't
record per-variant graphs.
"""
size: int