[EXL3] Route prefill batches through the planned Trellis MoE (+58-64% prefill, stacked on #139)#163
Conversation
Adds --quantization exl3: any exllamav3 EXL3 checkpoint loads through a QuantizationConfig + LinearMethodBase pair that reproduces turboderp's dense no_reconstruct forward argument-for-argument at the exl3_gemm boundary (input suh sign-flip + Hadamard-128, direct trellis GEMM, output svh + Hadamard-128), with mcg/mul1 sentinel semantics, legacy su/sv expansion, reference pad/trim, and tp_import-discipline TP slicing (KV replication; Qwen3.5 [(0,1,2),3] tuple shards). Registry delta is exactly three lines (Literal member, lazy import, mapping entry). RoutedExperts.load_weights: qualify is_fused by the matched mapping entry, not tensor rank alone — per-expert EXL3 trellis tensors are rank-3 [K/16, N/16, 16*bpw] and were mangled by the fused transpose/chunk path (fail-loud downstream, but wrong). Fused entries are exactly those whose weight_name carries no per-expert index; behavior for genuinely fused checkpoints is unchanged. Backend enforces eager execution at build time: exl3_gemm autotunes with timing launches on first call per (m-bucket, k, n, K) hash, which is incompatible with CUDA-graph capture, and m-bucketing defeats warmup coverage. Quantized lm_head with added vocabulary raises (TP slicing would silently misalign). MoE expert loader is marked supports_moe_loading for llama4-style loader paths. GPU validation gates (owner-run): smoke-load Qwen3-0.6B-exl3, generate on Qwen3.6-27B-exl3_3.30bpw, top-logprob comparison vs exllamav3 pinned to no_reconstruct=True. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Modules carrying the MCG codebook marker are prepared once at weight-processing time via b12x prepare_trellis256_dense_weight (zero-copy, bits inferred from native storage) and applied through run_trellis256_dense — the fused CuTeDSL kernel whose output is byte-identical across batch size m. Legacy default-codebook and MUL1 shards, and any shard the fused prepare rejects, fall back per-shard to the bit-faithful exllamav3_ext parity op, which remains the oracle. VLLM_EXL3_FUSED=0 forces the parity path (A/B switch); VLLM_EXL3_LOG_PATH_SELECTION=1 logs the per-shard selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A multimodal wrapper offers the quantized head as language_model.lm_head while the checkpoint storage map keys it as top-level lm_head. The segment-collapse loop only considered interior segments, so the head silently fell back to UnquantizedLinearMethod and loading the checkpoint's lm_head.mcg then failed. Collapse leading model/language_model segments too; exact-prefix candidates still take precedence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fused dense entry performs its outer rotations through exllamav3_ext.had_r_128, imported by module name inside b12x. Load the extension through the parity path's VLLM_EXL3_ABI_SHIM/VLLM_EXL3_EXT_PATH contract first, so an all-fused model does not import an unshimmed site-packages copy (undefined at::cuda::getCurrentCUDABlasHandle on torch 2.12). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up (CodeRabbit): _moe_prefix_is_exl3 hardcoded gate_proj/up_proj/down_proj while _validate_codebooks keys off the layer's ckpt_*_proj_name fields, so a remapped-projection MoE checkpoint would pass validation logic but silently miss EXL3 detection. Thread the RoutedExperts layer through and read its names, defaulting to the standard trio for layer variants that do not carry them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Brandon Music <brandon.m.music@gmail.com>
…MTP crash Port two upstream vLLM fixes so tool calling works under MTP speculative decoding: - structured_output: advance the grammar from the authoritative new_token_ids step delta instead of the counter-derived window, so the reasoning-end (</think>) marker is detected under async scheduling + spec decode and the tool-call grammar engages instead of emitting unconstrained output. (vllm-project#48516, supersedes vllm-project#44993.) - deepseek_v32 DSA: derive has_indexer from index_k as well, so MTP draft steps 1+ (skip_topk under index_share_for_mtp_iteration) no longer crash fused_norm_rope's index_k assertion. (vllm-project#48528.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Whitespace-only: collapses one call to canonical form so the ruff-format pre-commit hook passes on the touched file. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Companion sparkinfer build-compat PR (independent; needed to build this stack on public PyPI DSL 4.6.0): local-inference-lab/sparkinfer#70 |
📝 WalkthroughWalkthroughAdds EXL3 quantization for dense and rank-sliced MoE models, including metadata validation, loading, Trellis planning, parity fallback, and model integration. It also updates workspace lanes, distributed capture, KV-cache planning, attention paths, structured-output advancement, and optional FlashInfer JIT-cache installation. ChangesEXL3 quantization and rank-sliced MoE
Workspace and distributed capture
KV-cache and attention behavior
Structured-output advancement
FlashInfer JIT cache installation
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Checkpoint
participant ModelLoader
participant Exl3MoEMethod
participant Trellis
participant Exl3Extension
Checkpoint->>ModelLoader: provide rank-sliced weight names
ModelLoader->>Exl3MoEMethod: normalize and load local shards
Exl3MoEMethod->>Trellis: prepare weights and plans
Exl3MoEMethod->>Trellis: bind runtime inputs
Trellis->>Exl3Extension: execute rank-sliced MoE
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@vllm/model_executor/models/glm4_moe.py`:
- Around line 525-534: Assign the constructor’s quant_config parameter to
self.quant_config alongside self.config in Glm4MoeModel.__init__, so
load_weights can safely access it for both quantized and unquantized models.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 97bf2307-72f1-4fad-acca-5d9d9e7fd581
📒 Files selected for processing (9)
docker/Dockerfiletests/quantization/test_exl3.pytests/quantization/test_exl3_prefill_plan.pyvllm/config/model.pyvllm/model_executor/layers/fused_moe/routed_experts.pyvllm/model_executor/layers/quantization/__init__.pyvllm/model_executor/layers/quantization/exl3.pyvllm/model_executor/models/deepseek_v2.pyvllm/model_executor/models/glm4_moe.py
| rank_sliced_name = getattr( | ||
| self.quant_config, | ||
| "normalize_rank_sliced_weight_name", | ||
| None, | ||
| ) | ||
| for name, loaded_weight in weights: | ||
| if rank_sliced_name is not None: | ||
| name = rank_sliced_name(name) | ||
| if name is None: | ||
| continue |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Persist quant_config on Glm4MoeModel before using it here.
__init__ only defines the local quant_config (Line 418) and never assigns self.quant_config. Therefore every load_weights call now raises AttributeError, including unquantized models. Add self.quant_config = quant_config alongside self.config in __init__.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@vllm/model_executor/models/glm4_moe.py` around lines 525 - 534, Assign the
constructor’s quant_config parameter to self.quant_config alongside self.config
in Glm4MoeModel.__init__, so load_weights can safely access it for both
quantized and unquantized models.
Addresses the CodeRabbit maintainability note on PR vllm-project#139. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the rank-sliced EXL3 (ExLlamaV3 trellis) support added in local-inference-lab#139 by @brandonmusic, paired with the trellis3_t256 planned MoE from local-inference-lab/sparkinfer#49. No change to that work's weight loading, formats, or decode path; this commit only extends its batch dispatch. The vllm-project#139 backend serves every batch with m > 32 through the eager exllamav3_ext parity path: fp16-in/fp32-out staging (~25 GB extra HBM traffic per 4096-token step at GLM-5.2 geometry), python chunk loops, and one expert re-stream per 128-row chunk. That covers 100% of prefill, so prefill throughput is capped at ~2.0-2.3k tok/s while the same weights decode at full speed through the planned Trellis window. Build a second trellis_moe plan at max_tokens=max_num_batched_tokens with block_size_m=64 (env VLLM_EXL3_PREFILL_BLOCK_M, allowed {8,16,32,48,64}) beside the existing decode plan (32/8), and dispatch three ways in _apply_rank_sliced: * m in [min, max] -> decode plan (unchanged) * max < m <= capacity -> prefill plan (sparkinfer bind already accepts any tokens in [1, max_tokens]; zero kernel changes needed) * m < min -> parity path, whose persistent staging (xh/out32/token_sorted/weight_sorted) shrinks from capacity rows to one chunk while the prefill plan is live (~110 MiB/GPU returned) VLLM_EXL3_PREFILL_TRELLIS=0 restores the exact single-plan parity behavior (one-variable serving A/B lever). The parity branch now raises during CUDA graph capture instead of silently recording eager ext calls. The prefill arena (~1.05 GiB/GPU at capacity 3072) allocates during the profile pass, so vLLM's memory profiler sees it and the KV pool auto-shrinks instead of risking request-time OOM. Matched A/B serving brandonmusic/GLM-5.2-EXL3-TR3-3.0bpw on the published verdictai/glm52-exl3-sparkinfer runtime image (4x RTX PRO 6000 Blackwell, TP4/DCP4/MTP3, identical boot geometry, only the kill-switch flipped, fresh JIT cache both arms; llm_decode_bench prefill-only, exact /tokenize, C1, 60 s/context): ctx parity trellis delta 8k 2,287 3,742 +63.6% 32k 2,218 3,557 +60.4% 64k 2,078 3,381 +62.7% 128k 1,920 3,032 +57.9% Decode C1 unchanged-to-better; 57k-token needle retrieval exact.
Optional companion to the prefill-plan commit; mocked plan/ext APIs, no GPU, sparkinfer, or exllamav3_ext required. Covers plan construction order, the three-way dispatch boundaries, chunk-capped parity staging, the VLLM_EXL3_PREFILL_TRELLIS=0 kill-switch, VLLM_EXL3_PREFILL_BLOCK_M override, elastic re-plan above scheduler capacity, and the parity-path capture guard. Follows the existing CPU-test pattern of tests/quantization/test_exl3.py from local-inference-lab#139.
b801b62 to
f80a56d
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
vllm/v1/attention/backends/mla/b12x_mla_sparse.py (1)
2192-2198: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDead branch after gating simplification.
use_decode_kernelis nowattn_metadata.max_query_len <= 1, so inside this block the ternary conditionattn_metadata.max_query_len <= 1is always true and thecache_seq_lens_per_tokenelse branch is unreachable. Collapse to the direct assignment for clarity.♻️ Proposed simplification
use_decode_kernel = attn_metadata.max_query_len <= 1 if use_decode_kernel: - cache_seqlens = ( - attn_metadata.cache_seq_lens_per_req - if attn_metadata.max_query_len <= 1 - else attn_metadata.cache_seq_lens_per_token[:num_actual_toks] - ) + cache_seqlens = attn_metadata.cache_seq_lens_per_req🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@vllm/v1/attention/backends/mla/b12x_mla_sparse.py` around lines 2192 - 2198, In the use_decode_kernel block, simplify the cache_seqlens assignment to directly use attn_metadata.cache_seq_lens_per_req, removing the redundant max_query_len ternary and unreachable cache_seq_lens_per_token branch.vllm/distributed/device_communicators/custom_all_reduce.py (1)
83-95: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider a test for the negative-size validation branch.
_b12x_pcie_oneshot_limits()raisesValueErrorfor negative parsed sizes, but only the valid-size path is covered bytest_b12x_oneshot_buffer_tracks_dispatch_limits. A quick test setting a negative size (e.g."-5") would close this gap for a reachable misconfiguration.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@vllm/distributed/device_communicators/custom_all_reduce.py` around lines 83 - 95, The test suite should cover the negative-size validation branch in _b12x_pcie_oneshot_limits. Add a focused test alongside test_b12x_oneshot_buffer_tracks_dispatch_limits that configures a parsed size such as "-5" and asserts ValueError is raised, preserving the existing valid-size coverage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@vllm/v1/worker/gpu_worker.py`:
- Around line 400-409: Update the workspace sizing in the initialization flow
around init_workspace_manager to use parallel_config.num_ubatches directly
rather than deriving num_ubatches solely from enable_dbo. Preserve the existing
num_workspace_lanes calculation and pass the configured ubatch count so non-DBO
micro-batching allocates sufficient workspace slots.
---
Nitpick comments:
In `@vllm/distributed/device_communicators/custom_all_reduce.py`:
- Around line 83-95: The test suite should cover the negative-size validation
branch in _b12x_pcie_oneshot_limits. Add a focused test alongside
test_b12x_oneshot_buffer_tracks_dispatch_limits that configures a parsed size
such as "-5" and asserts ValueError is raised, preserving the existing
valid-size coverage.
In `@vllm/v1/attention/backends/mla/b12x_mla_sparse.py`:
- Around line 2192-2198: In the use_decode_kernel block, simplify the
cache_seqlens assignment to directly use attn_metadata.cache_seq_lens_per_req,
removing the redundant max_query_len ternary and unreachable
cache_seq_lens_per_token branch.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 5ea1ab24-7efe-4044-965e-31aa2874ab22
📒 Files selected for processing (25)
tests/distributed/test_b12x_fused_all_reduce.pytests/distributed/test_dcp_a2a.pytests/quantization/test_exl3_prefill_plan.pytests/v1/attention/test_sparse_mla_backends.pytests/v1/core/test_contiguous_kv_packing.pytests/v1/core/test_kv_cache_utils.pytests/v1/structured_output/test_reasoning_structured_output.pytests/v1/worker/test_workspace.pyvllm/distributed/device_communicators/custom_all_reduce.pyvllm/distributed/parallel_state.pyvllm/envs.pyvllm/model_executor/layers/quantization/exl3.pyvllm/models/deepseek_v32/nvidia/attention.pyvllm/v1/attention/backends/mla/b12x_mla_sparse.pyvllm/v1/attention/backends/mla/indexer.pyvllm/v1/attention/ops/dcp_alltoall.pyvllm/v1/core/kv_cache_utils.pyvllm/v1/core/sched/scheduler.pyvllm/v1/kv_cache_interface.pyvllm/v1/structured_output/__init__.pyvllm/v1/worker/gpu/model_runner.pyvllm/v1/worker/gpu/spec_decode/speculator.pyvllm/v1/worker/gpu/warmup.pyvllm/v1/worker/gpu_worker.pyvllm/v1/worker/workspace.py
🚧 Files skipped from review as they are similar to previous changes (2)
- vllm/model_executor/layers/quantization/exl3.py
- tests/quantization/test_exl3_prefill_plan.py
| # Target and speculative captures retain workspace views concurrently, | ||
| # so speculative V2 execution needs a separate ownership lane. | ||
| num_ubatches = 2 if self.vllm_config.parallel_config.enable_dbo else 1 | ||
| init_workspace_manager(self.device, num_ubatches) | ||
| num_workspace_lanes = ( | ||
| 2 | ||
| if self.use_v2_model_runner | ||
| and self.vllm_config.speculative_config is not None | ||
| else 1 | ||
| ) | ||
| init_workspace_manager(self.device, num_ubatches, num_workspace_lanes) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check whether ubatch_size can differ from 1 when enable_dbo is False, and how it's used elsewhere.
rg -nP -C3 'ubatch_size' vllm/config/parallel.py
rg -nP 'dbo_current_ubatch_id' vllm/v1 --type=py -g '!**/test/**'Repository: local-inference-lab/vllm
Length of output: 975
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== vllm/v1/worker/gpu_worker.py ==\n'
sed -n '390,420p' vllm/v1/worker/gpu_worker.py
printf '\n== vllm/v1/worker/workspace.py ==\n'
sed -n '1,260p' vllm/v1/worker/workspace.py
printf '\n== vllm/v1/worker/ubatching.py ==\n'
sed -n '1,240p' vllm/v1/worker/ubatching.py
printf '\n== vllm/config/parallel.py relevant section ==\n'
sed -n '180,240p' vllm/config/parallel.py
sed -n '510,540p' vllm/config/parallel.pyRepository: local-inference-lab/vllm
Length of output: 23682
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
for path, spans in {
"vllm/v1/worker/workspace.py": [(1, 260)],
"vllm/v1/worker/ubatching.py": [(1, 240)],
}.items():
print(f"\n== {path} ==")
text = Path(path).read_text().splitlines()
for start, end in spans:
for i in range(start, min(end, len(text)) + 1):
print(f"{i}:{text[i-1]}")
PYRepository: local-inference-lab/vllm
Length of output: 19861
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
for path, ranges in {
"vllm/v1/worker/workspace.py": [(130, 210)],
"vllm/v1/worker/ubatching.py": [(130, 190)],
}.items():
print(f"\n== {path} ==")
lines = Path(path).read_text().splitlines()
for start, end in ranges:
for i in range(start, min(end, len(lines)) + 1):
print(f"{i}:{lines[i-1]}")
PYRepository: local-inference-lab/vllm
Length of output: 6609
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "use_ubatching|num_ubatches|make_ubatch_contexts|enable_dbo|ubatch_size" vllm -g '*.py'Repository: local-inference-lab/vllm
Length of output: 5833
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
paths = [
"vllm/v1/worker/gpu_worker.py",
"vllm/v1/worker/ubatching.py",
"vllm/config/parallel.py",
]
for path in paths:
print(f"\n== {path} ==")
lines = Path(path).read_text().splitlines()
for i, line in enumerate(lines, 1):
if "use_ubatching" in line or "num_ubatches" in line or "make_ubatch_contexts" in line or "enable_dbo" in line or "ubatch_size" in line:
start = max(1, i - 8)
end = min(len(lines), i + 16)
for j in range(start, end + 1):
print(f"{j}:{lines[j-1]}")
print("---")
PYRepository: local-inference-lab/vllm
Length of output: 11169
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C3 "use_ubatching|make_ubatch_contexts|enable_dbo|ubatch_size" vllm -g '*.py'Repository: local-inference-lab/vllm
Length of output: 18892
Use parallel_config.num_ubatches for workspace sizing
use_ubatching also enables ubatch_size > 1 without DBO, but this path allocates only one ubatch slot unless DBO is on. That under-sizes WorkspaceManager for supported non-DBO micro-batching and can hit an out-of-range workspace index.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@vllm/v1/worker/gpu_worker.py` around lines 400 - 409, Update the workspace
sizing in the initialization flow around init_workspace_manager to use
parallel_config.num_ubatches directly rather than deriving num_ubatches solely
from enable_dbo. Preserve the existing num_workspace_lanes calculation and pass
the configured ubatch count so non-DBO micro-batching allocates sufficient
workspace slots.
|
Prebuilt test image (base = the published Drop-in with the model repo's compose: |
|
Status: incorporated into #139 as of its v20 rebase.
Also note @brandonmusic's |
Stacked on your
exl3-backendbranch (#139) — merging here lands the commits inside #139 with no history rewrite. No change to weight loading, formats, or the decode path; this only extends the rank-sliced batch dispatch.Problem
_apply_rank_slicedserves every batch withm > 32through the eagerexllamav3_extparity path: fp16-in/fp32-out staging (~25 GB extra HBM traffic per 4096-token step at GLM-5.2 geometry), python chunk loops, and one expert re-stream per 128-row chunk. That covers 100% of prefill, capping prefill at ~2.0–2.3k tok/s while the same weights decode at full speed through the planned Trellis window.Change
Build a second
trellis_moeplan atmax_tokens=max_num_batched_tokenswithblock_size_m=64(VLLM_EXL3_PREFILL_BLOCK_M, allowed {8,16,32,48,64}) beside the decode plan (32/8), and dispatch three ways:m ∈ [min, max]→ decode plan (unchanged)max < m ≤ capacity→ prefill plan — sparkinferbindalready accepts anytokens ∈ [1, max_tokens], so zero kernel changes are neededm < min→ parity path, whose persistent staging (xh/out32/token_sorted/weight_sorted) shrinks from capacity rows to one chunk while the prefill plan is live (~110 MiB/GPU returned)VLLM_EXL3_PREFILL_TRELLIS=0restores the exact single-plan parity behavior (one-variable serving A/B lever). The parity branch now raises during CUDA graph capture instead of silently recording eager ext calls. The prefill arena (~1.05 GiB/GPU at capacity 3072) allocates during the profile pass, so the memory profiler sees it and the KV pool auto-shrinks instead of risking request-time OOM.Measured
Matched A/B serving
brandonmusic/GLM-5.2-EXL3-TR3-3.0bpwon your publishedverdictai/glm52-exl3-sparkinferruntime image (4× RTX PRO 6000 Blackwell, TP4/DCP4/MTP3, identical boot geometry, only the kill-switch flipped, fresh JIT cache both arms; llm_decode_bench prefill-only, exact/tokenize, C1, 60 s/context):Decode C1 unchanged-to-better (99.7→101.8 aggregate across our runs); 57k-token needle retrieval exact; zero request errors.
Second commit adds CPU contract tests (mocked plan/ext APIs, no GPU/sparkinfer/ext needed) following the
test_exl3.pypattern — happy to drop it if you'd rather keep the branch minimal.Related build-compat PR on the sparkinfer side (independent, for PyPI DSL 4.6.0 users): see the companion PR on
exl3-trellis-fused.Summary by CodeRabbit
New Features
Bug Fixes
Tests