[SpecDecode] Add LiLiCorr drafter (#57934)
Signed-off-by: Andrii Skliar <askliar@nvidia.com> Signed-off-by: Andrii Skliar <andreyws96@gmail.com> Co-authored-by: Andrii Skliar <askliar@nvidia.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Benjamin Chislett <chislett.ben@gmail.com>
Andrii Skliar committed
Sep 25, 2026 at 16:08 UTC
73a78e6f1f38e280986b81e0f2a9aa5e1ee6fe47
21 files changed
+1933
-99
docs/features/speculative_decoding/README.md
+1
@@ -9,6 +9,7 @@ To train your own draft models for optimized speculative decoding, see [vllm-pro
9
vLLM supports a variety of methods of speculative decoding. Model-based methods such as EAGLE, MTP, draft models, PARD and MLP provide the best latency reduction, while simpler methods such as n-gram and suffix decoding provide modest speedups without increasing workload during peak traffic.
10
11
- [EAGLE](eagle.md)
12
+- [LiLiCorr](lilicorr.md)
13
- [Multi-Token Prediction (MTP)](mtp.md)
14
- [Draft Model](draft_model.md)
15
- [Parallel Draft Model (PARD)](parallel_draft_model.md)
docs/features/speculative_decoding/lilicorr.md
new
+77
@@ -0,0 +1,77 @@
1
+# LiLiCorr
2
+
3
+LiLiCorr uses a DFlash backbone to produce per-position candidates, then scores
4
+candidate combinations with a learned correlator. It requires a compatible
5
+trained checkpoint and Model Runner V2, which is selected automatically.
6
+Both plain and grouped-convolution checkpoints are supported.
7
+
8
+## Usage
9
+
10
+For a checkpoint trained with block size 16:
11
+
12
+```bash
13
+vllm serve /path/to/target --dtype bfloat16 \
14
+ --speculative-config '{"method":"dflash","model":"/path/to/lilicorr","num_speculative_tokens":15,"draft_sample_method":"probabilistic"}'
15
+```
16
+
17
+We strongly recommend explicitly setting `num_speculative_tokens` to the
18
+checkpoint's trained `block_size - 1` (for example, `15` for `block_size=16`).
19
+The trained block includes one anchor position. Other supported lengths are
20
+available, but may degrade acceptance length unpredictably; benchmark them with
21
+your checkpoint and workload before using them.
22
+
23
+Shorter drafts, down to one token, use the corresponding prefix of the learned
24
+slot embeddings and attention bias without changing checkpoint parameter shapes.
25
+Lengths above `block_size - 1` are unsupported because the checkpoint has no
26
+learned slot embeddings for those positions.
27
+
28
+Fixed-length drafting with shorter verification is a separate option provided by
29
+adaptive verification (`enable_adaptive_verification`). LiLiCorr shares the
30
+candidate-sampling and acceptance-estimation path, but this combination has not
31
+been validated end-to-end. Adaptive verification also requires compatible target
32
+attention backends and variable-length CUDA graph support; do not assume it works
33
+with Mamba2 targets.
34
+
35
+## Proposal sampling
36
+
37
+- `"draft_sample_method":"greedy"` selects the highest-scoring candidate at each
38
+ step of the correlator's conditional walk.
39
+- `"draft_sample_method":"probabilistic"` samples from those conditional scores
40
+ using the request's temperature and supplies the realized proposal
41
+ distributions to the rejection sampler.
42
+
43
+These settings control proposals; they do not replace the target model's
44
+sampling settings. With request temperature zero, probabilistic proposals also
45
+use argmax. For nonzero temperatures, benchmark both modes with your checkpoint
46
+and workload: compare acceptance length and end-to-end throughput or latency.
47
+Neither mode is universally faster.
48
+
49
+The default rejection method is `"rejection_sample_method":"standard"`.
50
+The shared Model Runner V2 rejection sampler also exposes `"block"`, but
51
+LiLiCorr-specific GPU correctness and performance validation for that combination
52
+is still pending. Do not assume that changing the rejection method improves
53
+throughput; evaluate it separately from the proposal sampling mode.
54
+
55
+## Checkpoint requirements
56
+
57
+The checkpoint must declare `LiLiCorrDraftModel` and include all `lilicorr_*`
58
+geometry fields and trained head weights. Geometry is read from `dflash_config`.
59
+Convolution tensors must match the configured `conv_kernel_size` and
60
+`conv_group_size`. Target input embeddings must be available on the draft rank.
61
+By default, candidates use the target LM head, which must also be available there.
62
+A checkpoint with top-level `"has_own_lm_head": true` instead uses its own
63
+`lm_head` with the draft quantization configuration and exclusions. Its weights
64
+must be present; for ModelOpt NVFP4 this includes `lm_head.weight`,
65
+`lm_head.weight_scale`, and `lm_head.weight_scale_2`. The owned head is preserved
66
+and is not replaced by the target head. Candidate token embeddings still come
67
+from the target.
68
+
69
+Correlator linear layers and convolution kernel projections use the draft
70
+quantization configuration and its module exclusions. The following parameters
71
+must remain in floating-point model dtype:
72
+
73
+- Lattice QKV `in_proj_weight` and `in_proj_bias`, which retain the exported
74
+ parameter layout.
75
+- `lilicorr.factor_input_proj`, `lilicorr.out_head`, and `lilicorr.in_head`, which
76
+ are used by the split factor and fused edge projections. Exclude these modules
77
+ when exporting a quantized checkpoint.
tests/models/registry.py
+7
@@ -1502,6 +1502,13 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1502
max_model_len=8192, # Reduce max len to ensure test runs in low-VRAM CI env
1503
max_num_seqs=32,
1504
),
1505
+ # LiLiCorr checkpoints are not published yet.
1506
+ "LiLiCorrDraftModel": _HfExamplesInfo(
1507
+ "Qwen/Qwen3-8B",
1508
+ speculative_model="LiLiCorrDraftModel",
1509
+ is_available_online=False,
1510
+ use_original_num_layers=True,
1511
+ ),
1512
"DFlash2DraftModel": _HfExamplesInfo(
1513
"Qwen/Qwen3.8-27B",
1514
speculative_model="z-lab/Qwen3.8-27B-DFlash2",
tests/test_config.py
+14
-9
@@ -582,7 +582,7 @@ def test_rocm_mrv1_default_yields_to_v1_unsupported_config(monkeypatch):
582
speculative_config=None,
583
)
584
config._dflash_needs_multi_kv_group = lambda: False
585
- config._is_dflash2_draft = lambda: False
585
+ config._is_dflash_candidate_draft = lambda: False
586
config._get_v2_model_runner_unsupported_features = lambda: []
587
# The real predicate, so the test also pins where dspark lands in it.
588
config._get_v1_model_runner_unsupported_features = lambda: (
@@ -813,7 +813,8 @@ def test_v2_model_runner_supports_custom_logits_processors():
813
assert config._get_v2_model_runner_unsupported_features() == []
814
815
816
-def test_dflash2_draft_forces_v2_model_runner():
816
+@pytest.mark.parametrize("architecture", ["DFlash2DraftModel", "LiLiCorrDraftModel"])
817
+def test_dflash_candidate_draft_forces_v2_model_runner(architecture):
818
"""A DFlash2 draft must reach the V2 speculator, the only one that runs its
819
candidate selector; on V1 it would draft as DFlash1 without raising."""
820
@@ -825,11 +826,15 @@ def test_dflash2_draft_forces_v2_model_runner():
826
)
827
)
828
828
- assert VllmConfig._is_dflash2_draft(config("dflash", ["DFlash2DraftModel"]))
829
- assert not VllmConfig._is_dflash2_draft(config("dflash", ["DFlashDraftModel"]))
830
- assert not VllmConfig._is_dflash2_draft(config("eagle", ["DFlash2DraftModel"]))
831
- assert not VllmConfig._is_dflash2_draft(SimpleNamespace(speculative_config=None))
832
- assert not VllmConfig._is_dflash2_draft(
829
+ assert VllmConfig._is_dflash_candidate_draft(config("dflash", [architecture]))
830
+ assert not VllmConfig._is_dflash_candidate_draft(
831
+ config("dflash", ["DFlashDraftModel"])
832
+ )
833
+ assert not VllmConfig._is_dflash_candidate_draft(config("eagle", [architecture]))
834
+ assert not VllmConfig._is_dflash_candidate_draft(
835
+ SimpleNamespace(speculative_config=None)
836
+ )
837
+ assert not VllmConfig._is_dflash_candidate_draft(
838
SimpleNamespace(
839
speculative_config=SimpleNamespace(method="dflash", draft_model_config=None)
840
)
@@ -1243,7 +1248,7 @@ def test_v1_model_runner_rejects_v2_only_features():
1248
model_config=None,
1249
)
1250
config._dflash_needs_multi_kv_group = lambda: False
1246
- config._is_dflash2_draft = lambda: False
1251
+ config._is_dflash_candidate_draft = lambda: False
1252
config._get_v1_model_runner_unsupported_features = lambda: (
1253
VllmConfig._get_v1_model_runner_unsupported_features(config)
1254
)
@@ -1430,7 +1435,7 @@ def test_v1_model_runner_rejects_pipeline_parallelism_with_async_scheduling():
1435
model_config=None,
1436
)
1437
config._dflash_needs_multi_kv_group = lambda: False
1433
- config._is_dflash2_draft = lambda: False
1438
+ config._is_dflash_candidate_draft = lambda: False
1439
1440
assert VllmConfig._get_v1_model_runner_unsupported_features(config) == []
1441
tests/v1/spec_decode/test_dflash2.py
+180
-5
@@ -6,6 +6,9 @@ from types import SimpleNamespace
6
import pytest
7
import torch
8
9
+from vllm.model_executor.models.qwen3_dflash import (
10
+ _add_global_draft_layer_exclusions,
11
+)
12
from vllm.model_executor.models.qwen3_dflash2 import _grouped_conv, _score_edges
13
from vllm.platforms import current_platform
14
from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
@@ -85,6 +88,24 @@ def test_grouped_conv_triton_matches_reference(
88
)
89
90
91
+def test_draft_quant_exclusions_include_global_layer_indices():
92
+ quant_config = SimpleNamespace(
93
+ exclude_modules=[
94
+ "layers.0.mlp_conv*",
95
+ "*layers.4.self_attn.q_proj",
96
+ "layers.88.already_global",
97
+ "lilicorr.layers.0.mlp.0",
98
+ ]
99
+ )
100
+
101
+ _add_global_draft_layer_exclusions(quant_config, 88, 5)
102
+
103
+ assert "layers.88.mlp_conv*" in quant_config.exclude_modules
104
+ assert "*layers.92.self_attn.q_proj" in quant_config.exclude_modules
105
+ assert quant_config.exclude_modules.count("layers.88.already_global") == 1
106
+ assert "lilicorr.layers.88.mlp.0" not in quant_config.exclude_modules
107
+
108
+
109
def test_selector_edges_match_sequential_reference():
110
torch.manual_seed(1)
111
batch, steps, top_k, rank = 2, 4, 3, 5
@@ -169,10 +190,13 @@ def test_selector_asks_for_fp32_proposal_logits():
190
191
192
@pytest.mark.skip_global_cleanup
172
-def test_dflash2_model_decoder_layer_cls(monkeypatch):
193
+@pytest.mark.parametrize("variant", ["dflash2", "lilicorr", "lilicorr_plain"])
194
+def test_candidate_model_decoder_layer_cls(monkeypatch, variant):
195
from types import SimpleNamespace
196
197
from vllm.config import set_current_vllm_config
198
+ from vllm.model_executor.models.lilicorr import LiLiCorr
199
+ from vllm.model_executor.models.qwen3_dflash import DFlashQwen3DecoderLayer
200
from vllm.model_executor.models.qwen3_dflash2 import (
201
DFlash2Qwen3DecoderLayer,
202
DFlash2Qwen3Model,
@@ -249,8 +273,17 @@ def test_dflash2_model_decoder_layer_cls(monkeypatch):
273
dflash_config={
274
"selector_rank": 4,
275
"selector_top_k": 3,
252
- "conv_kernel_size": 3,
253
- "conv_group_size": 2,
276
+ "conv_kernel_size": 0 if variant == "lilicorr_plain" else 3,
277
+ "conv_group_size": 0 if variant == "lilicorr_plain" else 2,
278
+ "block_size": 5,
279
+ "lilicorr_candidate_topk": 4,
280
+ "lilicorr_hidden_size": 8,
281
+ "lilicorr_num_layers": 2,
282
+ "lilicorr_num_heads": 2,
283
+ "lilicorr_mlp_ratio": 2.0,
284
+ "lilicorr_factor_dim": 4,
285
+ "lilicorr_vector_eps": 1e-6,
286
+ "lilicorr_logit_scale": 3.0,
287
"use_aux_hidden_state": False,
288
},
289
)
@@ -277,8 +310,150 @@ def test_dflash2_model_decoder_layer_cls(monkeypatch):
310
311
# 3. Instantiate the model under meta device to avoid parameter allocation issues
312
with set_current_vllm_config(mock_current_vllm_config), torch.device("meta"):
280
- model = DFlash2Qwen3Model(vllm_config=vllm_config)
313
+ model_cls = DFlash2Qwen3Model if variant == "dflash2" else LiLiCorr
314
+ model = model_cls(vllm_config=vllm_config)
315
316
# 4. Assert that the layers are DFlash2Qwen3DecoderLayer (the subclass)
317
assert len(model.layers) == 2
284
- assert isinstance(model.layers[0], DFlash2Qwen3DecoderLayer)
318
+ expected = (
319
+ DFlashQwen3DecoderLayer
320
+ if variant == "lilicorr_plain"
321
+ else DFlash2Qwen3DecoderLayer
322
+ )
323
+ assert type(model.layers[0]) is expected
324
+
325
+
326
+def test_conv_projections_use_draft_quant_config(monkeypatch):
327
+ from torch import nn
328
+
329
+ from vllm.distributed import parallel_state
330
+ from vllm.model_executor.layers.quantization import modelopt
331
+ from vllm.model_executor.models.qwen3_dflash import DFlashQwen3DecoderLayer
332
+ from vllm.model_executor.models.qwen3_dflash2 import DFlash2Qwen3DecoderLayer
333
+ from vllm.model_executor.models.utils import AutoWeightsLoader
334
+
335
+ monkeypatch.setattr(
336
+ parallel_state, "_TP", SimpleNamespace(rank_in_group=0, world_size=1)
337
+ )
338
+ monkeypatch.setattr(
339
+ DFlashQwen3DecoderLayer,
340
+ "__init__",
341
+ lambda self, *a, **kw: nn.Module.__init__(self),
342
+ )
343
+ # Exercise the real quantized parameter allocation without selecting a GPU kernel.
344
+ monkeypatch.setattr(
345
+ modelopt,
346
+ "select_linear_kernel",
347
+ lambda *a, **kw: SimpleNamespace(input_quant_key=lambda: None),
348
+ )
349
+ quant_config = modelopt.ModelOptNvFp4Config(
350
+ quant_method="W4A16_NVFP4", is_checkpoint_nvfp4_serialized=True
351
+ )
352
+ layer = DFlash2Qwen3DecoderLayer(
353
+ SimpleNamespace(
354
+ speculative_config=SimpleNamespace(num_speculative_tokens=7),
355
+ model_config=SimpleNamespace(dtype=torch.bfloat16),
356
+ ),
357
+ config=SimpleNamespace(
358
+ hidden_size=16,
359
+ dflash_config={"conv_kernel_size": 2, "conv_group_size": 2},
360
+ ),
361
+ layer_idx=0,
362
+ prefix="model.layers.0",
363
+ quant_config=quant_config,
364
+ )
365
+ for name in ("attention_conv", "mlp_conv"):
366
+ module = getattr(layer, name)
367
+ projection = module.kernel_projection
368
+ assert projection.weight.dtype == torch.uint8
369
+ assert projection.weight.shape == (32, 8)
370
+ weight = torch.ones_like(projection.weight)
371
+ AutoWeightsLoader(module).load_weights([("kernel_projection.weight", weight)])
372
+ torch.testing.assert_close(projection.weight, weight)
373
+ assert module.base_kernel.dtype == torch.bfloat16
374
+
375
+
376
+def test_context_kv_uses_quantized_projection_fallback(monkeypatch):
377
+ from torch import nn
378
+
379
+ from vllm.model_executor.models import qwen3_dflash
380
+
381
+ class Projection(nn.Module):
382
+ def __init__(self, packed_weight):
383
+ super().__init__()
384
+ self.register_buffer("packed_weight", packed_weight)
385
+ self.quant_method = object()
386
+ self.calls = 0
387
+
388
+ def forward(self, hidden_states):
389
+ self.calls += 1
390
+ return torch.nn.functional.linear(hidden_states, self.packed_weight), None
391
+
392
+ monkeypatch.setattr(
393
+ qwen3_dflash.ops,
394
+ "rms_norm",
395
+ lambda output, hidden_states, weight, eps: output.copy_(hidden_states),
396
+ )
397
+ context_states = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
398
+ projections = [
399
+ Projection(
400
+ torch.tensor(
401
+ [
402
+ [0.0, 0.0],
403
+ [0.0, 0.0],
404
+ [1.0, 0.0],
405
+ [0.0, 1.0],
406
+ [1.0, 1.0],
407
+ [1.0, -1.0],
408
+ ]
409
+ )
410
+ ),
411
+ Projection(
412
+ torch.tensor(
413
+ [
414
+ [0.0, 0.0],
415
+ [0.0, 0.0],
416
+ [2.0, 0.0],
417
+ [0.0, 2.0],
418
+ [-1.0, 0.0],
419
+ [0.0, -1.0],
420
+ ]
421
+ )
422
+ ),
423
+ ]
424
+ model = SimpleNamespace(
425
+ hidden_norm=SimpleNamespace(weight=nn.Parameter(torch.ones(2))),
426
+ _rms_norm_eps=1e-6,
427
+ )
428
+ layers_attn = [
429
+ SimpleNamespace(
430
+ qkv_proj=projection,
431
+ q_size=2,
432
+ k_norm=SimpleNamespace(weight=nn.Parameter(torch.ones(2))),
433
+ )
434
+ for projection in projections
435
+ ]
436
+ qwen3_dflash.DFlashQwen3Model._build_context_kv_buffers(
437
+ model, layers_attn, has_bias=False
438
+ )
439
+ assert model._fused_kv_weight is None
440
+ assert all(not hasattr(projection, "weight") for projection in projections)
441
+
442
+ actual_k, actual_v = qwen3_dflash.DFlashQwen3Model._project_context_kv(
443
+ model,
444
+ context_states,
445
+ num_ctx=2,
446
+ num_layers=2,
447
+ num_kv_heads=1,
448
+ head_dim=2,
449
+ )
450
+
451
+ expected_k = torch.tensor(
452
+ [[[1.0, 2.0], [3.0, 4.0]], [[2.0, 4.0], [6.0, 8.0]]]
453
+ ).unsqueeze(2)
454
+ expected_v = torch.tensor(
455
+ [[[3.0, -1.0], [7.0, -1.0]], [[-1.0, -2.0], [-3.0, -4.0]]]
456
+ ).unsqueeze(2)
457
+ torch.testing.assert_close(actual_k, expected_k)
458
+ torch.testing.assert_close(actual_v, expected_v)
459
+ assert [projection.calls for projection in projections] == [1, 1]
tests/v1/spec_decode/test_lilicorr.py
new
+777
@@ -0,0 +1,777 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from types import SimpleNamespace
5
+from typing import Any
6
+
7
+import pytest
8
+import torch
9
+import torch.nn.functional as F
10
+from torch import nn
11
+
12
+from vllm.config import (
13
+ CompilationConfig,
14
+ DeviceConfig,
15
+ LoadConfig,
16
+ VllmConfig,
17
+ set_current_vllm_config,
18
+)
19
+from vllm.distributed import parallel_state
20
+from vllm.model_executor.layers import logits_processor
21
+from vllm.model_executor.layers.linear import LinearBase
22
+from vllm.model_executor.models.lilicorr import LiLiCorrHead
23
+from vllm.transformers_utils.configs.eagle import EAGLEConfig
24
+from vllm.v1.worker.gpu.spec_decode.dflash2.speculator import CandidateSampler
25
+from vllm.v1.worker.gpu.spec_decode.lilicorr.speculator import LiLiCorrSpeculator
26
+
27
+
28
+@pytest.fixture(autouse=True)
29
+def head_tp_metadata(monkeypatch):
30
+ # Single-rank unit tests only need TP metadata.
31
+ monkeypatch.setattr(
32
+ parallel_state, "_TP", SimpleNamespace(rank_in_group=0, world_size=1)
33
+ )
34
+
35
+
36
+def _config(**overrides):
37
+ config = dict(
38
+ candidate_topk=4,
39
+ hidden_size=8,
40
+ num_layers=2,
41
+ num_heads=2,
42
+ mlp_ratio=2.0,
43
+ factor_dim=4,
44
+ vector_eps=1e-6,
45
+ logit_scale=3.0,
46
+ )
47
+ config.update(overrides)
48
+ return {f"lilicorr_{key}": value for key, value in config.items()}
49
+
50
+
51
+def _reference_scores(head, embeddings, log_probs, hidden, anchor, valid):
52
+ """Unfused exported head: MHA attention, concatenated factors, separate edges."""
53
+ batch, slots, top_k = log_probs.shape
54
+ rank = torch.arange(top_k, device=log_probs.device) / max(top_k - 1, 1)
55
+ top1 = (torch.arange(top_k, device=log_probs.device) == 0).float()
56
+ features = torch.stack(
57
+ (
58
+ log_probs,
59
+ log_probs.exp(),
60
+ log_probs - log_probs.amax(-1, keepdim=True),
61
+ rank.expand_as(log_probs),
62
+ top1.expand_as(log_probs),
63
+ ),
64
+ -1,
65
+ )
66
+ x = head.token_proj(embeddings) + head.pass_hidden_proj(hidden).unsqueeze(-2)
67
+ features = head.feature_norm(features.to(x.dtype))
68
+ x = x + head.feature_mlp.down_proj(F.silu(head.feature_mlp.up_proj(features)[0]))[0]
69
+ x = x + head.slot_embedding[:, 0, :slots] + head.rank_embedding[:, 0]
70
+ x = x.flatten(1, 2)
71
+ positions = torch.arange(slots, device=x.device).repeat_interleave(top_k)
72
+ relative = positions[:, None] - positions[None, :]
73
+ bias = head.relative_slot_bias[:, relative + head.block_size - 1]
74
+ bias = bias + (relative == 0) * head.same_slot_bias[:, None, None]
75
+ bias = bias.repeat(batch, 1, 1)
76
+ for layer in head.layers:
77
+ attention = nn.MultiheadAttention(
78
+ head.hidden_size,
79
+ head.num_heads,
80
+ batch_first=True,
81
+ dtype=x.dtype,
82
+ device=x.device,
83
+ )
84
+ attention.load_state_dict(layer.attn.state_dict())
85
+ normalized = layer.attn_norm(x)
86
+ x = (
87
+ x
88
+ + attention(
89
+ normalized, normalized, normalized, attn_mask=bias, need_weights=False
90
+ )[0]
91
+ )
92
+ x = x + layer.mlp.down_proj(F.silu(layer.mlp.up_proj(layer.mlp_norm(x))[0]))[0]
93
+ x = head.output_norm(x).view(batch, slots, top_k, -1)
94
+ a = head.anchor_norm(head.context_proj(anchor) * valid[:, None])
95
+ expanded = a[:, None, None].expand_as(x)
96
+ factors = F.silu(head.factor_input_proj(torch.cat((x, expanded, x * expanded), -1)))
97
+ incoming = F.normalize(head.in_head(factors), dim=-1, eps=head.vector_eps)
98
+ outgoing = F.normalize(head.out_head(factors), dim=-1, eps=head.vector_eps)
99
+ a = F.normalize(head.anchor_out_head(a), dim=-1, eps=head.vector_eps)
100
+ start = (a[:, None] * incoming[:, 0]).sum(-1)
101
+ pairs = outgoing[:, :-1] @ incoming[:, 1:].transpose(-1, -2)
102
+ return (
103
+ head.logit_scale
104
+ * torch.cat((start[:, None, None].expand(-1, 1, top_k, -1), pairs), 1).float()
105
+ )
106
+
107
+
108
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA or ROCm")
109
+@pytest.mark.parametrize(
110
+ "head_width,slots,dtype",
111
+ [
112
+ pytest.param(8, 3, torch.float32, id="projected-multi-slot"),
113
+ pytest.param(16, 1, torch.float32, id="identity-shortened-single-slot"),
114
+ pytest.param(8, 2, torch.bfloat16, id="bf16-shortened-multi-slot"),
115
+ ],
116
+)
117
+def test_lilicorr_matches_exported_head(head_width, slots, dtype):
118
+ device = torch.device("cuda")
119
+ torch.manual_seed(7)
120
+ with (
121
+ torch.device(device),
122
+ set_current_vllm_config(
123
+ VllmConfig(
124
+ device_config=DeviceConfig("cuda"),
125
+ compilation_config=CompilationConfig(mode=0),
126
+ )
127
+ ),
128
+ ):
129
+ head = LiLiCorrHead(
130
+ model_hidden_size=16,
131
+ block_size=4,
132
+ rms_norm_eps=1e-6,
133
+ config=_config(hidden_size=head_width),
134
+ ).to(dtype)
135
+ # vLLM linear parameters are initialized by the checkpoint loader.
136
+ with torch.no_grad(), torch.device(device):
137
+ for module in head.modules():
138
+ if isinstance(module, LinearBase):
139
+ for parameter in module.parameters():
140
+ parameter.weight_loader(
141
+ parameter, torch.randn_like(parameter) * 0.1
142
+ )
143
+ for layer in head.layers:
144
+ layer.attn.in_proj_weight.normal_(std=0.1)
145
+ layer.attn.in_proj_bias.normal_(std=0.1)
146
+ head.relative_slot_bias.normal_(std=0.1)
147
+ head.same_slot_bias.normal_(std=0.1)
148
+ head.slot_embedding.normal_(std=0.1)
149
+ head.rank_embedding.normal_(std=0.1)
150
+ head.materialize_inference_buffers(device, dtype)
151
+ inputs = (
152
+ torch.randn(2, slots, 4, 16, dtype=dtype),
153
+ torch.randn(2, slots, 23).log_softmax(-1).topk(4).values,
154
+ torch.randn(2, slots, 16, dtype=dtype),
155
+ torch.randn(2, 16, dtype=dtype),
156
+ torch.tensor([True, False]),
157
+ )
158
+ actual = head(*inputs)
159
+ expected = _reference_scores(head, *inputs)
160
+ tolerance = 0.04 if dtype == torch.bfloat16 else 2e-6
161
+ torch.testing.assert_close(actual, expected, atol=tolerance, rtol=tolerance)
162
+
163
+
164
+def test_runtime_length_keeps_trained_checkpoint_geometry(monkeypatch):
165
+ from vllm.model_executor.models.lilicorr import LiLiCorr
166
+ from vllm.model_executor.models.qwen3_dflash import DFlashQwen3Model
167
+
168
+ def init_backbone(self, **kwargs):
169
+ nn.Module.__init__(self)
170
+ self.quant_config = None
171
+
172
+ monkeypatch.setattr(DFlashQwen3Model, "__init__", init_backbone)
173
+ spec = SimpleNamespace(
174
+ num_speculative_tokens=1,
175
+ draft_model_config=SimpleNamespace(
176
+ hf_config=SimpleNamespace(
177
+ hidden_size=16,
178
+ rms_norm_eps=1e-6,
179
+ dflash_config=_config() | {"block_size": 4},
180
+ )
181
+ ),
182
+ )
183
+ with set_current_vllm_config(
184
+ VllmConfig(
185
+ device_config=DeviceConfig("cpu"),
186
+ compilation_config=CompilationConfig(mode=0),
187
+ )
188
+ ):
189
+ for slots in (1, 3):
190
+ spec.num_speculative_tokens = slots
191
+ head = LiLiCorr(
192
+ vllm_config=SimpleNamespace(speculative_config=spec)
193
+ ).lilicorr
194
+ assert head.slot_embedding.shape == (1, 1, 3, 1, 8)
195
+ assert head.relative_slot_bias.shape == (2, 7)
196
+ spec.draft_model_config.hf_config.dflash_config["lilicorr_enabled"] = False
197
+ with pytest.raises(ValueError, match="lilicorr_enabled"):
198
+ LiLiCorr(vllm_config=SimpleNamespace(speculative_config=spec))
199
+ spec.draft_model_config.hf_config.dflash_config["lilicorr_enabled"] = True
200
+ spec.num_speculative_tokens = 4
201
+ with pytest.raises(ValueError, match="trained block_size"):
202
+ LiLiCorr(vllm_config=SimpleNamespace(speculative_config=spec))
203
+
204
+
205
+@pytest.mark.parametrize("tp_size", [1, 2])
206
+def test_candidates_use_global_partition_and_exclude_padding(monkeypatch, tp_size):
207
+ """Candidate features must include mass outside top-k and on other TP ranks."""
208
+
209
+ class Gather:
210
+ def __init__(self, calls):
211
+ self.calls = calls
212
+ self.index = 0
213
+
214
+ def __call__(self, value, dim=-1):
215
+ expected_input, result = self.calls[self.index]
216
+ assert dim == -1
217
+ torch.testing.assert_close(value, expected_input)
218
+ self.index += 1
219
+ return result
220
+
221
+ shards = [
222
+ torch.tensor([[1.0, 3.0, 2.0, 100.0], [4.0, 1.0, 2.0, 100.0]]),
223
+ torch.tensor([[6.0, 5.0, 0.0, 100.0], [3.0, 6.0, 5.0, 100.0]]),
224
+ ][:tp_size]
225
+ for rank in range(tp_size):
226
+ fake = SimpleNamespace(
227
+ scale=1.0,
228
+ soft_cap=None,
229
+ _apply_head=lambda *args, rank=rank: shards[rank].clone(),
230
+ )
231
+ lm_head = SimpleNamespace(
232
+ tp_size=tp_size,
233
+ shard_indices=SimpleNamespace(
234
+ num_org_vocab_padding=1, org_vocab_start_index=rank * 3
235
+ ),
236
+ )
237
+ valid_shards = [shard[:, :3] for shard in shards]
238
+ local_topk = [shard.topk(2) for shard in valid_shards]
239
+ gather_calls = [
240
+ (
241
+ valid_shards[rank].logsumexp(-1, keepdim=True),
242
+ torch.cat(
243
+ [shard.logsumexp(-1, keepdim=True) for shard in valid_shards],
244
+ -1,
245
+ ),
246
+ ),
247
+ (
248
+ local_topk[rank].values,
249
+ torch.cat([topk.values for topk in local_topk], -1),
250
+ ),
251
+ (
252
+ local_topk[rank].indices + rank * 3,
253
+ torch.cat(
254
+ [
255
+ topk.indices + shard_rank * 3
256
+ for shard_rank, topk in enumerate(local_topk)
257
+ ],
258
+ -1,
259
+ ),
260
+ ),
261
+ ]
262
+ all_gather = Gather(gather_calls)
263
+
264
+ monkeypatch.setattr(
265
+ logits_processor,
266
+ "tensor_model_parallel_all_gather",
267
+ all_gather,
268
+ )
269
+ monkeypatch.setattr(logits_processor, "_topk", lambda x, k: x.topk(k, dim=-1))
270
+ ids, values = logits_processor.LogitsProcessor.get_top_k_tokens(
271
+ fake, lm_head, torch.empty(2, 1), 2, return_log_probs=True
272
+ )
273
+ expected = torch.cat(valid_shards, -1).log_softmax(-1).topk(2)
274
+ torch.testing.assert_close(ids, expected.indices)
275
+ torch.testing.assert_close(values, expected.values)
276
+ assert all_gather.index == (3 if tp_size > 1 else 0)
277
+
278
+
279
+def test_context_anchor_is_last_committed_normalized_feature():
280
+ hidden = torch.arange(1, 25, dtype=torch.float32).view(6, 4)
281
+ norm = nn.RMSNorm(4)
282
+ spec = SimpleNamespace(
283
+ hidden_states=hidden,
284
+ model=SimpleNamespace(model=SimpleNamespace(hidden_norm=norm)),
285
+ anchor_hidden=torch.full((4, 4), 123.0),
286
+ anchor_valid=torch.ones(4, dtype=torch.bool),
287
+ )
288
+ batch = SimpleNamespace(num_reqs=2, query_start_loc=torch.tensor([0, 2, 6]))
289
+ LiLiCorrSpeculator.prepare_context_anchor(spec, batch, torch.tensor([0, 2]))
290
+ torch.testing.assert_close(spec.anchor_hidden[:2], norm(hidden[[1, 3]]))
291
+ assert spec.anchor_valid.tolist() == [True, True, False, False]
292
+ assert not spec.anchor_hidden[2:].any()
293
+ # A smaller reordered batch must not retain an old request's anchor.
294
+ batch = SimpleNamespace(num_reqs=1, query_start_loc=torch.tensor([0, 3]))
295
+ LiLiCorrSpeculator.prepare_context_anchor(spec, batch, torch.tensor([1]))
296
+ torch.testing.assert_close(spec.anchor_hidden[0], norm(hidden[1]))
297
+ assert not spec.anchor_hidden[1:].any()
298
+
299
+
300
+@pytest.mark.parametrize("lilicorr", [False, True])
301
+def test_candidate_generation_routes_scores_and_adaptive_inputs(monkeypatch, lilicorr):
302
+ """Both heads share the walk, including a padded request and adaptive scores."""
303
+ from vllm.v1.worker.gpu.spec_decode.dflash.speculator import DFlashSpeculator
304
+ from vllm.v1.worker.gpu.spec_decode.dflash2.speculator import DFlash2Speculator
305
+
306
+ key = "lilicorr_candidate_topk" if lilicorr else "selector_top_k"
307
+
308
+ def init_base(self, config, device):
309
+ self.draft_model_config = SimpleNamespace(
310
+ hf_config=SimpleNamespace(hidden_size=4, dflash_config={key: 4})
311
+ )
312
+ self.max_num_reqs = 3
313
+ self.num_speculative_steps = 2
314
+ self.num_query_per_req = 3
315
+ self.dtype = torch.float32
316
+
317
+ monkeypatch.setattr(DFlashSpeculator, "__init__", init_base)
318
+ cls = LiLiCorrSpeculator if lilicorr else DFlash2Speculator
319
+ spec = cls(None, torch.device("cpu"))
320
+ hidden = torch.arange(36).float().view(9, 4)
321
+ spec._run_model = lambda *args: hidden
322
+ spec.sample_indices = torch.tensor([1, 2, 4, 5, 7, 8])
323
+ spec.sample_pos = torch.arange(6)
324
+ spec.sample_idx_mapping = torch.tensor([1, 1, 0, 0, -1, -1])
325
+ spec.sample_col = torch.tensor([0, 1, 0, 1, 0, 1])
326
+ spec.temperature = torch.ones(3)
327
+ spec.seeds = torch.arange(3)
328
+ spec.draft_tokens = torch.empty(3, 2, dtype=torch.long)
329
+ spec.draft_logits = None
330
+ spec.use_fp64_gumbel = False
331
+ spec.enable_adaptive_verification = True
332
+ spec.input_buffers = SimpleNamespace(input_ids=torch.arange(9))
333
+ candidates = torch.arange(24).view(6, 4)
334
+ log_probs = candidates.float().log_softmax(-1)
335
+ scores = torch.randn(3, 2, 4, 4)
336
+ scoring_inputs: list[torch.Tensor] = []
337
+ sampling_inputs: list[Any] = []
338
+ adaptive_inputs: list[torch.Tensor] = []
339
+
340
+ def score(*args):
341
+ scoring_inputs.extend(args)
342
+ return scores
343
+
344
+ def sample(*args):
345
+ sampling_inputs.extend(args)
346
+ spec.candidate_sampler.scores.copy_(scores[:, :, 0])
347
+
348
+ spec.model = SimpleNamespace(
349
+ compute_candidates=lambda h: (candidates, log_probs),
350
+ model=SimpleNamespace(lilicorr=score, candidate_selector=score),
351
+ )
352
+ spec.target_embeddings = lambda ids: ids.float().unsqueeze(-1).expand(*ids.shape, 4)
353
+ if lilicorr:
354
+ spec.anchor_hidden.copy_(torch.arange(12).view(3, 4))
355
+ spec.anchor_valid[:2] = True
356
+ spec.candidate_sampler.sample = sample
357
+ spec._maybe_predict_acceptance = lambda *args: adaptive_inputs.extend(args)
358
+ spec._generate_draft(3, 9, None, None, None)
359
+
360
+ expected_ids = candidates.view(3, 2, 4)
361
+ expected_first = spec.target_embeddings(expected_ids) if lilicorr else expected_ids
362
+ torch.testing.assert_close(scoring_inputs[0], expected_first)
363
+ torch.testing.assert_close(scoring_inputs[1], log_probs.view(3, 2, 4))
364
+ torch.testing.assert_close(
365
+ scoring_inputs[2], hidden[spec.sample_indices].view(3, 2, 4)
366
+ )
367
+ if lilicorr:
368
+ torch.testing.assert_close(scoring_inputs[3], spec.anchor_hidden)
369
+ torch.testing.assert_close(scoring_inputs[4], spec.anchor_valid)
370
+ else:
371
+ torch.testing.assert_close(scoring_inputs[3], torch.tensor([0, 3, 6]))
372
+ torch.testing.assert_close(sampling_inputs[0], expected_ids)
373
+ assert sampling_inputs[1] is scores
374
+ assert sampling_inputs[4] is spec.sample_idx_mapping
375
+ torch.testing.assert_close(adaptive_inputs[0], scores[:, :, 0].flatten(0, 1))
376
+ torch.testing.assert_close(adaptive_inputs[1], spec.sample_idx_mapping)
377
+ torch.testing.assert_close(adaptive_inputs[2], spec.sample_col)
378
+
379
+
380
+def test_lilicorr_config_preserves_architecture():
381
+ from transformers import Qwen3Config
382
+
383
+ config = Qwen3Config(architectures=["LiLiCorrDraftModel"])
384
+ wrapped = EAGLEConfig(config, method="dflash")
385
+ assert wrapped.architectures == ["LiLiCorrDraftModel"]
386
+
387
+
388
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
389
+@pytest.mark.parametrize("probabilistic", [False, True])
390
+def test_candidate_walk_preserves_conditional_scores_and_padding(probabilistic):
391
+ """Check the shared sampler's actual q, stale support, and graph padding."""
392
+ device = torch.device("cuda")
393
+ batch, steps, top_k, vocab = 3, 3, 4, 32
394
+ sampler = CandidateSampler(batch, steps, top_k, device)
395
+ candidates = (
396
+ torch.arange(steps * top_k, device=device)
397
+ .view(1, steps, top_k)
398
+ .repeat(batch, 1, 1)
399
+ )
400
+ scores = torch.randn(batch, steps, top_k, top_k, device=device)
401
+ tokens = torch.full((batch, steps), -1, dtype=torch.long, device=device)
402
+ cache = (
403
+ torch.full((batch, steps, vocab), -float("inf"), device=device)
404
+ if probabilistic
405
+ else None
406
+ )
407
+ positions = torch.arange(batch * steps * 2, device=device)[::2]
408
+ mappings = torch.tensor(
409
+ [2] * steps + [0] * steps + [-1] * steps, device=device
410
+ ).repeat_interleave(2)[::2]
411
+ temperature = torch.tensor([0.0, 1.0, 0.8], device=device)
412
+ seeds = torch.tensor([11, 22, 33], device=device)
413
+
414
+ def run():
415
+ sampler.sample(
416
+ candidates,
417
+ scores,
418
+ batch,
419
+ positions,
420
+ mappings,
421
+ temperature,
422
+ seeds,
423
+ tokens,
424
+ cache,
425
+ False,
426
+ )
427
+
428
+ run()
429
+ graph = torch.cuda.CUDAGraph()
430
+ with torch.cuda.graph(graph):
431
+ run()
432
+ old_candidates = candidates.clone()
433
+ candidates.add_(12)
434
+ graph.replay()
435
+ for row, state in ((0, 2), (1, 0)):
436
+ previous = 0
437
+ for step in range(steps):
438
+ realized = scores[row, step, previous]
439
+ torch.testing.assert_close(sampler.scores[row, step], realized)
440
+ index = (candidates[row, step] == tokens[row, step]).nonzero().item()
441
+ if not probabilistic or temperature[state] == 0:
442
+ assert index == realized.argmax().item()
443
+ if cache is not None:
444
+ torch.testing.assert_close(
445
+ cache[state, step, candidates[row, step]], realized
446
+ )
447
+ assert cache[state, step, old_candidates[row, step]].isneginf().all()
448
+ previous = index
449
+ assert (tokens[2] == -1).all()
450
+ if cache is not None:
451
+ assert cache[1].isneginf().all()
452
+
453
+
454
+@pytest.mark.parametrize(
455
+ ("convolution", "mismatch", "quantized"),
456
+ [
457
+ pytest.param(False, None, False, id="valid-plain"),
458
+ pytest.param(True, None, False, id="valid-convolution"),
459
+ pytest.param(False, "missing_head", False, id="missing-head"),
460
+ pytest.param(False, "extra_head", False, id="extra-head"),
461
+ pytest.param(True, "missing_conv", False, id="missing-convolution"),
462
+ pytest.param(False, "extra_conv", False, id="unexpected-convolution"),
463
+ pytest.param(False, None, True, id="optional-w4a16-input-scale"),
464
+ pytest.param(False, "missing_weight", True, id="missing-quantized-weight"),
465
+ pytest.param(False, "missing_bias", True, id="missing-quantized-bias"),
466
+ pytest.param(False, "missing_weight_scale", True, id="missing-block-scale"),
467
+ pytest.param(False, "missing_weight_scale_2", True, id="missing-global-scale"),
468
+ ],
469
+)
470
+def test_checkpoint_coverage_rejects_incomplete_or_wrong_heads(
471
+ monkeypatch, convolution, mismatch, quantized
472
+):
473
+ from vllm.model_executor.models.lilicorr import LiLiCorr, LiLiCorrForCausalLM
474
+
475
+ wrapper = LiLiCorrForCausalLM.__new__(LiLiCorrForCausalLM)
476
+ nn.Module.__init__(wrapper)
477
+ wrapper.model = LiLiCorr.__new__(LiLiCorr)
478
+ nn.Module.__init__(wrapper.model)
479
+ wrapper.has_own_lm_head = False
480
+ with set_current_vllm_config(
481
+ VllmConfig(
482
+ device_config=DeviceConfig("cpu"),
483
+ compilation_config=CompilationConfig(mode=0),
484
+ )
485
+ ):
486
+ wrapper.model.lilicorr = LiLiCorrHead(
487
+ model_hidden_size=16, block_size=4, rms_norm_eps=1e-6, config=_config()
488
+ )
489
+ layer = nn.Module()
490
+ if convolution:
491
+ layer.attention_conv = nn.Linear(2, 2, bias=False)
492
+ wrapper.model.layers = nn.ModuleList([layer])
493
+ if quantized:
494
+ from vllm.config.quantization import QuantSpec
495
+ from vllm.model_executor.layers.quantization.modelopt import (
496
+ CkptCtx,
497
+ ModelOptLinearMethod,
498
+ )
499
+ from vllm.model_executor.layers.quantization.utils.quant_utils import (
500
+ kNvfp4Static,
501
+ )
502
+
503
+ # Match the W4A16 NVFP4 checkpoint contract without GPU kernel setup.
504
+ projection = wrapper.model.lilicorr.pass_hidden_proj
505
+ projection.quant_method = ModelOptLinearMethod(
506
+ QuantSpec(weight=kNvfp4Static, activation=None), CkptCtx(group_size=16)
507
+ )
508
+ projection.weight = nn.Parameter(
509
+ torch.zeros(8, 8, dtype=torch.uint8), requires_grad=False
510
+ )
511
+ for name, value in (
512
+ ("weight_scale", torch.ones(8, 1).to(torch.float8_e4m3fn)),
513
+ ("weight_scale_2", torch.ones(1)),
514
+ ("input_scale", torch.full((1,), torch.nan)),
515
+ ):
516
+ projection.register_parameter(
517
+ name, nn.Parameter(value, requires_grad=False)
518
+ )
519
+ weights = dict(wrapper.model.state_dict())
520
+ if quantized:
521
+ del weights["lilicorr.pass_hidden_proj.input_scale"]
522
+ if mismatch == "missing_head":
523
+ del weights["lilicorr.slot_embedding"]
524
+ elif mismatch in (
525
+ "missing_weight",
526
+ "missing_bias",
527
+ "missing_weight_scale",
528
+ "missing_weight_scale_2",
529
+ ):
530
+ del weights[f"lilicorr.pass_hidden_proj.{mismatch.removeprefix('missing_')}"]
531
+ elif mismatch == "extra_head":
532
+ weights["lilicorr.untrained.weight"] = torch.zeros(1)
533
+ elif mismatch == "missing_conv":
534
+ del weights["layers.0.attention_conv.weight"]
535
+ elif mismatch == "extra_conv":
536
+ weights["layers.0.mlp_conv.untrained"] = torch.zeros(1)
537
+
538
+ # Keep production loading and mapping, bypass only backbone GPU KV setup.
539
+ wrapper.model.use_aux_hidden_state = True
540
+ wrapper.model.has_separate_mask_embedding = False
541
+ monkeypatch.setattr(wrapper, "_read_mask_embedding", lambda: None)
542
+ monkeypatch.setattr(
543
+ wrapper.model, "_build_fused_kv_buffers", lambda: None, raising=False
544
+ )
545
+ supplied = [("model." + name, value) for name, value in weights.items()]
546
+ if mismatch:
547
+ message = (
548
+ "no module or parameter"
549
+ if mismatch.startswith("extra")
550
+ else "coverage mismatch"
551
+ )
552
+ with pytest.raises(ValueError, match=message):
553
+ wrapper.load_weights(supplied)
554
+ else:
555
+ wrapper.load_weights(supplied)
556
+ assert wrapper.model.lilicorr._attn_bias is not None
557
+
558
+
559
+@pytest.fixture
560
+def owned_head_model(monkeypatch):
561
+ from vllm.model_executor.layers.quantization import modelopt
562
+ from vllm.model_executor.models import qwen3_dflash
563
+ from vllm.model_executor.models.lilicorr import LiLiCorrForCausalLM
564
+
565
+ quant_config = modelopt.ModelOptNvFp4Config(
566
+ quant_method="W4A16_NVFP4", is_checkpoint_nvfp4_serialized=True
567
+ )
568
+ monkeypatch.setattr(
569
+ modelopt,
570
+ "select_linear_kernel",
571
+ lambda *a, **kw: SimpleNamespace(input_quant_key=lambda: None),
572
+ )
573
+ monkeypatch.setattr(qwen3_dflash, "get_draft_quant_config", lambda _: quant_config)
574
+
575
+ class Backbone(nn.Module):
576
+ def __init__(self, **kwargs):
577
+ super().__init__()
578
+ self.quant_config = quant_config
579
+ self.use_aux_hidden_state = True
580
+ self.has_separate_mask_embedding = False
581
+ self.lilicorr = LiLiCorrHead(
582
+ model_hidden_size=16, block_size=4, rms_norm_eps=1e-6, config=_config()
583
+ )
584
+
585
+ def _build_fused_kv_buffers(self):
586
+ pass
587
+
588
+ monkeypatch.setattr(LiLiCorrForCausalLM, "model_cls", Backbone)
589
+ monkeypatch.setattr(LiLiCorrForCausalLM, "_read_mask_embedding", lambda _: None)
590
+
591
+ def build(owned=True):
592
+ config = SimpleNamespace(
593
+ vocab_size=64,
594
+ draft_vocab_size=64,
595
+ hidden_size=16,
596
+ has_own_lm_head=owned,
597
+ num_hidden_layers=0,
598
+ dflash_config={},
599
+ )
600
+ vllm_config = SimpleNamespace(
601
+ speculative_config=SimpleNamespace(
602
+ draft_model_config=SimpleNamespace(hf_config=config),
603
+ draft_load_config=None,
604
+ attention_backend=None,
605
+ kv_cache_dtype=None,
606
+ ),
607
+ model_config=SimpleNamespace(
608
+ get_total_num_hidden_layers=lambda: 0,
609
+ get_vocab_size=lambda: 64,
610
+ ),
611
+ attention_config=SimpleNamespace(),
612
+ cache_config=SimpleNamespace(),
613
+ load_config=LoadConfig(),
614
+ )
615
+ with set_current_vllm_config(
616
+ VllmConfig(
617
+ device_config=DeviceConfig("cpu"),
618
+ compilation_config=CompilationConfig(mode=0),
619
+ )
620
+ ):
621
+ model = LiLiCorrForCausalLM(vllm_config=vllm_config)
622
+ return model, vllm_config
623
+
624
+ return build
625
+
626
+
627
+@pytest.mark.parametrize("missing", [None, "weight", "weight_scale", "weight_scale_2"])
628
+def test_owned_nvfp4_lm_head_loads_required_checkpoint_tensors(
629
+ owned_head_model, missing
630
+):
631
+ model, _ = owned_head_model()
632
+ assert model.lm_head.weight.dtype == torch.uint8
633
+ assert model.lm_head.weight.shape == (64, 8)
634
+ weights = {
635
+ name: torch.ones_like(value) for name, value in model.state_dict().items()
636
+ }
637
+ del weights["lm_head.input_scale"] # Deprecated and unused for W4A16.
638
+ if missing:
639
+ del weights[f"lm_head.{missing}"]
640
+ with pytest.raises(ValueError, match=f"lm_head.{missing}"):
641
+ model.load_weights(weights.items())
642
+ else:
643
+ model.load_weights(weights.items())
644
+ assert model.has_own_lm_head
645
+ for name in ("weight", "weight_scale", "weight_scale_2"):
646
+ torch.testing.assert_close(
647
+ getattr(model.lm_head, name), weights[f"lm_head.{name}"]
648
+ )
649
+
650
+
651
+@pytest.mark.parametrize("owned", [False, True])
652
+def test_selected_lm_head_survives_dflash_and_lilicorr_sharing(
653
+ monkeypatch, owned_head_model, owned
654
+):
655
+ from vllm.model_executor.layers.vocab_parallel_embedding import (
656
+ VocabParallelEmbedding,
657
+ )
658
+ from vllm.v1.worker.gpu.spec_decode.dflash import utils as dflash_utils
659
+ from vllm.v1.worker.gpu.spec_decode.eagle import utils as eagle_utils
660
+
661
+ model, config = owned_head_model(owned)
662
+ own_head = model.lm_head
663
+ target_head = owned_head_model(owned)[0].lm_head
664
+ # Identical packed bytes do not imply identical quantized heads.
665
+ with torch.no_grad():
666
+ own_head.weight.fill_(1)
667
+ target_head.weight.fill_(1)
668
+ if owned:
669
+ own_head.weight_scale_2.fill_(1)
670
+ target_head.weight_scale_2.fill_(2)
671
+ embedding = VocabParallelEmbedding(64, 16)
672
+ target = SimpleNamespace(
673
+ model=SimpleNamespace(embed_tokens=embedding), lm_head=target_head
674
+ )
675
+ monkeypatch.setattr(
676
+ eagle_utils, "get_pp_group", lambda: SimpleNamespace(world_size=1)
677
+ )
678
+ monkeypatch.setattr(
679
+ dflash_utils, "replace", lambda obj, **kw: SimpleNamespace(**(vars(obj) | kw))
680
+ )
681
+ monkeypatch.setattr(dflash_utils, "get_pp_safe_draft_load_config", lambda c: c)
682
+ monkeypatch.setattr(dflash_utils, "get_model", lambda **kw: model)
683
+ if owned:
684
+
685
+ def no_weight_comparison(*args):
686
+ raise AssertionError("Explicit ownership must bypass raw-weight comparison")
687
+
688
+ monkeypatch.setattr(dflash_utils, "_should_share", no_weight_comparison)
689
+ spec = LiLiCorrSpeculator.__new__(LiLiCorrSpeculator)
690
+ spec.vllm_config = config
691
+ assert spec.load_draft_model(target, set()) is model
692
+ assert model.lm_head is (own_head if owned else target_head)
693
+ assert spec.target_embeddings is embedding
694
+ if owned:
695
+ target.lm_head = None
696
+ assert spec.load_draft_model(target, set()).lm_head is own_head
697
+
698
+
699
+def test_quantized_head_calls_methods_without_reading_packed_weights(monkeypatch):
700
+ from vllm.model_executor.layers import linear
701
+ from vllm.model_executor.layers.linear import UnquantizedLinearMethod
702
+
703
+ calls = []
704
+ configured = []
705
+ quant_config = object()
706
+
707
+ class PackedMethod(UnquantizedLinearMethod):
708
+ def apply(self, layer, x, bias=None):
709
+ calls.append(layer.prefix)
710
+ # Packed storage has a different name/dtype from the activations.
711
+ assert x.dtype == torch.float32
712
+ return F.linear(x, layer.packed_weight.float() * layer.weight_scale, bias)
713
+
714
+ def resolve(config, layer, prefix):
715
+ assert config is quant_config
716
+ configured.append(prefix)
717
+ return PackedMethod()
718
+
719
+ monkeypatch.setattr(linear, "resolve_quant_method", resolve)
720
+ with set_current_vllm_config(
721
+ VllmConfig(
722
+ device_config=DeviceConfig("cpu"),
723
+ compilation_config=CompilationConfig(mode=0),
724
+ )
725
+ ):
726
+ head = LiLiCorrHead(
727
+ model_hidden_size=16,
728
+ block_size=4,
729
+ rms_norm_eps=1e-6,
730
+ config=_config(hidden_size=8),
731
+ quant_config=quant_config,
732
+ prefix="model.lilicorr",
733
+ )
734
+ with torch.no_grad():
735
+ for module in head.modules():
736
+ if isinstance(module, LinearBase):
737
+ if module.quant_config is None:
738
+ module.weight.normal_(std=0.1)
739
+ module.bias.zero_()
740
+ continue
741
+ module.register_parameter(
742
+ "packed_weight",
743
+ nn.Parameter(
744
+ torch.randint(-4, 5, module.weight.shape, dtype=torch.int8),
745
+ requires_grad=False,
746
+ ),
747
+ )
748
+ module.register_parameter(
749
+ "weight_scale",
750
+ nn.Parameter(
751
+ torch.tensor(0.05),
752
+ requires_grad=False,
753
+ ),
754
+ )
755
+ del module.weight
756
+ module.bias.zero_()
757
+ for layer in head.layers:
758
+ layer.attn.in_proj_weight.normal_(std=0.1)
759
+ layer.attn.in_proj_bias.zero_()
760
+ head.materialize_inference_buffers(torch.device("cpu"), torch.float32)
761
+ result = head(
762
+ torch.randn(2, 3, 4, 16),
763
+ torch.randn(2, 3, 4).log_softmax(-1),
764
+ torch.randn(2, 3, 16),
765
+ torch.randn(2, 16),
766
+ torch.tensor([True, False]),
767
+ )
768
+ assert result.shape == (2, 3, 4, 4)
769
+ assert torch.isfinite(result).all()
770
+ for name in ("factor_input_proj", "out_head", "in_head"):
771
+ assert getattr(head, name).quant_config is None
772
+ assert f"model.lilicorr.{name}" not in configured
773
+ assert set(calls) == set(configured)
774
+ assert len(configured) == len(set(configured))
775
+ for name, module in head.named_modules():
776
+ if isinstance(module, LinearBase):
777
+ assert module.prefix == f"model.lilicorr.{name}"
vllm/config/vllm.py
+10
-6
@@ -760,8 +760,8 @@ class VllmConfig:
760
761
return True
762
763
- def _is_dflash2_draft(self) -> bool:
764
- """Whether the DFlash draft is a DFlash2 one, by the architecture the
763
+ def _is_dflash_candidate_draft(self) -> bool:
764
+ """Whether the DFlash draft has a candidate head, by the architecture the
765
speculator selects on (v1/worker/gpu/spec_decode/__init__.py)."""
766
spec = self.speculative_config
767
if spec is None or spec.method != "dflash":
@@ -769,7 +769,11 @@ class VllmConfig:
769
draft_config = getattr(spec, "draft_model_config", None)
770
if draft_config is None:
771
return False
772
- return "DFlash2DraftModel" in (draft_config.architectures or [])
772
+ return bool(
773
+ {"DFlash2DraftModel", "LiLiCorrDraftModel"}.intersection(
774
+ draft_config.architectures or []
775
+ )
776
+ )
777
778
def _dflash_needs_multi_kv_group(self) -> bool:
779
"""Whether a DFlash draft mixes sliding-window and full attention."""
@@ -3097,11 +3101,11 @@ class VllmConfig:
3101
if self._dflash_needs_multi_kv_group():
3102
unsupported.append("mixed sliding/full dflash drafts")
3103
3100
- # The DFlash2 candidate selector exists only in the V2 speculator. On
3104
+ # DFlash candidate heads exist only in the V2 speculator. On
3105
# V1 the same checkpoint drafts through DFlashProposer, which never
3106
# calls it, so the draft would degrade to DFlash1 silently.
3103
- if self._is_dflash2_draft():
3104
- unsupported.append("dflash2 drafts")
3107
+ if self._is_dflash_candidate_draft():
3108
+ unsupported.append("DFlash candidate-head drafts")
3109
3110
if self.model_config is not None and self.model_config.is_diffusion:
3111
unsupported.append("diffusion models")
vllm/model_executor/layers/logits_processor.py
+21
-1
@@ -256,6 +256,8 @@ class LogitsProcessor(PluggableLayer):
256
hidden_states: torch.Tensor,
257
k: int,
258
embedding_bias: torch.Tensor | None = None,
259
+ *,
260
+ return_log_probs: bool = False,
261
) -> tuple[torch.Tensor, torch.Tensor]:
262
"""Vocab-parallel top-k without all-gathering full logits.
263
@@ -263,7 +265,9 @@ class LogitsProcessor(PluggableLayer):
265
the values as well as the global ids. Communication is
266
O(batch * 2k * tp_size) rather than O(batch * vocab_size).
267
266
- Scale and soft cap are applied to the k selected values rather than
268
+ With return_log_probs, values use the full-vocabulary log-partition.
269
+
270
+ Otherwise scale and soft cap are applied to the k selected values rather than
271
the whole vocabulary; both are monotonic, so the selection is the same
272
and only k entries are touched.
273
"""
@@ -274,12 +278,26 @@ class LogitsProcessor(PluggableLayer):
278
)
279
280
logits = self._apply_head(lm_head, hidden_states, embedding_bias)
281
+ if return_log_probs:
282
+ if self.soft_cap is not None:
283
+ logits = torch.tanh(logits / self.soft_cap) * self.soft_cap
284
+ if self.scale != 1.0:
285
+ logits = logits * self.scale
286
287
# Mask out padding entries beyond org_vocab_size on this shard.
288
num_pad = lm_head.shard_indices.num_org_vocab_padding
289
if num_pad > 0:
290
logits[..., -num_pad:] = -float("inf")
291
292
+ if return_log_probs:
293
+ log_partition = torch.logsumexp(logits.float(), dim=-1, keepdim=True)
294
+ if lm_head.tp_size > 1:
295
+ log_partition = torch.logsumexp(
296
+ tensor_model_parallel_all_gather(log_partition, dim=-1),
297
+ dim=-1,
298
+ keepdim=True,
299
+ )
300
+
301
values, ids = _topk(logits, k)
302
# Convert shard-local indices to global vocab indices.
303
ids = ids.to(torch.int64) + lm_head.shard_indices.org_vocab_start_index
@@ -291,6 +309,8 @@ class LogitsProcessor(PluggableLayer):
309
ids = ids.gather(-1, selected)
310
311
values = values.float()
312
+ if return_log_probs:
313
+ return ids, values - log_partition
314
if self.scale != 1.0:
315
values = values * self.scale
316
if self.soft_cap is not None:
vllm/model_executor/models/lilicorr.py
new
+546
@@ -0,0 +1,546 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+# LiLiCorr head adapted from sgl-project/sglang PR #37462 (Apache-2.0).
4
+"""DFlash backbone with the LiLiCorr candidate-lattice correlator."""
5
+
6
+from collections.abc import Iterable
7
+from typing import Any
8
+
9
+import torch
10
+import torch.nn.functional as F
11
+from torch import nn
12
+
13
+from vllm.compilation.backends import set_model_tag
14
+from vllm.compilation.decorators import support_torch_compile
15
+from vllm.config import VllmConfig
16
+from vllm.model_executor.layers.linear import (
17
+ LinearBase,
18
+ ReplicatedLinear,
19
+ UnquantizedLinearMethod,
20
+)
21
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
22
+from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
23
+from vllm.model_executor.layers.quantization.modelopt import ModelOptLinearMethod
24
+from vllm.model_executor.layers.quantization.utils.quant_utils import kNvfp4Static
25
+from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
26
+
27
+from .nemotron_h import NemotronHMLP
28
+from .qwen3_dflash import DFlashQwen3ForCausalLM, DFlashQwen3Model
29
+from .qwen3_dflash2 import DFlash2Qwen3DecoderLayer
30
+from .utils import AutoWeightsLoader, maybe_prefix
31
+
32
+
33
+class LiLiCorrLatticeAttention(nn.Module):
34
+ def __init__(
35
+ self,
36
+ hidden_size: int,
37
+ num_heads: int,
38
+ quant_config: QuantizationConfig | None = None,
39
+ prefix: str = "",
40
+ ) -> None:
41
+ super().__init__()
42
+ if num_heads <= 0 or hidden_size % num_heads != 0:
43
+ raise ValueError(
44
+ f"LiLiCorr hidden_size={hidden_size} must be divisible "
45
+ f"by num_heads={num_heads}."
46
+ )
47
+ self.hidden_size = hidden_size
48
+ self.num_heads = num_heads
49
+ self.head_dim = self.hidden_size // self.num_heads
50
+ # Exported lattice QKV parameters stay in the model's floating dtype.
51
+ self.in_proj_weight = nn.Parameter(
52
+ torch.empty(3 * hidden_size, hidden_size), requires_grad=False
53
+ )
54
+ self.in_proj_bias = nn.Parameter(
55
+ torch.empty(3 * hidden_size), requires_grad=False
56
+ )
57
+ self.out_proj = ReplicatedLinear(
58
+ hidden_size,
59
+ hidden_size,
60
+ return_bias=False,
61
+ quant_config=quant_config,
62
+ prefix=maybe_prefix(prefix, "out_proj"),
63
+ )
64
+
65
+ def forward(
66
+ self, hidden_states: torch.Tensor, attention_bias: torch.Tensor
67
+ ) -> torch.Tensor:
68
+ bsz, seq_len, _ = hidden_states.shape
69
+ qkv = F.linear(hidden_states, self.in_proj_weight, self.in_proj_bias)
70
+ q, k, v = qkv.chunk(3, dim=-1)
71
+ shape = (bsz, seq_len, self.num_heads, self.head_dim)
72
+ q = q.view(shape).transpose(1, 2)
73
+ k = k.view(shape).transpose(1, 2)
74
+ v = v.view(shape).transpose(1, 2)
75
+ # Attention and MMEncoderAttention cannot accept this learned per-head
76
+ # additive bias. The full candidate lattice has no KV-cache state.
77
+ out = F.scaled_dot_product_attention(
78
+ q,
79
+ k,
80
+ v,
81
+ attn_mask=attention_bias,
82
+ )
83
+ return self.out_proj(
84
+ out.transpose(1, 2).reshape(bsz, seq_len, self.hidden_size)
85
+ )
86
+
87
+
88
+class LiLiCorrLayer(nn.Module):
89
+ def __init__(
90
+ self,
91
+ hidden_size: int,
92
+ num_heads: int,
93
+ mlp_ratio: float,
94
+ rms_norm_eps: float,
95
+ quant_config: QuantizationConfig | None = None,
96
+ prefix: str = "",
97
+ ) -> None:
98
+ super().__init__()
99
+ self.attn_norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
100
+ self.attn = LiLiCorrLatticeAttention(
101
+ hidden_size, num_heads, quant_config, maybe_prefix(prefix, "attn")
102
+ )
103
+ self.mlp_norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
104
+ mlp_hidden_size = int(hidden_size * mlp_ratio)
105
+ self.mlp = NemotronHMLP(
106
+ config=None,
107
+ hidden_size=hidden_size,
108
+ intermediate_size=mlp_hidden_size,
109
+ hidden_act="silu",
110
+ bias=True,
111
+ quant_config=quant_config,
112
+ prefix=maybe_prefix(prefix, "mlp"),
113
+ )
114
+
115
+ def forward(
116
+ self, hidden_states: torch.Tensor, attention_bias: torch.Tensor
117
+ ) -> torch.Tensor:
118
+ hidden_states = hidden_states + self.attn(
119
+ self.attn_norm(hidden_states), attention_bias
120
+ )
121
+ return hidden_states + self.mlp(self.mlp_norm(hidden_states))
122
+
123
+
124
+@support_torch_compile
125
+class LiLiCorrHead(nn.Module):
126
+ num_candidate_features = 5
127
+
128
+ def __init__(
129
+ self,
130
+ *,
131
+ model_hidden_size: int,
132
+ block_size: int,
133
+ rms_norm_eps: float,
134
+ config: dict[str, Any],
135
+ quant_config: QuantizationConfig | None = None,
136
+ prefix: str = "",
137
+ ) -> None:
138
+ super().__init__()
139
+ hidden_size = config["lilicorr_hidden_size"] or model_hidden_size
140
+ self.block_size = block_size
141
+ self.num_candidate_slots = block_size - 1
142
+ self.candidate_topk = config["lilicorr_candidate_topk"]
143
+ if (
144
+ self.candidate_topk <= 0
145
+ or self.candidate_topk > 16
146
+ or self.candidate_topk & (self.candidate_topk - 1)
147
+ ):
148
+ raise ValueError("LiLiCorr candidate_topk must be a power of two <= 16.")
149
+ self.hidden_size = hidden_size
150
+ self.num_heads = config["lilicorr_num_heads"]
151
+ self.mlp_ratio = config["lilicorr_mlp_ratio"]
152
+ self.factor_dim = config["lilicorr_factor_dim"]
153
+ self.vector_eps = config["lilicorr_vector_eps"]
154
+ self.logit_scale = config["lilicorr_logit_scale"]
155
+ # Candidate IDs and embeddings are replicated across TP ranks, so each
156
+ # rank scores the same lattice; the MLPs use standard tensor parallelism.
157
+ self.token_proj = (
158
+ nn.Identity()
159
+ if model_hidden_size == hidden_size
160
+ else ReplicatedLinear(
161
+ model_hidden_size,
162
+ hidden_size,
163
+ return_bias=False,
164
+ quant_config=quant_config,
165
+ prefix=maybe_prefix(prefix, "token_proj"),
166
+ )
167
+ )
168
+ self.pass_hidden_proj = ReplicatedLinear(
169
+ model_hidden_size,
170
+ hidden_size,
171
+ return_bias=False,
172
+ quant_config=quant_config,
173
+ prefix=maybe_prefix(prefix, "pass_hidden_proj"),
174
+ )
175
+ self.feature_norm = nn.LayerNorm(self.num_candidate_features)
176
+ self.feature_mlp = NemotronHMLP(
177
+ config=None,
178
+ hidden_size=self.num_candidate_features,
179
+ intermediate_size=hidden_size,
180
+ hidden_act="silu",
181
+ output_size=hidden_size,
182
+ bias=True,
183
+ quant_config=quant_config,
184
+ prefix=maybe_prefix(prefix, "feature_mlp"),
185
+ )
186
+ self.slot_embedding = nn.Parameter(
187
+ torch.zeros(1, 1, self.num_candidate_slots, 1, hidden_size)
188
+ )
189
+ self.rank_embedding = nn.Parameter(
190
+ torch.zeros(1, 1, 1, self.candidate_topk, hidden_size)
191
+ )
192
+ self.relative_slot_bias = nn.Parameter(
193
+ torch.zeros(self.num_heads, 2 * self.block_size - 1)
194
+ )
195
+ self.same_slot_bias = nn.Parameter(torch.zeros(self.num_heads))
196
+ self.context_proj = ReplicatedLinear(
197
+ model_hidden_size,
198
+ hidden_size,
199
+ return_bias=False,
200
+ quant_config=quant_config,
201
+ prefix=maybe_prefix(prefix, "context_proj"),
202
+ )
203
+ self.layers = nn.ModuleList(
204
+ [
205
+ LiLiCorrLayer(
206
+ hidden_size=hidden_size,
207
+ num_heads=self.num_heads,
208
+ mlp_ratio=self.mlp_ratio,
209
+ rms_norm_eps=rms_norm_eps,
210
+ quant_config=quant_config,
211
+ prefix=maybe_prefix(prefix, f"layers.{i}"),
212
+ )
213
+ for i in range(config["lilicorr_num_layers"])
214
+ ]
215
+ )
216
+ self.output_norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
217
+ self.anchor_norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
218
+ self.factor_input_proj = ReplicatedLinear(
219
+ hidden_size * 3,
220
+ hidden_size,
221
+ return_bias=False,
222
+ quant_config=None,
223
+ prefix=maybe_prefix(prefix, "factor_input_proj"),
224
+ )
225
+ # Each candidate gets outgoing and incoming vectors. After normalization,
226
+ # their dot product scores a transition from slot s to slot s+1:
227
+ # pair_score[s, i, j] = dot(out[s, i], incoming[s+1, j])
228
+ # The anchor supplies the outgoing vector for the first slot.
229
+ self.out_head = ReplicatedLinear(
230
+ hidden_size,
231
+ self.factor_dim,
232
+ return_bias=False,
233
+ quant_config=None,
234
+ prefix=maybe_prefix(prefix, "out_head"),
235
+ )
236
+ self.in_head = ReplicatedLinear(
237
+ hidden_size,
238
+ self.factor_dim,
239
+ return_bias=False,
240
+ quant_config=None,
241
+ prefix=maybe_prefix(prefix, "in_head"),
242
+ )
243
+ self.anchor_out_head = ReplicatedLinear(
244
+ hidden_size,
245
+ self.factor_dim,
246
+ return_bias=False,
247
+ quant_config=quant_config,
248
+ prefix=maybe_prefix(prefix, "anchor_out_head"),
249
+ )
250
+ self._fused_edge_weight: torch.Tensor | None = None
251
+ self._fused_edge_bias: torch.Tensor | None = None
252
+ self._factor_input_splits: tuple[torch.Tensor, ...]
253
+ self._attn_bias: torch.Tensor | None = None
254
+ self._rank_frac_col: torch.Tensor
255
+ self._is_top1_col: torch.Tensor
256
+
257
+ @torch.no_grad()
258
+ def materialize_inference_buffers(
259
+ self, device: torch.device, dtype: torch.dtype
260
+ ) -> None:
261
+ """Build inference-only views/copies after checkpoint weights are loaded."""
262
+ topk = self.candidate_topk
263
+ self._attn_bias = self._build_attention_bias(device=device, dtype=dtype)
264
+ # Both projections consume the same candidate features f:
265
+ # out = W_out @ f + b_out
266
+ # incoming = W_in @ f + b_in
267
+ # Stack weights and biases to compute both in one linear call. score()
268
+ # splits the result and normalizes the two vectors separately.
269
+ self._fused_edge_weight = (
270
+ torch.cat([self.out_head.weight, self.in_head.weight], dim=0)
271
+ .to(device=device, dtype=dtype)
272
+ .contiguous()
273
+ )
274
+ self._fused_edge_bias = (
275
+ torch.cat([self.out_head.bias, self.in_head.bias], dim=0)
276
+ .to(device=device, dtype=dtype)
277
+ .contiguous()
278
+ )
279
+ # For candidate state x and request anchor a, the trained projection is:
280
+ # W @ concat(x, a, x*a) + b
281
+ # Split W into input blocks to evaluate the same expression as:
282
+ # W_x @ x + W_a @ a + W_cross @ (x*a) + b
283
+ # Here x*a is elementwise. This avoids concatenating inputs and reuses
284
+ # W_a @ a across candidates, adding the original bias only once.
285
+ weight = self.factor_input_proj.weight
286
+ hdim = self.hidden_size
287
+ self._factor_input_splits = (
288
+ weight[:, :hdim].contiguous(),
289
+ weight[:, hdim : 2 * hdim].contiguous(),
290
+ weight[:, 2 * hdim :].contiguous(),
291
+ )
292
+ if topk > 1:
293
+ rank_frac = torch.arange(topk, device=device, dtype=torch.float32).view(
294
+ 1, 1, topk
295
+ ) / float(topk - 1)
296
+ else:
297
+ rank_frac = torch.zeros(1, 1, topk, device=device, dtype=torch.float32)
298
+ is_top1 = torch.zeros(1, 1, topk, device=device, dtype=torch.float32)
299
+ is_top1[..., 0] = 1.0
300
+ self._rank_frac_col = rank_frac.contiguous()
301
+ self._is_top1_col = is_top1.contiguous()
302
+
303
+ def _build_attention_bias(
304
+ self, *, device: torch.device, dtype: torch.dtype
305
+ ) -> torch.Tensor:
306
+ topk = self.candidate_topk
307
+ slot_ids = torch.arange(
308
+ self.num_candidate_slots, device=device, dtype=torch.long
309
+ ).repeat_interleave(topk)
310
+ # Attention flattens [slot, candidate] into one sequence. With topk=2,
311
+ # the candidate slot IDs are [0, 0, 1, 1, ...]. Each head learns:
312
+ # bias(q, k) = relative_bias[slot(q) - slot(k) + offset]
313
+ # + same_slot_bias * (slot(q) == slot(k))
314
+ # The same-slot term includes distinct candidates at the same position.
315
+ # This is an additive preference, not a causal mask. Keep the trained
316
+ # offset = block_size-1 when score() selects a shorter draft's prefix,
317
+ # so each distance continues to use the same learned weight.
318
+ rel = slot_ids.view(-1, 1) - slot_ids.view(1, -1)
319
+ bias = self.relative_slot_bias[:, rel + self.block_size - 1]
320
+ same_slot = slot_ids.view(-1, 1) == slot_ids.view(1, -1)
321
+ bias = bias + same_slot.unsqueeze(0).to(
322
+ dtype=bias.dtype
323
+ ) * self.same_slot_bias.view(-1, 1, 1)
324
+ return bias.to(device=device, dtype=dtype).contiguous()
325
+
326
+ def score(
327
+ self,
328
+ *,
329
+ token_embeddings: torch.Tensor,
330
+ candidate_log_probs: torch.Tensor,
331
+ pass_hidden: torch.Tensor,
332
+ anchor_hidden: torch.Tensor,
333
+ anchor_valid: torch.Tensor,
334
+ ) -> tuple[torch.Tensor, torch.Tensor]:
335
+ if self._attn_bias is None:
336
+ raise RuntimeError(
337
+ "Call materialize_inference_buffers() after loading the LiLiCorr head."
338
+ )
339
+ bsz, n_slots, topk = candidate_log_probs.shape
340
+ if not 1 <= n_slots <= self.num_candidate_slots:
341
+ raise ValueError(
342
+ f"LiLiCorr supports 1..{self.num_candidate_slots} candidate slots, "
343
+ f"got {n_slots}."
344
+ )
345
+ if topk != self.candidate_topk:
346
+ raise ValueError(
347
+ f"LiLiCorr expects candidate_topk={self.candidate_topk}, got {topk}."
348
+ )
349
+ proj_dtype = self.slot_embedding.dtype
350
+ if token_embeddings.dtype != proj_dtype:
351
+ token_embeddings = token_embeddings.to(proj_dtype)
352
+ if pass_hidden.dtype != proj_dtype:
353
+ pass_hidden = pass_hidden.to(proj_dtype)
354
+ token_states = self.token_proj(token_embeddings)
355
+ pass_states = self.pass_hidden_proj(pass_hidden).unsqueeze(-2)
356
+ log_probs = candidate_log_probs.float()
357
+ features = torch.stack(
358
+ [
359
+ log_probs,
360
+ log_probs.exp(),
361
+ log_probs - log_probs.max(dim=-1, keepdim=True).values,
362
+ self._rank_frac_col.expand_as(log_probs),
363
+ self._is_top1_col.expand_as(log_probs),
364
+ ],
365
+ dim=-1,
366
+ )
367
+ hidden_states = token_states + pass_states
368
+ hidden_states = hidden_states + self.feature_mlp(
369
+ self.feature_norm(features.to(dtype=token_states.dtype))
370
+ )
371
+ hidden_states = hidden_states + self.slot_embedding[:, 0, :n_slots]
372
+ hidden_states = hidden_states + self.rank_embedding[:, 0]
373
+ hidden_states = hidden_states.reshape(bsz, n_slots * topk, self.hidden_size)
374
+ anchor_state = self.context_proj(anchor_hidden)
375
+ anchor_state = anchor_state * anchor_valid[:, None].to(anchor_state.dtype)
376
+ # Shorter drafts use the learned prefix; keep checkpoint parameter shapes.
377
+ lattice = n_slots * topk
378
+ attention_bias = self._attn_bias[None, :, :lattice, :lattice]
379
+ for layer in self.layers:
380
+ hidden_states = layer(hidden_states, attention_bias)
381
+ hidden_states = self.output_norm(hidden_states).reshape(
382
+ bsz, n_slots, topk, self.hidden_size
383
+ )
384
+ anchor_state = self.anchor_norm(anchor_state)
385
+ w_self, w_anchor, w_cross = self._factor_input_splits
386
+ anchor_row = anchor_state[:, None, None, :]
387
+ pre = F.linear(hidden_states, w_self, self.factor_input_proj.bias)
388
+ pre = pre + F.linear(anchor_row, w_anchor)
389
+ pre = pre + F.linear(hidden_states * anchor_row, w_cross)
390
+ factor_hidden = F.silu(pre)
391
+ edges = F.linear(factor_hidden, self._fused_edge_weight, self._fused_edge_bias)
392
+ out_vec, in_vec = F.normalize(
393
+ edges.unflatten(-1, (2, self.factor_dim)), dim=-1, eps=self.vector_eps
394
+ ).unbind(-2)
395
+ anchor_out = F.normalize(
396
+ self.anchor_out_head(anchor_state), dim=-1, eps=self.vector_eps
397
+ )
398
+ # start_scores[b, j] scores anchor -> candidate j in slot zero.
399
+ # pair_scores[b, s, i, j] scores candidate i in slot s -> candidate j
400
+ # in slot s+1. Normalized factor dot products encode learned compatibility;
401
+ # forward() scales them into logits for the candidate-path selector.
402
+ start_scores = (anchor_out[:, None, :] * in_vec[:, 0, :, :]).sum(dim=-1)
403
+ pair_scores = torch.matmul(out_vec[:, :-1], in_vec[:, 1:].transpose(-1, -2))
404
+ return (start_scores, pair_scores)
405
+
406
+ def forward(
407
+ self,
408
+ token_embeddings: torch.Tensor,
409
+ candidate_log_probs: torch.Tensor,
410
+ pass_hidden: torch.Tensor,
411
+ anchor_hidden: torch.Tensor,
412
+ anchor_valid: torch.Tensor,
413
+ ) -> torch.Tensor:
414
+ start, pairs = self.score(
415
+ token_embeddings=token_embeddings,
416
+ candidate_log_probs=candidate_log_probs,
417
+ pass_hidden=pass_hidden,
418
+ anchor_hidden=anchor_hidden,
419
+ anchor_valid=anchor_valid,
420
+ )
421
+ start = self.logit_scale * start.float()
422
+ pairs = self.logit_scale * pairs.float()
423
+ # The shared walk starts at predecessor index zero; all first rows agree.
424
+ first = start[:, None, None, :].expand(-1, 1, self.candidate_topk, -1)
425
+ return torch.cat((first, pairs), dim=1)
426
+
427
+
428
+class LiLiCorr(DFlashQwen3Model):
429
+ def __init__(
430
+ self, *, vllm_config: VllmConfig, start_layer_id: int = 0, prefix: str = ""
431
+ ):
432
+ spec = vllm_config.speculative_config
433
+ assert spec is not None
434
+ config = spec.draft_model_config.hf_config
435
+ draft_config = config.dflash_config
436
+ if not draft_config.get("lilicorr_enabled", True):
437
+ raise ValueError("LiLiCorr requires lilicorr_enabled=True.")
438
+ block_size = draft_config.get("block_size", getattr(config, "block_size", 0))
439
+ if not 1 <= spec.num_speculative_tokens < block_size:
440
+ raise ValueError(
441
+ "LiLiCorr requires 1 <= num_speculative_tokens < trained block_size."
442
+ )
443
+ taps = draft_config.get("conv_kernel_size", 0)
444
+ groups = draft_config.get("conv_group_size", 0)
445
+ if taps < 0 or groups < 0 or bool(taps) != bool(groups):
446
+ raise ValueError(
447
+ "LiLiCorr convolution geometry must enable both taps and group size."
448
+ )
449
+ if taps:
450
+ self.decoder_layer_cls = DFlash2Qwen3DecoderLayer
451
+ super().__init__(
452
+ vllm_config=vllm_config, start_layer_id=start_layer_id, prefix=prefix
453
+ )
454
+ with set_model_tag("lilicorr_head"):
455
+ self.lilicorr = LiLiCorrHead(
456
+ model_hidden_size=config.hidden_size,
457
+ block_size=block_size,
458
+ rms_norm_eps=config.rms_norm_eps,
459
+ config=draft_config,
460
+ quant_config=self.quant_config,
461
+ prefix=maybe_prefix(prefix, "lilicorr"),
462
+ )
463
+
464
+
465
+class LiLiCorrForCausalLM(DFlashQwen3ForCausalLM):
466
+ model_cls = LiLiCorr
467
+
468
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
469
+ spec = vllm_config.speculative_config
470
+ assert spec is not None
471
+ super().__init__(vllm_config=vllm_config, prefix=prefix)
472
+ if self.draft_id_to_target_id is not None:
473
+ raise ValueError("LiLiCorr candidates require the full target vocabulary.")
474
+ self.has_own_lm_head = bool(getattr(self.config, "has_own_lm_head", False))
475
+ # The correlator consumes raw candidate-head log-softmax features.
476
+ self.candidate_logits_processor = LogitsProcessor(self.config.vocab_size)
477
+
478
+ def compute_candidates(
479
+ self, hidden_states: torch.Tensor
480
+ ) -> tuple[torch.Tensor, torch.Tensor]:
481
+ return self.candidate_logits_processor.get_top_k_tokens(
482
+ self.lm_head,
483
+ hidden_states,
484
+ self.model.lilicorr.candidate_topk,
485
+ return_log_probs=True,
486
+ )
487
+
488
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
489
+ quantized_metadata: set[str] = set()
490
+ for name, module in self.named_modules():
491
+ name = name.removeprefix("model.")
492
+ if not isinstance(module, (LinearBase, ParallelLMHead)) or isinstance(
493
+ module.quant_method, UnquantizedLinearMethod
494
+ ):
495
+ continue
496
+ required = {"weight", "bias"}
497
+ method = module.quant_method
498
+ if (
499
+ isinstance(method, ModelOptLinearMethod)
500
+ and method.spec.weight == kNvfp4Static
501
+ ):
502
+ # NVFP4 scales are trained checkpoint data. Only W4A16's
503
+ # deprecated, unused input_scale may be absent.
504
+ required.update(("weight_scale", "weight_scale_2"))
505
+ if method.spec.activation is not None:
506
+ required.add("input_scale")
507
+ quantized_metadata.update(
508
+ f"{name}.{parameter}"
509
+ for parameter, _ in module.named_parameters(recurse=False)
510
+ if parameter not in required
511
+ )
512
+ expected = {
513
+ name.removeprefix("model.")
514
+ for name, _ in self.named_parameters()
515
+ if (
516
+ name.startswith("model.lilicorr.")
517
+ or ".attention_conv." in name
518
+ or ".mlp_conv." in name
519
+ or (self.has_own_lm_head and name.startswith("lm_head."))
520
+ )
521
+ and name.removeprefix("model.") not in quantized_metadata
522
+ }
523
+ seen: set[str] = set()
524
+ head_weights: list[tuple[str, torch.Tensor]] = []
525
+
526
+ def normalized_weights():
527
+ for name, value in weights:
528
+ name = name.removeprefix("model.")
529
+ seen.add(name)
530
+ # The backbone stacks up_proj into gate_up_proj. Load the
531
+ # nongated correlator separately to avoid that transformation.
532
+ if name.startswith("lilicorr."):
533
+ head_weights.append((name.removeprefix("lilicorr."), value))
534
+ else:
535
+ yield name, value
536
+
537
+ super().load_weights(normalized_weights())
538
+ AutoWeightsLoader(self.model.lilicorr).load_weights(head_weights)
539
+ missing = expected - seen
540
+ if missing:
541
+ raise ValueError(
542
+ f"LiLiCorr checkpoint coverage mismatch: missing={sorted(missing)}"
543
+ )
544
+ head = self.model.lilicorr
545
+ parameter = head.slot_embedding
546
+ head.materialize_inference_buffers(parameter.device, parameter.dtype)
vllm/model_executor/models/nano_nemotron_vl.py
+2
@@ -34,6 +34,7 @@ from vllm.model_executor.models.interfaces import (
34
HasInnerState,
35
IsHybrid,
36
MultiModalEmbeddings,
37
+ SupportsEagle3,
38
SupportsLoRA,
39
SupportsMultiModal,
40
SupportsMultiModalPruning,
@@ -928,6 +929,7 @@ class NemotronH_Nano_VL_V2(
929
nn.Module,
930
HasInnerState,
931
IsHybrid,
932
+ SupportsEagle3,
933
SupportsMultiModal,
934
SupportsMultiModalPruning,
935
SupportsLoRA,
vllm/model_executor/models/nemotron_h.py
+7
-4
@@ -34,7 +34,7 @@ from vllm.config.parallel import ParallelConfig
34
from vllm.distributed import get_ep_group, get_tensor_model_parallel_world_size
35
from vllm.distributed.communication_op import tensor_model_parallel_all_gather
36
from vllm.distributed.parallel_state import get_pp_group
37
-from vllm.model_executor.layers.activation import ReLUSquaredActivation
37
+from vllm.model_executor.layers.activation import get_act_fn
38
from vllm.model_executor.layers.attention import Attention
39
from vllm.model_executor.layers.fused_moe import (
40
FusedMoEFactory,
@@ -93,7 +93,7 @@ from vllm.transformers_utils.configs.nemotron_h import NemotronHConfig
93
class NemotronHMLP(nn.Module):
94
def __init__(
95
self,
96
- config: NemotronHConfig,
96
+ config: NemotronHConfig | None,
97
hidden_size: int,
98
intermediate_size: int,
99
quant_config: QuantizationConfig | None = None,
@@ -101,6 +101,9 @@ class NemotronHMLP(nn.Module):
101
reduce_results: bool = True,
102
is_sequence_parallel: bool = False,
103
prefix: str = "",
104
+ *,
105
+ hidden_act: str = "relu2",
106
+ output_size: int | None = None,
107
) -> None:
108
super().__init__()
109
@@ -114,14 +117,14 @@ class NemotronHMLP(nn.Module):
117
)
118
self.down_proj = RowParallelLinear(
119
input_size=intermediate_size,
117
- output_size=hidden_size,
120
+ output_size=hidden_size if output_size is None else output_size,
121
bias=bias,
122
quant_config=quant_config,
123
reduce_results=reduce_results,
124
disable_tp=is_sequence_parallel,
125
prefix=f"{prefix}.down_proj",
126
)
124
- self.act_fn = ReLUSquaredActivation()
127
+ self.act_fn = get_act_fn(hidden_act)
128
129
def forward(self, x: torch.Tensor):
130
x, _ = self.up_proj(x)
vllm/model_executor/models/qwen3_dflash.py
+70
-15
@@ -4,6 +4,7 @@
4
import io
5
from collections.abc import Iterable
6
7
+import regex as re
8
import torch
9
import torch.nn.functional as F
10
from torch import nn
@@ -23,6 +24,7 @@ from vllm.model_executor.layers.linear import (
24
QKVParallelLinear,
25
ReplicatedLinear,
26
RowParallelLinear,
27
+ UnquantizedLinearMethod,
28
)
29
from vllm.model_executor.layers.logits_processor import LogitsProcessor
30
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
@@ -54,6 +56,31 @@ logger = init_logger(__name__)
56
57
58
_SLIDING_ATTENTION = "sliding_attention"
59
+_DRAFT_LAYER_PATTERN = re.compile(r"(?<![A-Za-z0-9_.])layers\.(\d+)")
60
+
61
+
62
+def _add_global_draft_layer_exclusions(
63
+ quant_config: QuantizationConfig | None,
64
+ start_layer_id: int,
65
+ num_hidden_layers: int,
66
+) -> None:
67
+ """Add runtime layer aliases for checkpoint-local quant exclusions."""
68
+ if quant_config is None or start_layer_id == 0:
69
+ return
70
+ exclusions = getattr(quant_config, "exclude_modules", None)
71
+ if not isinstance(exclusions, list):
72
+ return
73
+
74
+ def offset_local_layer(match: re.Match[str]) -> str:
75
+ layer_idx = int(match.group(1))
76
+ if layer_idx >= num_hidden_layers:
77
+ return match.group(0)
78
+ return f"layers.{layer_idx + start_layer_id}"
79
+
80
+ for exclusion in tuple(exclusions):
81
+ global_exclusion = _DRAFT_LAYER_PATTERN.sub(offset_local_layer, exclusion)
82
+ if global_exclusion != exclusion and global_exclusion not in exclusions:
83
+ exclusions.append(global_exclusion)
84
85
86
def _dflash_layer_causal(config: Qwen3Config, layer_idx: int) -> bool:
@@ -394,6 +421,9 @@ class DFlashQwen3Model(nn.Module):
421
self.config = vllm_config.speculative_config.draft_model_config.hf_config
422
self.vocab_size = self.config.vocab_size
423
self.quant_config = get_draft_quant_config(vllm_config)
424
+ _add_global_draft_layer_exclusions(
425
+ self.quant_config, start_layer_id, self.config.num_hidden_layers
426
+ )
427
428
drafter_config = getattr(self.config, "eagle_config", {})
429
drafter_config.update(getattr(self.config, "dflash_config", {}))
@@ -473,14 +503,25 @@ class DFlashQwen3Model(nn.Module):
503
has_bias: bool,
504
) -> None:
505
self._hidden_norm_weight = self.hidden_norm.weight.data
506
+ self._context_qkv_projs = [a.qkv_proj for a in layers_attn]
507
+ self._context_q_sizes = [a.q_size for a in layers_attn]
508
477
- # KV projection weights: [num_layers * 2 * kv_size, hidden_size]
478
- kv_weights = [a.qkv_proj.weight[a.q_size :] for a in layers_attn]
479
- self._fused_kv_weight = torch.cat(kv_weights, dim=0)
480
- if has_bias:
481
- kv_biases = [a.qkv_proj.bias[a.q_size :] for a in layers_attn]
482
- self._fused_kv_bias: torch.Tensor | None = torch.cat(kv_biases, dim=0)
509
+ if all(
510
+ isinstance(proj.quant_method, UnquantizedLinearMethod)
511
+ for proj in self._context_qkv_projs
512
+ ):
513
+ # KV projection weights: [num_layers * 2 * kv_size, hidden_size]
514
+ kv_weights = [a.qkv_proj.weight[a.q_size :] for a in layers_attn]
515
+ self._fused_kv_weight: torch.Tensor | None = torch.cat(kv_weights, dim=0)
516
+ if has_bias:
517
+ kv_biases = [a.qkv_proj.bias[a.q_size :] for a in layers_attn]
518
+ self._fused_kv_bias: torch.Tensor | None = torch.cat(kv_biases, dim=0)
519
+ else:
520
+ self._fused_kv_bias = None
521
else:
522
+ # Quantized linear weights may use packed storage that cannot be
523
+ # consumed by F.linear. Run each projection through its quant method.
524
+ self._fused_kv_weight = None
525
self._fused_kv_bias = None
526
527
# K-norm weights stacked into one contiguous [num_layers, head_dim]
@@ -548,17 +589,26 @@ class DFlashQwen3Model(nn.Module):
589
self._hidden_norm_weight,
590
self._rms_norm_eps,
591
)
551
- all_kv_flat = F.linear(
552
- normed_context_states, self._fused_kv_weight, self._fused_kv_bias
553
- )
592
+ if self._fused_kv_weight is not None:
593
+ all_kv_flat = F.linear(
594
+ normed_context_states, self._fused_kv_weight, self._fused_kv_bias
595
+ )
596
+ all_kv = all_kv_flat.view(num_ctx, num_layers, 2, num_kv_heads, head_dim)
597
+ else:
598
+ layer_kv = []
599
+ for proj, q_size in zip(
600
+ self._context_qkv_projs, self._context_q_sizes, strict=True
601
+ ):
602
+ qkv, _ = proj(normed_context_states)
603
+ layer_kv.append(
604
+ qkv[:, q_size:].view(num_ctx, 2, num_kv_heads, head_dim)
605
+ )
606
+ all_kv = torch.stack(layer_kv, dim=1)
607
+
608
# Single contiguous copy that separates K/V and transposes to
555
- # layer-major layout. Result: [2, L, num_ctx, nkv, hd] contiguous.
609
+ # layer-major layout. Result: [2, L, num_ctx, nkv, hd] contiguous.
610
# Indexing dim-0 gives contiguous [L, num_ctx, nkv, hd] for K and V.
557
- all_kv = (
558
- all_kv_flat.view(num_ctx, num_layers, 2, num_kv_heads, head_dim)
559
- .permute(2, 1, 0, 3, 4)
560
- .contiguous()
561
- )
611
+ all_kv = all_kv.permute(2, 1, 0, 3, 4).contiguous()
612
all_k = all_kv[0] # [L, num_ctx, nkv, hd], contiguous
613
all_v = all_kv[1] # [L, num_ctx, nkv, hd], contiguous
614
return all_k, all_v
@@ -724,6 +774,11 @@ class DFlashQwen3ForCausalLM(Qwen3ForCausalLM):
774
self.lm_head = ParallelLMHead(
775
self.config.draft_vocab_size,
776
self.config.hidden_size,
777
+ quant_config=(
778
+ get_draft_quant_config(vllm_config)
779
+ if getattr(self.config, "has_own_lm_head", False)
780
+ else None
781
+ ),
782
prefix=maybe_prefix(prefix, "lm_head"),
783
)
784
self.logits_processor = LogitsProcessor(
vllm/model_executor/models/qwen3_dflash2.py
+3
-1
@@ -176,6 +176,7 @@ class DFlashGroupedConv(nn.Module):
176
block_size: int,
177
params_dtype: torch.dtype,
178
prefix: str,
179
+ quant_config: QuantizationConfig | None = None,
180
) -> None:
181
super().__init__()
182
if hidden_size % group_size:
@@ -195,7 +196,7 @@ class DFlashGroupedConv(nn.Module):
196
2 * taps * self.num_groups,
197
bias=False,
198
params_dtype=params_dtype,
198
- quant_config=None,
199
+ quant_config=quant_config,
200
prefix=maybe_prefix(prefix, "kernel_projection"),
201
return_bias=False,
202
)
@@ -254,6 +255,7 @@ class DFlash2Qwen3DecoderLayer(DFlashQwen3DecoderLayer):
255
# Query tokens per request: the bonus token plus the mask tokens.
256
block_size=1 + speculative_config.num_speculative_tokens,
257
params_dtype=vllm_config.model_config.dtype,
258
+ quant_config=quant_config,
259
)
260
self.attention_conv = DFlashGroupedConv(
261
**conv_args, prefix=maybe_prefix(prefix, "attention_conv")
vllm/model_executor/models/registry.py
+1
@@ -633,6 +633,7 @@ _SPECULATIVE_DECODING_MODELS = {
633
"EagleLlama4ForCausalLM": ("llama4_eagle", "EagleLlama4ForCausalLM"),
634
"EagleMiniCPMForCausalLM": ("minicpm_eagle", "EagleMiniCPMForCausalLM"),
635
"DFlashDraftModel": ("qwen3_dflash", "DFlashQwen3ForCausalLM"),
636
+ "LiLiCorrDraftModel": ("lilicorr", "LiLiCorrForCausalLM"),
637
"DFlash2DraftModel": ("qwen3_dflash2", "DFlash2Qwen3ForCausalLM"),
638
# Muse Glimmer's DFlash draft head, reusing the generic qwen3_dflash
639
# implementation. EAGLEConfig rewrites a dflash draft's architecture to
vllm/transformers_utils/configs/eagle.py
+3
-1
@@ -68,7 +68,9 @@ class EAGLEConfig(PreTrainedConfig):
68
)
69
kwargs["architectures"] = [
70
arch
71
- if arch.startswith("DFlash") or arch.endswith("DFlash")
71
+ if arch.startswith("DFlash")
72
+ or arch.endswith("DFlash")
73
+ or arch == "LiLiCorrDraftModel"
74
else f"DFlash{arch}"
75
for arch in self.model.architectures
76
]
vllm/v1/worker/gpu/spec_decode/__init__.py
+6
@@ -15,6 +15,12 @@ def init_speculator(vllm_config: VllmConfig, device: torch.device):
15
16
return ExtractHiddenStatesSpeculator(vllm_config, device)
17
elif speculative_config.method == "dflash":
18
+ if "LiLiCorrDraftModel" in speculative_config.draft_model_config.architectures:
19
+ from vllm.v1.worker.gpu.spec_decode.lilicorr.speculator import (
20
+ LiLiCorrSpeculator,
21
+ )
22
+
23
+ return LiLiCorrSpeculator(vllm_config, device)
24
if "DFlash2DraftModel" in speculative_config.draft_model_config.architectures:
25
from vllm.v1.worker.gpu.spec_decode.dflash2.speculator import (
26
DFlash2Speculator,
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+6
@@ -322,6 +322,11 @@ class DFlashSpeculator(DraftModelSpeculator):
322
context_slots,
323
)
324
325
+ def prepare_context_anchor(
326
+ self, input_batch: InputBatch, num_rejected: torch.Tensor
327
+ ) -> None:
328
+ """Publish context features required by a draft's candidate head."""
329
+
330
@torch.inference_mode()
331
def propose(
332
self,
@@ -369,6 +374,7 @@ class DFlashSpeculator(DraftModelSpeculator):
374
else:
375
hidden_states = last_hidden_states
376
self.hidden_states[:num_target_tokens].copy_(hidden_states[:num_target_tokens])
377
+ self.prepare_context_anchor(input_batch, num_rejected)
378
379
if dummy_run and skip_attn_for_dummy_run:
380
# Memory profiling path: block_tables / kv_cache_config are not initialized.
vllm/v1/worker/gpu/spec_decode/dflash/utils.py
+6
-2
@@ -61,8 +61,12 @@ def load_dflash_model(target_model: nn.Module, vllm_config: VllmConfig) -> nn.Mo
61
62
target_lm_head = get_target_lm_head(target_model, target_language_model)
63
draft_lm_head = getattr(dflash_model, "lm_head", None)
64
- if target_lm_head is not None and _should_share(
65
- dflash_model, "has_own_lm_head", draft_lm_head, target_lm_head
64
+ if (
65
+ target_lm_head is not None
66
+ and not getattr(dflash_model.config, "has_own_lm_head", False)
67
+ and _should_share(
68
+ dflash_model, "has_own_lm_head", draft_lm_head, target_lm_head
69
+ )
70
):
71
if draft_lm_head is not None:
72
del dflash_model.lm_head
vllm/v1/worker/gpu/spec_decode/dflash2/speculator.py
+97
-55
@@ -108,76 +108,101 @@ def _cache_draft_logits_kernel(
108
tl.store(cached_candidate_ptr + cache_base + offsets, token_ids, mask=mask)
109
110
111
-class DFlash2Speculator(DFlashSpeculator):
112
- _speculator_name = "DFlash2"
111
+class CandidateSampler:
112
+ """The shared DFlash2/LiLiCorr candidate walk and realized proposal cache."""
113
114
- def __init__(self, vllm_config: VllmConfig, device: torch.device):
115
- super().__init__(vllm_config, device)
116
- draft_config = self.draft_model_config.hf_config.dflash_config
117
- self.selector_top_k = int(draft_config["selector_top_k"])
118
- self._anchor_indices = (
119
- torch.arange(self.max_num_reqs, dtype=torch.int64, device=device)
120
- * self.num_query_per_req
121
- )
122
- self._selector_scores = torch.empty(
123
- self.max_num_reqs,
124
- self.num_speculative_steps,
125
- self.selector_top_k,
126
- dtype=torch.float32,
127
- device=device,
114
+ def __init__(
115
+ self, max_num_reqs: int, num_steps: int, top_k: int, device: torch.device
116
+ ):
117
+ self.num_steps = num_steps
118
+ self.top_k = top_k
119
+ self.scores = torch.empty(
120
+ max_num_reqs, num_steps, top_k, dtype=torch.float32, device=device
121
)
129
- self._cached_candidate_ids = torch.zeros(
130
- self._selector_scores.shape, dtype=torch.int64, device=device
122
+ self.cached_candidate_ids = torch.zeros(
123
+ self.scores.shape, dtype=torch.int64, device=device
124
)
125
133
- def draft_logits_spec(self, vllm_config: VllmConfig) -> tuple[torch.dtype, float]:
134
- # fp32 so the walk and the rejection that checks it read the same
135
- # distribution; -inf because the cache kernel writes only the K
136
- # candidates.
137
- return torch.float32, -float("inf")
138
-
139
- def _sample_path(
126
+ def sample(
127
self,
128
candidate_ids: torch.Tensor,
129
scores: torch.Tensor,
130
num_reqs: int,
131
+ sample_pos: torch.Tensor,
132
+ idx_mapping: torch.Tensor,
133
+ temperature: torch.Tensor,
134
+ seeds: torch.Tensor,
135
+ draft_tokens: torch.Tensor,
136
+ draft_logits: torch.Tensor | None,
137
+ use_fp64: bool,
138
) -> None:
145
- block_k = triton.next_power_of_2(self.selector_top_k)
139
+ candidate_ids = candidate_ids.contiguous()
140
+ sample_pos = sample_pos.contiguous()
141
+ idx_mapping = idx_mapping.contiguous()
142
+ block_k = triton.next_power_of_2(self.top_k)
143
_selector_walk_kernel[(num_reqs,)](
144
scores.contiguous(),
145
candidate_ids.contiguous(),
149
- self.sample_pos,
150
- self.sample_idx_mapping,
151
- self.temperature,
152
- self.seeds,
153
- self.draft_tokens,
154
- self._selector_scores,
155
- num_steps=self.num_speculative_steps,
156
- top_k=self.selector_top_k,
146
+ sample_pos,
147
+ idx_mapping,
148
+ temperature,
149
+ seeds,
150
+ draft_tokens,
151
+ self.scores,
152
+ num_steps=self.num_steps,
153
+ top_k=self.top_k,
154
BLOCK_K=block_k,
158
- SAMPLE_PROBABILISTIC=self.draft_logits is not None,
159
- USE_FP64=self.use_fp64_gumbel,
155
+ SAMPLE_PROBABILISTIC=draft_logits is not None,
156
+ USE_FP64=use_fp64,
157
num_warps=1,
158
)
159
163
- def _cache_draft_logits(self, candidate_ids: torch.Tensor, num_sample: int) -> None:
164
- draft_logits = self.draft_logits
165
- assert draft_logits is not None
166
- block_k = triton.next_power_of_2(self.selector_top_k)
160
+ if draft_logits is not None:
161
+ self._cache_draft_logits(
162
+ candidate_ids, num_reqs * self.num_steps, idx_mapping, draft_logits
163
+ )
164
+
165
+ def _cache_draft_logits(
166
+ self,
167
+ candidate_ids: torch.Tensor,
168
+ num_sample: int,
169
+ idx_mapping: torch.Tensor,
170
+ draft_logits: torch.Tensor,
171
+ ) -> None:
172
+ block_k = triton.next_power_of_2(self.top_k)
173
_cache_draft_logits_kernel[(num_sample,)](
174
draft_logits,
169
- self._cached_candidate_ids,
175
+ self.cached_candidate_ids,
176
candidate_ids,
171
- self._selector_scores,
172
- self.sample_idx_mapping,
177
+ self.scores,
178
+ idx_mapping,
179
draft_logits.stride(0),
180
draft_logits.stride(1),
175
- num_steps=self.num_speculative_steps,
176
- top_k=self.selector_top_k,
181
+ num_steps=self.num_steps,
182
+ top_k=self.top_k,
183
BLOCK_K=block_k,
184
num_warps=1,
185
)
186
187
+
188
+class DFlash2Speculator(DFlashSpeculator):
189
+ _speculator_name = "DFlash2"
190
+ _candidate_top_k_key = "selector_top_k"
191
+
192
+ def __init__(self, vllm_config: VllmConfig, device: torch.device):
193
+ super().__init__(vllm_config, device)
194
+ draft_config = self.draft_model_config.hf_config.dflash_config
195
+ self.top_k = int(draft_config[self._candidate_top_k_key])
196
+ self.candidate_sampler = CandidateSampler(
197
+ self.max_num_reqs, self.num_speculative_steps, self.top_k, device
198
+ )
199
+
200
+ def draft_logits_spec(self, vllm_config: VllmConfig) -> tuple[torch.dtype, float]:
201
+ # fp32 so the walk and the rejection that checks it read the same
202
+ # distribution; -inf because the cache kernel writes only the K
203
+ # candidates.
204
+ return torch.float32, -float("inf")
205
+
206
def _generate_draft(
207
self,
208
num_reqs: int,
@@ -202,22 +227,39 @@ class DFlash2Speculator(DFlashSpeculator):
227
hidden_states.flatten(0, 1)
228
)
229
candidate_ids = candidate_ids.view(
205
- num_reqs, self.num_speculative_steps, self.selector_top_k
230
+ num_reqs, self.num_speculative_steps, self.top_k
231
)
232
unary_logits = unary_logits.view_as(candidate_ids)
208
- anchor_token_ids = self.input_buffers.input_ids[self._anchor_indices[:num_reqs]]
209
- scores = self.model.model.candidate_selector(
233
+ scores = self._score_candidates(candidate_ids, unary_logits, hidden_states)
234
+ self.candidate_sampler.sample(
235
candidate_ids,
211
- unary_logits,
212
- hidden_states,
213
- anchor_token_ids,
236
+ scores,
237
+ num_reqs,
238
+ self.sample_pos,
239
+ self.sample_idx_mapping,
240
+ self.temperature,
241
+ self.seeds,
242
+ self.draft_tokens,
243
+ self.draft_logits,
244
+ self.use_fp64_gumbel,
245
)
215
- self._sample_path(candidate_ids, scores, num_reqs)
246
if self.enable_adaptive_verification:
247
self._maybe_predict_acceptance(
218
- self._selector_scores[:num_reqs].flatten(0, 1),
248
+ self.candidate_sampler.scores[:num_reqs].flatten(0, 1),
249
self.sample_idx_mapping[:num_sample],
250
self.sample_col[:num_sample],
251
)
222
- if self.draft_logits is not None:
223
- self._cache_draft_logits(candidate_ids, num_sample)
252
+
253
+ def _score_candidates(
254
+ self,
255
+ candidate_ids: torch.Tensor,
256
+ unary_logits: torch.Tensor,
257
+ hidden_states: torch.Tensor,
258
+ ) -> torch.Tensor:
259
+ num_reqs = candidate_ids.shape[0]
260
+ anchor_token_ids = self.input_buffers.input_ids[
261
+ : num_reqs * self.num_query_per_req : self.num_query_per_req
262
+ ]
263
+ return self.model.model.candidate_selector(
264
+ candidate_ids, unary_logits, hidden_states, anchor_token_ids
265
+ )
vllm/v1/worker/gpu/spec_decode/lilicorr/__init__.py
new
+2
@@ -0,0 +1,2 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/v1/worker/gpu/spec_decode/lilicorr/speculator.py
new
+97
@@ -0,0 +1,97 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import torch
5
+from torch import nn
6
+
7
+from vllm.config import VllmConfig
8
+from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
9
+from vllm.v1.worker.gpu.input_batch import InputBatch
10
+from vllm.v1.worker.gpu.spec_decode.dflash2.speculator import DFlash2Speculator
11
+from vllm.v1.worker.gpu.spec_decode.eagle.utils import get_target_lm_head
12
+
13
+
14
+class LiLiCorrSpeculator(DFlash2Speculator):
15
+ _speculator_name = "LiLiCorr"
16
+ _candidate_top_k_key = "lilicorr_candidate_topk"
17
+
18
+ def __init__(self, vllm_config: VllmConfig, device: torch.device):
19
+ super().__init__(vllm_config, device)
20
+ config = self.draft_model_config.hf_config
21
+ self.anchor_hidden = torch.zeros(
22
+ self.max_num_reqs, config.hidden_size, dtype=self.dtype, device=device
23
+ )
24
+ self.anchor_valid = torch.zeros(
25
+ self.max_num_reqs, dtype=torch.bool, device=device
26
+ )
27
+
28
+ def load_draft_model(
29
+ self, target_model: nn.Module, target_attn_layer_names: set[str]
30
+ ) -> nn.Module:
31
+ model = super().load_draft_model(target_model, target_attn_layer_names)
32
+ language_model = (
33
+ target_model.get_language_model()
34
+ if hasattr(target_model, "get_language_model")
35
+ else target_model
36
+ )
37
+ inner = getattr(language_model, "model", language_model)
38
+ embedding = getattr(inner, "embed_tokens", None) or getattr(
39
+ inner, "embedding", None
40
+ )
41
+ lm_head = (
42
+ model.lm_head
43
+ if model.has_own_lm_head
44
+ else get_target_lm_head(target_model, language_model)
45
+ )
46
+ if not isinstance(embedding, VocabParallelEmbedding) or not isinstance(
47
+ lm_head, VocabParallelEmbedding
48
+ ):
49
+ raise ValueError(
50
+ "LiLiCorr requires the target input embedding and its selected "
51
+ "LM head on the draft rank."
52
+ )
53
+ if embedding.embedding_dim != model.config.hidden_size:
54
+ raise ValueError(
55
+ "LiLiCorr target embedding width must match its trained "
56
+ "token projection input."
57
+ )
58
+ if (
59
+ embedding.shard_indices.num_added_elements
60
+ or lm_head.shard_indices.num_added_elements
61
+ ):
62
+ raise ValueError("LiLiCorr does not support added vocabulary entries.")
63
+ # Candidate embeddings always come from the target; the checkpoint may
64
+ # supply its own candidate LM head.
65
+ self.target_embeddings = embedding
66
+ model.lm_head = lm_head
67
+ return model
68
+
69
+ def prepare_context_anchor(
70
+ self, input_batch: InputBatch, num_rejected: torch.Tensor
71
+ ) -> None:
72
+ num_reqs = input_batch.num_reqs
73
+ starts = input_batch.query_start_loc[:num_reqs]
74
+ ends = input_batch.query_start_loc[1 : num_reqs + 1] - num_rejected[:num_reqs]
75
+ valid = ends > starts
76
+ indices = (ends - 1).clamp_min(0).long()
77
+ self.anchor_hidden.zero_()
78
+ self.anchor_valid.zero_()
79
+ self.anchor_hidden[:num_reqs].copy_(
80
+ self.model.model.hidden_norm(self.hidden_states[indices]) * valid[:, None]
81
+ )
82
+ self.anchor_valid[:num_reqs].copy_(valid)
83
+
84
+ def _score_candidates(
85
+ self,
86
+ candidate_ids: torch.Tensor,
87
+ unary_logits: torch.Tensor,
88
+ hidden_states: torch.Tensor,
89
+ ) -> torch.Tensor:
90
+ num_reqs = candidate_ids.shape[0]
91
+ return self.model.model.lilicorr(
92
+ self.target_embeddings(candidate_ids),
93
+ unary_logits,
94
+ hidden_states,
95
+ self.anchor_hidden[:num_reqs],
96
+ self.anchor_valid[:num_reqs],
97
+ )