Skip to content

[perf] W4A16 launch path + indexer env parsing: full kernel rebuild before every compile-cache probe, prewarmed launches unreachable at runtime, per-call getenv on the paged-indexer prefill route #96

Description

@malaiwah

Found during a performance 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, so permalinks use that SHA. vLLM caller refs are from the composed r12 vLLM tree (base b46c3aa). Deployment context: GLM-5.2 753B MoE (hidden 6144, 78 layers, top-k 8, indexer top-k 2048), EXL3 trellis 3 bpw, TP4, FULL_AND_PIECEWISE graphs; launcher defaults VLLM_EXL3_TRELLIS_MAX_M=32, VLLM_EXL3_TRELLIS_BLOCK_M=8, VLLM_EXL3_PREFILL_CHUNK=128.

All items below are per-call Python/launch-path costs: they are paid on every eager MoE/indexer invocation (prefill steps, and any batch outside graph coverage) and are amortized to capture time inside CUDA graphs. Related: #93 (trellis scratch sizing), #94 (indexer fold slab), and the vLLM-side routing-dtype/env issue local-inference-lab/vllm#204.

1. W4A16 fused-MoE runtime resolve rebuilds the full kernel object before every compile-cache probe; the plan-time prewarm is unreachable from that probe

Where (runtime resolve, no force_tile_config): sparkinfer/moe/_shared/kernels/w4a16/kernel.py#L10248-L10277

    if fused_launch is None:
        fused = compile_w4a16_fused_moe(
            size_m=m,
            ...
            # (no force_tile_config here)
        )

Where (vLLM bind always passes None): sparkinfer/moe/fused_moe/_impl.py#L937-L959TPMoEScratchPlan.bind(...) hard-codes fused_launch=None, topk_sum_launch=None, and the prewarmed launches stored at #L6455 (_prewarmed_fused_launches) are never read anywhere — they exist only to warm _FUSED_CACHE.

Mechanism, part A — per-call Python before the probe. compile_w4a16_fused_moe (kernel.py:7026) runs, on every run_w4a16_moe call with fused_launch=None:

  • 2 (up to 4) _select_tile_config sweeps (#L7161-L7216) — these run even when the result will be overridden by a pinned tile config;
  • construction of a full W4A16FusedMoeKernel, i.e. two W4A16GemmKernel inits (~300 lines of derived-field arithmetic each), including an unconditional nf3_codebook_pools(...) recompute (kernel.py:826-831) and torch.cuda.get_device_properties(torch.cuda.current_device()) per GEMM (kernel.py:894);
  • only then the cache probe on kernel.__cache_key__ (L7423-7428), and on a hit a dataclasses.replace of a ~30-field frozen result (L7430-7435). The same pattern repeats for the top-k-sum launch.

Recomputed cost: a faithful pure-Python replica of the two _select_tile_config sweeps measures ~7 us per invocation on this host; the kernel-object construction around it is several times that. Estimated total ~30-100+ us per layer per eager call, x78 layers => roughly 2-8 ms of pure Python per eager prefill step, repeated every step (amortized only inside captured graphs). vLLM profiler traces of eager GLM prefill should show this directly under compile_w4a16_fused_moe.

Mechanism, part B — the prewarm can never be hit by the runtime probe. The plan-time prewarm passes force_tile_config=core_plan.trellis_tile_config (_impl.py#L6249-L6278); vLLM pins (64, 256, 64, 256) (or (64, 128, 64, 128)) via exl3.py:_trellis_tile_config. The runtime resolve omits force_tile_config, so its tiles come from _select_tile_config — and at moe_block_size=8 the candidate list is _SMALL_BATCH_TILE_CONFIGS = ((128,128,256),(64,128,128),(128,64,128)) (#L219-L223), which does not contain the pinned (tile_k=64, tile_n=256) at all (our replica selects (128,128,256) for all GLM-shaped m). Since the fused cache key embeds the FC tile shapes, cta_threads and blocks_per_sm (kernel.py:4780-4816 via the per-GEMM key L1000-L1037), the prewarmed entries and the runtime probes live under different keys: the prewarm JIT-compiles kernels that are never launched, and the first-use CuTe JIT (100s of ms per specialization) that the prewarm comment (L6452-6454) intends to avoid still happens at warmup / first live shape.

Mechanism, part C — the exact-M prewarm threshold. _impl.py#L6239-L6247:

        fused_token_counts = (
            tuple(range(1, capacity_tokens + 1))
            if capacity_tokens <= 32
            else (capacity_tokens,)
        )

At the launcher default VLLM_EXL3_TRELLIS_MAX_M=32 the decode plan prewarms every exact M (good). But the prefill plan's capacity is max_num_batched_tokens (3072/8192), so it prewarms only that single M; and any operator who raises TRELLIS_MAX_M to cover larger FULL-graph capture sizes (e.g. 64) silently flips the decode plan from "all M" to "capacity only". Because blocks_per_sm is m-dependent at small M (work-CTA clamp, kernel.py:367-369), each small M is its own cache entry — an M bucket that was neither prewarmed (part B makes that moot today) nor exercised during vLLM's warmup dummy runs pays a multi-100-ms JIT mid-serving on first occurrence.

Suggested fix (in order of impact):

  1. Consume the plan's prewarmed launches: have bind() attach the matching _prewarmed_fused_launches entry instead of hard-coding fused_launch=None — this skips the entire per-call prologue and makes the prewarm meaningful.
  2. Failing that: pass force_tile_config on the runtime resolve so prewarm and runtime agree on keys, and probe the cache with a key derived from the inputs before constructing kernel objects.
  3. Align the prewarm threshold with the plan capacity (or log when capacity > 32 truncates coverage).

2. _get_c_tmp fallback: capture raises, eager silently allocates per call — add a log-once

Where: kernel.py#L9164-L9184

        if int(scratch.numel()) >= int(elements):
            return scratch[: int(elements)]
    if torch.cuda.is_current_stream_capturing():
        raise RuntimeError(
            "W4A16 GEMM scratch is not initialized for CUDA graph capture; ..."
        )
    return torch.empty((elements,), dtype=torch.float32, device=device)

Mechanism: if a caller-provided fc*_c_tmp binding is under-sized, capture fails loudly but eager mode silently falls into a fresh fp32 torch.empty per call per layer — allocator churn that hides the sizing bug precisely in the mode where it is cheapest to notice. Defensive today (vLLM's bindings are plan-sized), but the asymmetry means an eager-only regression would ship unnoticed. Suggested fix: logger.warning-once (with requested vs provided numel) when the fallback path is taken.

3. Paged indexer prefill route parses env per call and per supertile chunk

Where: sparkinfer/attention/nsa_indexer/paged.py#L96-L128 (fold policy, 2x os.getenv, called per index_topk_fp8 at L942->L159), #L717-L735 (supertile-K), #L1034 (per chunk), and nsa_indexer/kernel.py#L3002-L3006 (os.environ.get per run_paged_supertile_logits_kernel call at L2887).

Mechanism & scope (sharpened from our first pass): the production decode route returns at the fused early-exit (paged.py:839-865) before any env read, so decode is unaffected. The tiled/packed-contiguous route (prefill; decode only if the fused route is not resolved) pays per call: 2 getenv (fold policy) + up to 1 (supertile-K; usually short-circuited by the binding/scratch attrs at L867-870) + 2 per supertile chunk (L1034 + kernel.py:2887). Cost: 78 layers x (2 + 2 x num_chunks); at 512k context (16 chunks of 32768 tokens) that is ~2,600 os.getenv + strip/lower/int-parse per prefill step — order 1-3 ms of Python, every step, on the eager path. Fix: resolve once at plan/module init — fused_indexer.py:110 already does exactly this for its own flag, and the vLLM side has the same pattern (tracked in local-inference-lab/vllm#204).

Note on routing-id dtype (cross-ref vllm#204)

For completeness: the int64->int32 topk cast quoted in earlier drafts of this review (kernel.py:10115-10117) is gated on weight_layout in ("packed", "nf3_2p1") and m<=8, so it is unreachable on the GLM trellis3_t256 path; the route-pack/top-k-sum kernels consume int64 ids natively via route_ids_dtype-specialized launches (prewarmed for both dtypes, _impl.py:6597). The sparkinfer-side residual cost of vLLM handing int64 is therefore 2x route-id bytes plus duplicate kernel specializations, not a per-step cast. The root fix is on the vLLM side (local-inference-lab/vllm#204).

Verification hint

nsys/py-spy an eager GLM prefill step: expect visible per-layer time under compile_w4a16_fused_moe / W4A16GemmKernel.__init__ (item 1) and _read_two_level_fold_policy / os.getenv (item 3). For item 1 part B: log _FUSED_CACHE keys after plan-time prewarm vs after the first live forward — the prewarmed keys are never probed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions