Found during a memory review of the v20 r12 release image voipmonitor/vllm:gilded-gnosis-v20-vllmb46c3aa-si35aebc6-fi801d57a-cu132-20260730-r12. Code refs are against the composed r12 tree; all cited sparkinfer files are byte-identical to public commit de04125508c75e748016493f851e6bd4513b515b (reachable from pr/92), so permalinks use that SHA. vLLM caller refs are from the composed r12 vLLM tree (base f978d009 + release-lock PR heads). Deployment context for the impact math: GLM-5.2 753B MoE, EXL3 3.0bpw, 4x RTX 6000 Pro Blackwell 96GB (sm120), TP4; release gate = DCP4 / fp8 KV / V2 runner / max_num_batched_tokens=8192 / VLLM_DCP_A2A_MAX_TOKENS=64; production turnkey = DCP2 / nvfp4 KV / V1 runner / batched 3072 / MTP k=5 / GMU 0.957 (~4.1 GiB total margin per GPU). These are proposals from a source review, not demands — happy to be corrected on any of the mechanics.
1. DCP A2A pool mints a fresh IPC channel + slab per graph-capture context, retained until pool close
Where: sparkinfer/comm/pcie/pcie_dcp_a2a.py#L766-L789
@contextmanager
def capture(self, stream: object = None):
"""Bind nested CUDA captures to the enclosing stream's channel."""
if self.single_channel:
channel = self.for_stream(stream)
else:
...
# Keep a graph-owned channel even when CUDA recycles the enclosing
# stream handle used by a previously captured graph manager.
channel = self._new_channel(stream_key)
self._channels[key] = channel
What/Why: With single_channel=False (what vLLM's dcp_alltoall._get_b12x_dcp_a2a_pool passes), every entry into PCIeDCPA2APool.capture() unconditionally allocates a new channel, i.e. a new IPC staging slab (_staging_layout, lines 61-106: two slots of max_batch × total_heads × max(head_dim, query_head_dim) × 2 B output + fp32 LSE). Channels are appended to _all_channels and only freed in close() (lines 791-801); checkpoint/rollback_channels is used by vLLM solely around throwaway profiling captures.
One correction to our own initial reading, in fairness to the design: vLLM enters exactly one capture context per cudagraph-manager capture pass, not one per capture size — vllm/v1/worker/gpu/cudagraph_utils.py:464 wraps the whole PIECEWISE+FULL size loop in a single graph_capture(...), which enters capture_b12x_dcp_a2a once. So the multiplier is the number of manager passes (target model + MTP drafter prefill + drafter decode on the V2 runner), not the 16+ capture sizes.
Impact (recomputed): Per-channel slab with VLLM_DCP_A2A_MAX_TOKENS=64, query_head_dim=576, head_dim=512:
- DCP4 (gate,
total_heads=96 = 24 heads/TP-rank × 4): 13.55 MiB/channel/rank. Warm channel + 3 capture passes ≈ 4 channels ≈ 54 MiB/rank, of which ~40 MiB exists only for graph-lifetime isolation.
- DCP2 (turnkey,
total_heads=48): 6.77 MiB/channel; warm + 1 capture pass (V1 runner captures everything under one graph_capture) ≈ 13.5 MiB/rank.
Not huge, but it is IPC-pinned cudaMalloc memory outside the torch allocator, invisible to the profiler's KV budget, and it grows by one slab any time another capture pass is added (e.g. more drafter managers). At turnkey's ~4.1 GiB total margin/GPU, 54 MiB is ~1.3% of margin.
Suggested fix: Key capture channels by capture context and reuse them across manager passes that cannot replay concurrently (target FULL vs drafter decode graphs can overlap, but target prefill/drafter prefill cannot), or free transient capture channels when their graphs are destroyed rather than at pool close.
Verification: Count PCIeDCPA2A.from_exchange_group calls (or log in _new_channel) during a TP4/DCP4 startup with MTP enabled on the V2 runner; observe 1 warmup + 1 per manager capture pass, none freed before shutdown.
2. Two-level fold candidate slab is torch.empty'd per index_topk_fp8 call, escaping the scratch plan
Where: sparkinfer/attention/nsa_indexer/paged.py#L955-L964
if two_level_slices:
fold_values = torch.empty(
(q_rows * total_slices, topk), dtype=torch.float32, device=q_fp8.device
)
fold_indices = torch.empty(
(q_rows * total_slices, topk), dtype=torch.int32, device=q_fp8.device
)
fold_lengths = torch.full(
(q_rows,), total_slices * topk, dtype=torch.int32, device=q_fp8.device
)
What/Why: Everything else this route touches is plan-reserved (tile_logits, top-k buffers, the streaming-carry double buffer at scratch.py:948-962), but the two-level fold candidate slab is a per-call allocation, sized q_rows × (total_slices × topk × 8 + 4) bytes (_plan_two_level_fold, paged.py:198), bounded only by the 256 MiB SPARKINFER_INDEXER_TWO_LEVEL_FOLD_MAX_MIB budget. On GLM (32 indexer heads, topk=2048) the fused route only covers decode rows ≤ 16 (fused_indexer.py:138, _GLM32_FUSED_MAX_ROWS = 16), so any decode batch of 17–64 rows takes the tiled route and hits this allocation — per sparse layer, per step.
Impact (recomputed): At long context (≥512k tokens the slice cap makes total_slices = 32), per call: 48 rows (turnkey: 8 seqs × MTP k=5+1) → 24 MiB; 64 rows (gate capture max) → 32 MiB. In eager mode that is a 24 MiB empty+free per sparse layer per step — exactly the pattern that expandable_segments:False (forced under LMCache) turns into fragmentation pressure. Under FULL CUDA-graph capture the slab lands in the graph pool; since the V2 runner captures all sizes into one shared pool largest-first, retained pool growth is roughly the max-size slab (~32 MiB), not a per-size sum — we could not substantiate a 100+ MiB accumulation from the code alone, so we flag the graph-pool magnitude as uncertain; the per-call churn is not.
Suggested fix: Move the fold candidate slab into the paged scratch layout (it is bounded by min(budget, max_q_rows × 32 × topk × 8), i.e. ≤ 32 MiB at decode caps — cheaper than one extra graph's worth of churn), sharing one reservation across capture sizes like the carry double-buffer already does.
Verification: torch.cuda.memory_stats() alloc/free counters across one decode step at 48 rows / long context show per-layer 24 MiB allocation pairs attributable to index_topk_fp8; the scratch plan (plan_indexer_scratch) contains no matching reservation.
3. DMA ring slab keeps full bf16 shard width in compressed wire modes, and adds the fp8 stage on top
Where: sparkinfer/comm/pcie/pcie_dma.py#L179-L229
self.shard_capacity = _align_up(
(self.max_bytes + self.world_size - 1) // self.world_size, SCRATCH_ALIGN
)
steps = 2 * (self.world_size - 1)
flags_bytes = FLAG_SLOTS * FLAG_STRIDE
slab_bytes = flags_bytes + steps * self.shard_capacity
...
if self._fp8:
max_shard_elems = self.max_bytes // 2 // self.world_size
stride = _align_up(
max_shard_elems + max_shard_elems // FP8_QUANT_BLOCK * 4,
SCRATCH_ALIGN,
)
self._fp8_stage = torch.empty(
self.world_size * stride, dtype=torch.uint8, device=self.device
)
What/Why: shard_capacity is computed from max_bytes (bf16 sizing) before and regardless of the wire mode. In the compressed ring/a2a modes every hop copies piece_slice_bytes = piece_elems + piece_elems/128*4 (~0.516× of the bf16 width — verified against the step loop at lines 496-524, where compressed sends write into slots addressed by piece * piece_slice_bytes but strided by the full shard_capacity). So in i8_ring/mx_ring/ring mode, all 2(W−1) slots are ~48% dead space. In the AG-only modes (ag/i8/mx) the reduce-scatter hops still carry full-width bf16, so only the (W−1) allgather slots could shrink. On top of that, _fp8_stage is allocated eagerly at init, in addition to the full-width slab.
Impact (recomputed, TP4, max_bytes = max_num_batched_tokens × 6144 × 2):
- Gate (8192 tok): slab = 32 KiB flags + 6 × 24 MiB = 144.03 MiB/rank IPC-pinned; +49.5 MiB
_fp8_stage if a compressed mode is enabled.
- Turnkey (3072 tok): slab = 54.03 MiB/rank; +18.6 MiB stage if compressed.
- Sizing
shard_capacity from the wire payload in fully-compressed modes would save ~70 MiB (gate) / ~26 MiB (turnkey) per rank; in AG-only modes ~35 MiB / ~13 MiB (allgather slots only).
Note the default deployment (no SPARKINFER_PCIE_DMA_FP8/VLLM_PCIE_DMA_FP8) runs bf16 wire, where the slab is correctly sized — this finding applies to the compressed opt-in.
Suggested fix: Compute a per-mode slot width (ring/a2a variants: ~0.52 × shard_capacity for all steps; ag variants: full width for RS steps, compressed width for AG steps) and derive _fp8_stage from the same widths instead of adding it on top.
Verification: Instantiate PCIeDmaAllReduce(max_bytes=96 MiB, fp8="i8_ring") on 4 ranks and compare slab_bytes against the sum of bytes actually addressed by fp8_scratch_piece across all steps (~52% of the slab).
4. Latent: full-context fp32 logits blob on the direct (non-supertile) paged logits route
Where: sparkinfer/attention/nsa_indexer/kernel.py#L2323-L2333 (inside run_paged_logits_kernel)
if preinitialize_invalid_logits:
logits = torch.full(
(rows, width_tokens),
float("-inf"),
dtype=torch.float32,
device=q_fp8.device,
)
else:
logits = torch.empty(
(rows, width_tokens), dtype=torch.float32, device=q_fp8.device
)
What/Why: Both branches materialize a (rows, width_tokens) fp32 blob — 128 MiB at 512k context × 64 rows (and the -inf branch also pays a full fill). We confirmed production GLM decode does not reach this: index_topk_fp8 routes through the fused kernel (rows ≤ 16) or the supertile tile-logits scratch, and run_paged_logits_kernel's only callers are _impl.paged_decode_logits and the MSA block-scores path. Filing it as a latent hazard: any future caller (debug, reference parity, MSA at long context) silently allocates context-proportional fp32.
Suggested fix: Guard the direct route with a byte ceiling (mirroring the two-level fold budget), or require a caller-provided output buffer above a threshold.
Verification: Call paged_decode_logits with a 512k-token page table and 64 rows; observe the single 128 MiB fp32 allocation.
5. tile_logits fp32 plan sizing: up to 384-512 MiB of shared-workspace high-water mark at prefill caps
Where: sparkinfer/attention/nsa_indexer/scratch.py#L883-L893 and #L936-L937
num_q_tiles = (
max_q_rows + _PAGED_INDEX_TILE_BLOCK_Q - 1
) // _PAGED_INDEX_TILE_BLOCK_Q
num_k_tiles = supertile_tokens // _PAGED_INDEX_TILE_BLOCK_K
tile_logits_elements = max(
1,
num_q_tiles
* num_k_tiles
* _PAGED_INDEX_TILE_BLOCK_Q
* _PAGED_INDEX_TILE_BLOCK_K,
)
...
cursor += tile_logits_elements * dtype_nbytes(torch.float32)
What/Why: tile_logits = round32(max_q_rows) × supertile_tokens × 4 B (supertile default 32768). We traced what vLLM actually plans (per the "verify the row cap" note in our review): the composed vLLM's sparse_attn_indexer.py reserves both a decode plan at capture rows and a prefill plan at profile_q_rows = min(q_rows, VLLM_SPARSE_INDEXER_MAX_LOGITS_MB·MiB/4 / 32768) (_get_b12x_paged_indexer_profile_q_rows, default cap 512 MB → 4096 rows), bound into the shared workspace via current_workspace_manager().get_simultaneous(*plan.shapes_and_dtypes()).
Impact (recomputed): decode plan at 64 rows: 8 MiB (fine). Prefill plan: turnkey 3072 rows → 384 MiB; gate 8192 rows → capped at 4096 → 512 MiB contributed to the shared workspace high-water mark per rank. (The 1 GiB figure in our internal review only occurs with a raised VLLM_SPARSE_INDEXER_MAX_LOGITS_MB; the default env cap bounds it at 512 MiB by construction.) bf16 tile logits would halve this to 192/256 MiB — but tiled_topk.py:1616 hard-requires fp32 (row_logits must have dtype torch.float32), so this needs a kernel-side change, not just a dtype swap. Given the indexer scores are consumed only through a top-k selection, fp32→bf16 precision impact is plausibly acceptable but needs validation on the 3.0 bpw model.
Suggested fix: Accept bf16 tile_logits in run_tiled_topk (keep fp32 carry/output), or expose a per-plan dtype so integrators can trade the workspace HWM.
Verification: Log plan.shapes_and_dtypes() from the prefill reservation on a 3072-token profile run: the tile_logits entry is (3072/32768-tile shape) fp32 = 384 MiB.
6. Oneshot channels: 8 MiB rank_data per channel for ≤84 KiB dispatch payloads
Where: sparkinfer/comm/pcie/pcie_oneshot.py#L27 and #L325-L327
DEFAULT_RANK_DATA_BYTES = 8 * 1024 * 1024
...
self.rank_data = torch.empty(
rank_data_bytes, dtype=torch.uint8, device=self.device
)
What/Why: Every oneshot channel allocates an 8 MiB rank_data tensor; the composed vLLM never overrides rank_data_bytes. Channels multiply like the DCP A2A ones: one warm channel + one per graph_capture() entry (custom_all_reduce.py:672 enters pool.capture() per capture pass) → ~4 channels on the V2 gate config → ~32 MiB/rank of registration bookkeeping for a dispatcher whose payloads are capped at 84 KiB (VLLM_PCIE_ONESHOT_ALLREDUCE_MAX_SIZE).
One important correction to our internal review: the "2×8 MiB eager slabs per channel" part does not apply to the vLLM deployment path. The composed vLLM already right-sizes the eager IPC slabs to the dispatch cutoff — custom_all_reduce.py:78-89 passes eager_buffer_bytes = max(84 KB, 84 KB, 16), so the per-channel IPC slab is ~169 KiB, and the in-tree comment explicitly documents why. The 2×8 MiB cost only hits callers that use from_exchange_group defaults (DEFAULT_MAX_SIZE at line 26).
Suggested fix: Scale rank_data_bytes with max_size (a ~1 MiB default comfortably covers 84 KiB dispatch registration), and/or lower DEFAULT_MAX_SIZE so default-constructed channels don't reserve 16 MiB of IPC staging for an 84 KiB dispatcher.
Verification: On a TP4 V2-runner boot with MTP, sum channel.rank_data.numel() across pool._all_channels after capture: 4 × 8 MiB, against max_size=86016.
Found during a memory review of the v20 r12 release image
voipmonitor/vllm:gilded-gnosis-v20-vllmb46c3aa-si35aebc6-fi801d57a-cu132-20260730-r12. Code refs are against the composed r12 tree; all cited sparkinfer files are byte-identical to public commitde04125508c75e748016493f851e6bd4513b515b(reachable frompr/92), so permalinks use that SHA. vLLM caller refs are from the composed r12 vLLM tree (base f978d009 + release-lock PR heads). Deployment context for the impact math: GLM-5.2 753B MoE, EXL3 3.0bpw, 4x RTX 6000 Pro Blackwell 96GB (sm120), TP4; release gate = DCP4 / fp8 KV / V2 runner /max_num_batched_tokens=8192/VLLM_DCP_A2A_MAX_TOKENS=64; production turnkey = DCP2 / nvfp4 KV / V1 runner / batched 3072 / MTP k=5 / GMU 0.957 (~4.1 GiB total margin per GPU). These are proposals from a source review, not demands — happy to be corrected on any of the mechanics.1. DCP A2A pool mints a fresh IPC channel + slab per graph-capture context, retained until pool close
Where:
sparkinfer/comm/pcie/pcie_dcp_a2a.py#L766-L789What/Why: With
single_channel=False(what vLLM'sdcp_alltoall._get_b12x_dcp_a2a_poolpasses), every entry intoPCIeDCPA2APool.capture()unconditionally allocates a new channel, i.e. a new IPC staging slab (_staging_layout, lines 61-106: two slots ofmax_batch × total_heads × max(head_dim, query_head_dim) × 2 Boutput + fp32 LSE). Channels are appended to_all_channelsand only freed inclose()(lines 791-801);checkpoint/rollback_channelsis used by vLLM solely around throwaway profiling captures.One correction to our own initial reading, in fairness to the design: vLLM enters exactly one capture context per cudagraph-manager capture pass, not one per capture size —
vllm/v1/worker/gpu/cudagraph_utils.py:464wraps the whole PIECEWISE+FULL size loop in a singlegraph_capture(...), which enterscapture_b12x_dcp_a2aonce. So the multiplier is the number of manager passes (target model + MTP drafter prefill + drafter decode on the V2 runner), not the 16+ capture sizes.Impact (recomputed): Per-channel slab with
VLLM_DCP_A2A_MAX_TOKENS=64,query_head_dim=576,head_dim=512:total_heads=96= 24 heads/TP-rank × 4): 13.55 MiB/channel/rank. Warm channel + 3 capture passes ≈ 4 channels ≈ 54 MiB/rank, of which ~40 MiB exists only for graph-lifetime isolation.total_heads=48): 6.77 MiB/channel; warm + 1 capture pass (V1 runner captures everything under onegraph_capture) ≈ 13.5 MiB/rank.Not huge, but it is IPC-pinned
cudaMallocmemory outside the torch allocator, invisible to the profiler's KV budget, and it grows by one slab any time another capture pass is added (e.g. more drafter managers). At turnkey's ~4.1 GiB total margin/GPU, 54 MiB is ~1.3% of margin.Suggested fix: Key capture channels by capture context and reuse them across manager passes that cannot replay concurrently (target FULL vs drafter decode graphs can overlap, but target prefill/drafter prefill cannot), or free transient capture channels when their graphs are destroyed rather than at pool close.
Verification: Count
PCIeDCPA2A.from_exchange_groupcalls (or log in_new_channel) during a TP4/DCP4 startup with MTP enabled on the V2 runner; observe 1 warmup + 1 per manager capture pass, none freed before shutdown.2. Two-level fold candidate slab is
torch.empty'd perindex_topk_fp8call, escaping the scratch planWhere:
sparkinfer/attention/nsa_indexer/paged.py#L955-L964What/Why: Everything else this route touches is plan-reserved (tile_logits, top-k buffers, the streaming-carry double buffer at
scratch.py:948-962), but the two-level fold candidate slab is a per-call allocation, sizedq_rows × (total_slices × topk × 8 + 4)bytes (_plan_two_level_fold,paged.py:198), bounded only by the 256 MiBSPARKINFER_INDEXER_TWO_LEVEL_FOLD_MAX_MIBbudget. On GLM (32 indexer heads, topk=2048) the fused route only covers decode rows ≤ 16 (fused_indexer.py:138,_GLM32_FUSED_MAX_ROWS = 16), so any decode batch of 17–64 rows takes the tiled route and hits this allocation — per sparse layer, per step.Impact (recomputed): At long context (≥512k tokens the slice cap makes
total_slices = 32), per call: 48 rows (turnkey: 8 seqs × MTP k=5+1) → 24 MiB; 64 rows (gate capture max) → 32 MiB. In eager mode that is a 24 MiBempty+free per sparse layer per step — exactly the pattern thatexpandable_segments:False(forced under LMCache) turns into fragmentation pressure. Under FULL CUDA-graph capture the slab lands in the graph pool; since the V2 runner captures all sizes into one shared pool largest-first, retained pool growth is roughly the max-size slab (~32 MiB), not a per-size sum — we could not substantiate a 100+ MiB accumulation from the code alone, so we flag the graph-pool magnitude as uncertain; the per-call churn is not.Suggested fix: Move the fold candidate slab into the paged scratch layout (it is bounded by
min(budget, max_q_rows × 32 × topk × 8), i.e. ≤ 32 MiB at decode caps — cheaper than one extra graph's worth of churn), sharing one reservation across capture sizes like the carry double-buffer already does.Verification:
torch.cuda.memory_stats()alloc/free counters across one decode step at 48 rows / long context show per-layer 24 MiB allocation pairs attributable toindex_topk_fp8; the scratch plan (plan_indexer_scratch) contains no matching reservation.3. DMA ring slab keeps full bf16 shard width in compressed wire modes, and adds the fp8 stage on top
Where:
sparkinfer/comm/pcie/pcie_dma.py#L179-L229What/Why:
shard_capacityis computed frommax_bytes(bf16 sizing) before and regardless of the wire mode. In the compressed ring/a2a modes every hop copiespiece_slice_bytes = piece_elems + piece_elems/128*4(~0.516× of the bf16 width — verified against the step loop at lines 496-524, where compressed sends write into slots addressed bypiece * piece_slice_bytesbut strided by the fullshard_capacity). So ini8_ring/mx_ring/ringmode, all2(W−1)slots are ~48% dead space. In the AG-only modes (ag/i8/mx) the reduce-scatter hops still carry full-width bf16, so only the(W−1)allgather slots could shrink. On top of that,_fp8_stageis allocated eagerly at init, in addition to the full-width slab.Impact (recomputed, TP4,
max_bytes = max_num_batched_tokens × 6144 × 2):_fp8_stageif a compressed mode is enabled.shard_capacityfrom the wire payload in fully-compressed modes would save ~70 MiB (gate) / ~26 MiB (turnkey) per rank; in AG-only modes ~35 MiB / ~13 MiB (allgather slots only).Note the default deployment (no
SPARKINFER_PCIE_DMA_FP8/VLLM_PCIE_DMA_FP8) runs bf16 wire, where the slab is correctly sized — this finding applies to the compressed opt-in.Suggested fix: Compute a per-mode slot width (
ring/a2avariants:~0.52 × shard_capacityfor all steps;agvariants: full width for RS steps, compressed width for AG steps) and derive_fp8_stagefrom the same widths instead of adding it on top.Verification: Instantiate
PCIeDmaAllReduce(max_bytes=96 MiB, fp8="i8_ring")on 4 ranks and compareslab_bytesagainst the sum of bytes actually addressed byfp8_scratch_pieceacross all steps (~52% of the slab).4. Latent: full-context fp32 logits blob on the direct (non-supertile) paged logits route
Where:
sparkinfer/attention/nsa_indexer/kernel.py#L2323-L2333(insiderun_paged_logits_kernel)What/Why: Both branches materialize a
(rows, width_tokens)fp32 blob — 128 MiB at 512k context × 64 rows (and the-infbranch also pays a full fill). We confirmed production GLM decode does not reach this:index_topk_fp8routes through the fused kernel (rows ≤ 16) or the supertile tile-logits scratch, andrun_paged_logits_kernel's only callers are_impl.paged_decode_logitsand the MSA block-scores path. Filing it as a latent hazard: any future caller (debug, reference parity, MSA at long context) silently allocates context-proportional fp32.Suggested fix: Guard the direct route with a byte ceiling (mirroring the two-level fold budget), or require a caller-provided output buffer above a threshold.
Verification: Call
paged_decode_logitswith a 512k-token page table and 64 rows; observe the single 128 MiB fp32 allocation.5.
tile_logitsfp32 plan sizing: up to 384-512 MiB of shared-workspace high-water mark at prefill capsWhere:
sparkinfer/attention/nsa_indexer/scratch.py#L883-L893and#L936-L937What/Why:
tile_logits=round32(max_q_rows) × supertile_tokens × 4 B(supertile default 32768). We traced what vLLM actually plans (per the "verify the row cap" note in our review): the composed vLLM'ssparse_attn_indexer.pyreserves both a decode plan at capture rows and a prefill plan atprofile_q_rows = min(q_rows, VLLM_SPARSE_INDEXER_MAX_LOGITS_MB·MiB/4 / 32768)(_get_b12x_paged_indexer_profile_q_rows, default cap 512 MB → 4096 rows), bound into the shared workspace viacurrent_workspace_manager().get_simultaneous(*plan.shapes_and_dtypes()).Impact (recomputed): decode plan at 64 rows: 8 MiB (fine). Prefill plan: turnkey 3072 rows → 384 MiB; gate 8192 rows → capped at 4096 → 512 MiB contributed to the shared workspace high-water mark per rank. (The 1 GiB figure in our internal review only occurs with a raised
VLLM_SPARSE_INDEXER_MAX_LOGITS_MB; the default env cap bounds it at 512 MiB by construction.) bf16 tile logits would halve this to 192/256 MiB — buttiled_topk.py:1616hard-requires fp32 (row_logits must have dtype torch.float32), so this needs a kernel-side change, not just a dtype swap. Given the indexer scores are consumed only through a top-k selection, fp32→bf16 precision impact is plausibly acceptable but needs validation on the 3.0 bpw model.Suggested fix: Accept bf16
tile_logitsinrun_tiled_topk(keep fp32 carry/output), or expose a per-plan dtype so integrators can trade the workspace HWM.Verification: Log
plan.shapes_and_dtypes()from the prefill reservation on a 3072-token profile run: the tile_logits entry is(3072/32768-tile shape) fp32= 384 MiB.6. Oneshot channels: 8 MiB
rank_dataper channel for ≤84 KiB dispatch payloadsWhere:
sparkinfer/comm/pcie/pcie_oneshot.py#L27and#L325-L327What/Why: Every oneshot channel allocates an 8 MiB
rank_datatensor; the composed vLLM never overridesrank_data_bytes. Channels multiply like the DCP A2A ones: one warm channel + one pergraph_capture()entry (custom_all_reduce.py:672enterspool.capture()per capture pass) → ~4 channels on the V2 gate config → ~32 MiB/rank of registration bookkeeping for a dispatcher whose payloads are capped at 84 KiB (VLLM_PCIE_ONESHOT_ALLREDUCE_MAX_SIZE).One important correction to our internal review: the "2×8 MiB eager slabs per channel" part does not apply to the vLLM deployment path. The composed vLLM already right-sizes the eager IPC slabs to the dispatch cutoff —
custom_all_reduce.py:78-89passeseager_buffer_bytes = max(84 KB, 84 KB, 16), so the per-channel IPC slab is ~169 KiB, and the in-tree comment explicitly documents why. The 2×8 MiB cost only hits callers that usefrom_exchange_groupdefaults (DEFAULT_MAX_SIZEat line 26).Suggested fix: Scale
rank_data_byteswithmax_size(a ~1 MiB default comfortably covers 84 KiB dispatch registration), and/or lowerDEFAULT_MAX_SIZEso default-constructed channels don't reserve 16 MiB of IPC staging for an 84 KiB dispatcher.Verification: On a TP4 V2-runner boot with MTP, sum
channel.rank_data.numel()acrosspool._all_channelsafter capture: 4 × 8 MiB, againstmax_size=86016.