vllm-quantpack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vllm_quantpack/__init__.py +61 -0
- vllm_quantpack/exl3/__init__.py +0 -0
- vllm_quantpack/exl3/embedding.py +85 -0
- vllm_quantpack/exl3/hooks.py +11 -0
- vllm_quantpack/exl3/layers.py +89 -0
- vllm_quantpack/exl3/linear.py +328 -0
- vllm_quantpack/exl3/moe.py +280 -0
- vllm_quantpack/exl3/names.py +92 -0
- vllm_quantpack/exl3/ops.py +49 -0
- vllm_quantpack/exl3/spec_decode.py +37 -0
- vllm_quantpack/exl3/tp_ranges.py +54 -0
- vllm_quantpack/families/__init__.py +0 -0
- vllm_quantpack/families/flint/__init__.py +31 -0
- vllm_quantpack/families/flint/config.py +83 -0
- vllm_quantpack/families/flint/interm_div.py +71 -0
- vllm_quantpack/families/flint/model.py +580 -0
- vllm_quantpack/families/nemotron/__init__.py +14 -0
- vllm_quantpack/families/nemotron/model.py +112 -0
- vllm_quantpack/families/qwen.py +105 -0
- vllm_quantpack/families/spark/__init__.py +25 -0
- vllm_quantpack/families/spark/config.py +68 -0
- vllm_quantpack/families/spark/model.py +240 -0
- vllm_quantpack-0.1.0.dist-info/METADATA +73 -0
- vllm_quantpack-0.1.0.dist-info/RECORD +29 -0
- vllm_quantpack-0.1.0.dist-info/WHEEL +5 -0
- vllm_quantpack-0.1.0.dist-info/entry_points.txt +2 -0
- vllm_quantpack-0.1.0.dist-info/licenses/LICENSE +202 -0
- vllm_quantpack-0.1.0.dist-info/licenses/NOTICE +25 -0
- vllm_quantpack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""vllm-quantpack: serve EXL3 (exllamav3 trellis) checkpoints, and the model-family fixes they need, in stock vLLM.
|
|
2
|
+
|
|
3
|
+
Installed as a vLLM general plugin (entry point `vllm.general_plugins`), so `vllm serve <repo>` picks up any checkpoint
|
|
4
|
+
whose config says `quant_method: "exl3"` with no flag and no patch to vLLM.
|
|
5
|
+
|
|
6
|
+
Layout:
|
|
7
|
+
exl3/ quantization config, linear / embedding / MoE methods, the custom ops, tensor-parallel cuts
|
|
8
|
+
families/ fixes for specific model families (Qwen vision and Flash-Next PLE) and models vLLM lacks or
|
|
9
|
+
maps incompletely (Flint, Spark, Nemotron-3.5-Super-VL)
|
|
10
|
+
|
|
11
|
+
It replaces OrcaSAQ2 (whose code it includes, Apache-2.0), qwen38_exl3_vision, flint_vllm and spark_vllm, which must
|
|
12
|
+
not be installed alongside.
|
|
13
|
+
"""
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
# Entry-point names of the plugins this package replaces.
|
|
17
|
+
_CONFLICTS = ("orcasaq2", "qwen38_exl3_vision", "tarninfer_exl3", "flint_vllm", "spark_vllm")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _conflicts():
|
|
21
|
+
"""Replaced plugins that vLLM will also load: installed, and not left out by VLLM_PLUGINS."""
|
|
22
|
+
import os
|
|
23
|
+
from importlib.metadata import entry_points
|
|
24
|
+
|
|
25
|
+
allowed = os.environ.get("VLLM_PLUGINS")
|
|
26
|
+
allowed = None if allowed is None else {p.strip() for p in allowed.split(",")}
|
|
27
|
+
return sorted({ep.name for ep in entry_points(group="vllm.general_plugins")
|
|
28
|
+
if ep.name in _CONFLICTS and (allowed is None or ep.name in allowed)})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register():
|
|
32
|
+
from vllm.logger import init_logger
|
|
33
|
+
from vllm.model_executor.layers.quantization import register_quantization_config
|
|
34
|
+
|
|
35
|
+
logger = init_logger("vllm.quantpack")
|
|
36
|
+
found = _conflicts()
|
|
37
|
+
if found:
|
|
38
|
+
# They register the same quant_method and patch the same classes; which one wins would depend on plugin order.
|
|
39
|
+
raise RuntimeError(f"vllm-quantpack replaces the vLLM plugin(s) {', '.join(found)}: uninstall them from this "
|
|
40
|
+
"environment, or leave them out with VLLM_PLUGINS=quantpack")
|
|
41
|
+
|
|
42
|
+
from .exl3.layers import patch_embedding_layers
|
|
43
|
+
from .exl3.linear import Exl3Config
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
register_quantization_config("exl3")(Exl3Config)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return # already registered in this process
|
|
49
|
+
patch_embedding_layers(logger)
|
|
50
|
+
|
|
51
|
+
from .exl3.spec_decode import patch_should_share
|
|
52
|
+
from .families import flint, nemotron, qwen, spark
|
|
53
|
+
patch_should_share(logger)
|
|
54
|
+
qwen.register(logger)
|
|
55
|
+
if flint.register(logger):
|
|
56
|
+
logger.info("vllm-quantpack: Flint model registered")
|
|
57
|
+
if spark.register(logger):
|
|
58
|
+
logger.info("vllm-quantpack: Spark model registered")
|
|
59
|
+
if nemotron.register(logger):
|
|
60
|
+
logger.info("vllm-quantpack: Nemotron-3.5-Super-VL model registered")
|
|
61
|
+
logger.info("vllm-quantpack %s: quant_method 'exl3' is loadable (linear, int8 embedding, MoE, TP)", __version__)
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The embedding table for vLLM's VocabParallelEmbedding.
|
|
2
|
+
|
|
3
|
+
exllamav3 refuses to quantize embed_tokens and keeps it in system RAM, so its native engine never pays VRAM for it. vLLM
|
|
4
|
+
does: VocabParallelEmbedding is a GPU tensor (2.54 GB in bf16 for a 248,320-token vocabulary). Our packs store it as
|
|
5
|
+
int8 rows with a per-row scale (scripts/embed_int8.py in each repo), dequantized on the GATHERED rows so the table itself
|
|
6
|
+
stays int8; the ablation measured that as free (KLD 0.0338 against 0.0341 for bf16).
|
|
7
|
+
|
|
8
|
+
Origin: OrcaSAQ2 heads.py (Apache-2.0) with the published patch (empty draft table), plus the TP cut and host release
|
|
9
|
+
qwen38_exl3_vision 0.4.1 added: each rank keeps its own vocabulary rows (VocabParallelEmbedding masks the ids and
|
|
10
|
+
all-reduces).
|
|
11
|
+
"""
|
|
12
|
+
import torch
|
|
13
|
+
from vllm.model_executor.layers.quantization.base_config import QuantizeMethodBase
|
|
14
|
+
|
|
15
|
+
from .linear import Exl3ShardParam, release
|
|
16
|
+
|
|
17
|
+
EMBED_SUFFIXES = ("qweight", "scales", "weight")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Exl3EmbeddingMethod(QuantizeMethodBase):
|
|
21
|
+
"""int8 rows with a per-row scale, or a dense table, whichever the pack carries."""
|
|
22
|
+
|
|
23
|
+
def create_weights(self, layer, input_size_per_partition, output_partition_sizes,
|
|
24
|
+
input_size, output_size, params_dtype, **extra_weight_attrs):
|
|
25
|
+
# `extra_weight_attrs` is deliberately not applied: it carries vLLM's vocab-sharding weight_loader, which would
|
|
26
|
+
# assert against our empty placeholders.
|
|
27
|
+
for suffix in EMBED_SUFFIXES:
|
|
28
|
+
layer.register_parameter(suffix, Exl3ShardParam(suffix=suffix))
|
|
29
|
+
layer.exl3_params_dtype = params_dtype
|
|
30
|
+
|
|
31
|
+
def process_weights_after_loading(self, layer):
|
|
32
|
+
if getattr(layer, "tp_size", 1) > 1:
|
|
33
|
+
_cut_tp(layer)
|
|
34
|
+
q = layer.qweight.shards.get(0)
|
|
35
|
+
s = layer.scales.shards.get(0)
|
|
36
|
+
w = layer.weight.shards.get(0)
|
|
37
|
+
dev = next((t.device for t in (q, w) if t is not None and t.device.type == "cuda"),
|
|
38
|
+
torch.device("cuda", torch.cuda.current_device()))
|
|
39
|
+
release(layer, EMBED_SUFFIXES)
|
|
40
|
+
# vLLM's speculative proposer reads `<embed>.weight` (EAGLE/DFlash width check), so keep the name, as None.
|
|
41
|
+
layer.weight = None
|
|
42
|
+
layer.exl3_embed_q = None
|
|
43
|
+
layer.exl3_embed = None
|
|
44
|
+
if q is None:
|
|
45
|
+
# No table at all: a DFlash/EAGLE draft, whose module vLLM replaces with the target's after loading
|
|
46
|
+
# (_maybe_share_embeddings). embedding() complains if it is ever used.
|
|
47
|
+
if w is not None:
|
|
48
|
+
layer.exl3_embed = torch.nn.Parameter(w.to(dev), requires_grad=False)
|
|
49
|
+
return
|
|
50
|
+
if s is None:
|
|
51
|
+
raise RuntimeError("embed_tokens: qweight without scales")
|
|
52
|
+
layer.exl3_embed_q = torch.nn.Parameter(q.to(dev), requires_grad=False)
|
|
53
|
+
layer.exl3_embed_s = torch.nn.Parameter(s.to(dev, layer.exl3_params_dtype).reshape(-1, 1), requires_grad=False)
|
|
54
|
+
|
|
55
|
+
def embedding(self, layer, input_: torch.Tensor) -> torch.Tensor:
|
|
56
|
+
if layer.exl3_embed_q is None and layer.exl3_embed is None:
|
|
57
|
+
raise RuntimeError("embed_tokens: empty table (a draft whose embeddings were not shared with the target)")
|
|
58
|
+
if layer.exl3_embed_q is None:
|
|
59
|
+
return layer.exl3_embed[input_]
|
|
60
|
+
# Index directly rather than through F.embedding: the table is int8, and the scale is applied to the gathered
|
|
61
|
+
# rows so the table itself is never materialised in bf16.
|
|
62
|
+
rows = layer.exl3_embed_q[input_].to(layer.exl3_embed_s.dtype)
|
|
63
|
+
return rows * layer.exl3_embed_s[input_]
|
|
64
|
+
|
|
65
|
+
def apply(self, layer, x: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor:
|
|
66
|
+
if layer.exl3_embed_q is None:
|
|
67
|
+
w = layer.exl3_embed
|
|
68
|
+
else:
|
|
69
|
+
w = layer.exl3_embed_q.to(x.dtype) * layer.exl3_embed_s
|
|
70
|
+
out = torch.nn.functional.linear(x, w.to(x.dtype))
|
|
71
|
+
return out + bias if bias is not None else out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cut_tp(layer):
|
|
75
|
+
si = layer.shard_indices
|
|
76
|
+
a, b = si.org_vocab_start_index, si.org_vocab_end_index
|
|
77
|
+
n = layer.num_embeddings_per_partition
|
|
78
|
+
for name in EMBED_SUFFIXES:
|
|
79
|
+
d = getattr(layer, name).shards
|
|
80
|
+
if 0 not in d:
|
|
81
|
+
continue
|
|
82
|
+
full = d[0]
|
|
83
|
+
local = full.new_zeros((n,) + tuple(full.shape[1:]))
|
|
84
|
+
local[: b - a] = full[a:b]
|
|
85
|
+
d[0] = local
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Post-load hooks for model families that need to adjust EXL3 layers after their weights are in place.
|
|
2
|
+
|
|
3
|
+
Each hook is called once per layer at the end of process_weights_after_loading:
|
|
4
|
+
|
|
5
|
+
LINEAR: hook(prefix, layer) layer.exl3_shards / exl3_dense are final; may set layer.exl3_out_mul
|
|
6
|
+
MOE: hook(prefix, layer, ex) ex is the layer's _Experts; may set ex.out_scale
|
|
7
|
+
|
|
8
|
+
A hook must return without touching the layer when the prefix or model is not one it handles.
|
|
9
|
+
"""
|
|
10
|
+
LINEAR: list = []
|
|
11
|
+
MOE: list = []
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Give VocabParallelEmbedding and ParallelLMHead the quant config the model withholds.
|
|
2
|
+
|
|
3
|
+
vLLM consults quant_config only for layers that are constructed with one, and several models (qwen3_next.py among them)
|
|
4
|
+
build `embed_tokens` and `lm_head` without passing it. A pack whose lm_head is an EXL3 tensor, or whose embedding is int8
|
|
5
|
+
rows, then cannot load: the loader looks for `lm_head.weight` / `embed_tokens.weight` and fails on the names it finds.
|
|
6
|
+
The two constructors are wrapped to supply the current config when the caller omitted one:
|
|
7
|
+
|
|
8
|
+
* EXL3: always (layers of other quant methods are untouched);
|
|
9
|
+
* compressed-tensors (GPTQ packs with an int4/int8 embedding, `re:.*embed_tokens$`): only for the embedding a family
|
|
10
|
+
module announced with `embedding_prefix(...)`, because compressed-tensors picks the scheme by layer name and the
|
|
11
|
+
constructor alone does not know it (see families/qwen.py).
|
|
12
|
+
|
|
13
|
+
Origin: OrcaSAQ2 __init__.py (Apache-2.0); the compressed-tensors case replaces qwen3_5-embed-quant.patch from
|
|
14
|
+
syv-ai/qwen38-27b-rtx3090 (Apache-2.0).
|
|
15
|
+
"""
|
|
16
|
+
import contextlib
|
|
17
|
+
import contextvars
|
|
18
|
+
import inspect
|
|
19
|
+
|
|
20
|
+
_pending_embed = contextvars.ContextVar("quantpack_pending_embed", default=None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextlib.contextmanager
|
|
24
|
+
def embedding_prefix(prefix: str):
|
|
25
|
+
"""While a model builds its layers: the next embedding built without a quant config is `prefix`."""
|
|
26
|
+
token = _pending_embed.set(prefix)
|
|
27
|
+
try:
|
|
28
|
+
yield
|
|
29
|
+
finally:
|
|
30
|
+
_pending_embed.reset(token)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def take_embedding_prefix():
|
|
34
|
+
"""The announced embedding prefix, once; None if there is none or it was already used."""
|
|
35
|
+
prefix = _pending_embed.get()
|
|
36
|
+
if prefix is not None:
|
|
37
|
+
_pending_embed.set(None)
|
|
38
|
+
return prefix
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _current_quant_config():
|
|
42
|
+
from vllm.config import get_current_vllm_config
|
|
43
|
+
try:
|
|
44
|
+
cfg = get_current_vllm_config()
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
return getattr(cfg, "quant_config", None) or getattr(getattr(cfg, "model_config", None), "quant_config", None)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _current_exl3_config():
|
|
51
|
+
from .linear import Exl3Config
|
|
52
|
+
qc = _current_quant_config()
|
|
53
|
+
return qc if isinstance(qc, Exl3Config) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def patch_embedding_layers(logger):
|
|
57
|
+
from vllm.model_executor.layers import vocab_parallel_embedding as vpe
|
|
58
|
+
|
|
59
|
+
if getattr(vpe.VocabParallelEmbedding, "_quantpack_patched", False):
|
|
60
|
+
return
|
|
61
|
+
for cls in (vpe.VocabParallelEmbedding, vpe.ParallelLMHead):
|
|
62
|
+
def make(orig_init, is_head):
|
|
63
|
+
sig = inspect.signature(orig_init)
|
|
64
|
+
|
|
65
|
+
def __init__(self, *args, **kwargs):
|
|
66
|
+
# ParallelLMHead forwards quant_config to its parent POSITIONALLY, and the parent is wrapped too, so
|
|
67
|
+
# injecting a keyword blindly collides. Bind against the real signature and only fill an empty slot.
|
|
68
|
+
try:
|
|
69
|
+
bound = sig.bind_partial(self, *args, **kwargs)
|
|
70
|
+
except TypeError:
|
|
71
|
+
return orig_init(self, *args, **kwargs)
|
|
72
|
+
if bound.arguments.get("quant_config") is None:
|
|
73
|
+
qc = _current_exl3_config()
|
|
74
|
+
if qc is not None:
|
|
75
|
+
bound.arguments["quant_config"] = qc
|
|
76
|
+
return orig_init(*bound.args, **bound.kwargs)
|
|
77
|
+
if not is_head and "prefix" in sig.parameters:
|
|
78
|
+
prefix = take_embedding_prefix()
|
|
79
|
+
qc = _current_quant_config() if prefix is not None else None
|
|
80
|
+
if type(qc).__name__ == "CompressedTensorsConfig":
|
|
81
|
+
bound.arguments["quant_config"] = qc
|
|
82
|
+
bound.arguments["prefix"] = prefix
|
|
83
|
+
return orig_init(*bound.args, **bound.kwargs)
|
|
84
|
+
return orig_init(self, *args, **kwargs)
|
|
85
|
+
return __init__
|
|
86
|
+
|
|
87
|
+
cls.__init__ = make(cls.__init__, cls is vpe.ParallelLMHead)
|
|
88
|
+
vpe.VocabParallelEmbedding._quantpack_patched = True
|
|
89
|
+
logger.info("vllm-quantpack: embed_tokens / lm_head receive the EXL3 (or compressed-tensors embedding) quant config")
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Quantization config and linear method for EXL3 checkpoints.
|
|
2
|
+
|
|
3
|
+
An EXL3 checkpoint stores each projection self-contained: a packed tail-biting trellis plus its own incoherence vectors,
|
|
4
|
+
and the bitrate is implied by the trellis width. Two consequences shape this module:
|
|
5
|
+
|
|
6
|
+
* Shards of a fused vLLM layer (q/k/v inside qkv_proj, gate/up inside gate_up_proj) are separate EXL3 tensors with
|
|
7
|
+
different shapes and, under a mixed-precision allocation, different K. Each shard is kept as its own buffer and
|
|
8
|
+
`apply` runs one GEMM per shard and concatenates.
|
|
9
|
+
* Nothing about the shapes is known until the weights arrive, so create_weights registers placeholders and a loader
|
|
10
|
+
that captures each shard as it is loaded.
|
|
11
|
+
|
|
12
|
+
Origin: OrcaSAQ2 config.py (Apache-2.0) with the published patch (prefix normalisation, empty draft lm_head), merged
|
|
13
|
+
with what qwen38_exl3_vision 0.4.1 used to add on top by monkeypatching:
|
|
14
|
+
* padding: EXL3 pads some unfused layers to a multiple of 128; inputs are zero-padded to the stored width and the extra
|
|
15
|
+
output columns are trimmed;
|
|
16
|
+
* dense layers stored in fp16 are converted once to the model dtype (OrcaSAQ2 converted them on every call: several ms
|
|
17
|
+
per token with a 1 GB lm_head);
|
|
18
|
+
* host memory: the captured tensors are views of the safetensors mmap; the shard dicts are emptied once each layer is
|
|
19
|
+
on the GPU, otherwise the whole checkpoint stayed resident in host RAM;
|
|
20
|
+
* tensor parallelism: the placeholders collect the WHOLE checkpoint tensor whatever the rank, and each rank keeps its
|
|
21
|
+
slice before the kernels are built (tp_ranges.py). Slices off the 128-column block keep the whole tensor and select
|
|
22
|
+
this rank's columns (or zero-pad this rank's input for row-parallel layers).
|
|
23
|
+
"""
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import torch
|
|
27
|
+
import torch.nn.functional as F
|
|
28
|
+
from vllm.logger import init_logger
|
|
29
|
+
from vllm.model_executor.layers.linear import LinearBase, LinearMethodBase
|
|
30
|
+
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig, QuantizeMethodBase
|
|
31
|
+
from vllm.model_executor.parameter import BasevLLMParameter
|
|
32
|
+
|
|
33
|
+
from . import hooks
|
|
34
|
+
from . import ops # noqa: F401 (registers quantpack::exl3_gemm)
|
|
35
|
+
from .names import expand, manifest_prefixes, norm_prefix, slot, trellis_k
|
|
36
|
+
from .tp_ranges import col_ranges, logical_parts, on_block, row_range
|
|
37
|
+
|
|
38
|
+
logger = init_logger("vllm.quantpack.exl3")
|
|
39
|
+
|
|
40
|
+
LINEAR_SUFFIXES = ("trellis", "suh", "svh", "mcg", "mul1", "weight")
|
|
41
|
+
|
|
42
|
+
EXLLAMAV3_MISSING = (
|
|
43
|
+
"vllm-quantpack needs the exllamav3 CUDA extension to serve EXL3 checkpoints, and importing it failed: {err}\n"
|
|
44
|
+
"It is not on PyPI: the wheel is ABI-linked to both torch and the CUDA major version, so pick the release asset "
|
|
45
|
+
"matching this environment:\n"
|
|
46
|
+
" python -c \"import torch; print(torch.__version__, torch.version.cuda)\"\n"
|
|
47
|
+
" https://github.com/turboderp-org/exllamav3/releases (cu128.* for CUDA 12.x, cu132.* for CUDA 13.x)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Exl3ShardParam(BasevLLMParameter):
|
|
51
|
+
"""One placeholder parameter standing in for a list of per-shard EXL3 tensors.
|
|
52
|
+
|
|
53
|
+
vLLM wants a Parameter of known shape per weight name; EXL3 shard shapes (and K) are only known once the file is
|
|
54
|
+
read, and differ between shards of a fused layer. So the Parameter is an empty dummy and the real tensors land in
|
|
55
|
+
`shards`, keyed by slot (0 for an unfused layer). `parts` remembers the loader's shard id of each slot, which the
|
|
56
|
+
TP cut needs to know which logical partitions a tensor carries.
|
|
57
|
+
|
|
58
|
+
It carries its OWN weight_loader: vLLM's generic path is `param.weight_loader(param, loaded_weight, shard_id)`, and
|
|
59
|
+
the layer's legacy loader would assert the placeholder's shape against the incoming tensor.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __new__(cls, **kwargs):
|
|
63
|
+
return super().__new__(cls, data=torch.empty(0))
|
|
64
|
+
|
|
65
|
+
def __init__(self, suffix: str):
|
|
66
|
+
super().__init__(data=torch.empty(0), weight_loader=_capture)
|
|
67
|
+
# A dict rather than a fixed list: the number of EXL3 tensors behind a fused layer is not its output-partition
|
|
68
|
+
# count (vLLM builds in_proj_qkvz with four partitions while the pack has two tensors).
|
|
69
|
+
self.shards: dict[int, torch.Tensor] = {}
|
|
70
|
+
self.parts: dict[int, Any] = {}
|
|
71
|
+
self.suffix = suffix
|
|
72
|
+
|
|
73
|
+
def slot(self, shard_id):
|
|
74
|
+
return slot(shard_id)
|
|
75
|
+
|
|
76
|
+
# The BasevLLMParameter hooks, for whichever layers take vLLM's newer path.
|
|
77
|
+
def load_column_parallel_weight(self, loaded_weight, **kw):
|
|
78
|
+
_capture(self, loaded_weight, kw.get("shard_id", kw.get("loaded_shard_id")))
|
|
79
|
+
|
|
80
|
+
load_row_parallel_weight = load_column_parallel_weight
|
|
81
|
+
load_merged_column_weight = load_column_parallel_weight
|
|
82
|
+
load_qkv_weight = load_column_parallel_weight
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _capture(param, loaded_weight, shard_id=None, **kw):
|
|
86
|
+
"""Stash a shard; shapes are whatever the checkpoint says."""
|
|
87
|
+
s = slot(shard_id)
|
|
88
|
+
param.shards[s] = loaded_weight
|
|
89
|
+
param.parts[s] = shard_id
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def release(layer, names):
|
|
93
|
+
"""Empty and drop the placeholders: their tensors are views of the safetensors mmap."""
|
|
94
|
+
for p in names:
|
|
95
|
+
if hasattr(layer, p):
|
|
96
|
+
sh = getattr(getattr(layer, p), "shards", None)
|
|
97
|
+
if sh is not None:
|
|
98
|
+
sh.clear()
|
|
99
|
+
delattr(layer, p)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Exl3Config(QuantizationConfig):
|
|
103
|
+
"""`quant_method: "exl3"`: exllamav3 trellis quantization, any per-tensor bitrate."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, bits: float, codebook: str, head_bits: float, full_config: dict):
|
|
106
|
+
super().__init__()
|
|
107
|
+
self.bits = bits
|
|
108
|
+
self.codebook = codebook
|
|
109
|
+
self.head_bits = head_bits
|
|
110
|
+
self.full_config = full_config
|
|
111
|
+
# which module prefixes actually carry a trellis, from the pack's own manifest (normalised)
|
|
112
|
+
self.quantized = manifest_prefixes(full_config.get("tensor_storage") or {})
|
|
113
|
+
|
|
114
|
+
def __repr__(self):
|
|
115
|
+
return (f"Exl3Config(bits={self.bits}, codebook={self.codebook!r}, "
|
|
116
|
+
f"head_bits={self.head_bits}, quantized={len(self.quantized)})")
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def get_name(cls):
|
|
120
|
+
return "exl3"
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def get_supported_act_dtypes(cls):
|
|
124
|
+
return [torch.half, torch.bfloat16]
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def get_min_capability(cls):
|
|
128
|
+
return 80
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def get_config_filenames():
|
|
132
|
+
return ["quantization_config.json"]
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_config(cls, config: dict[str, Any]):
|
|
136
|
+
# Fail here, not in the middle of a forward pass: every GEMM reaches for the extension lazily.
|
|
137
|
+
try:
|
|
138
|
+
from exllamav3.ext import exllamav3_ext # noqa: F401
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise RuntimeError(EXLLAMAV3_MISSING.format(err=f"{type(e).__name__}: {e}")) from e
|
|
141
|
+
return cls(bits=float(config.get("bits", 0.0)),
|
|
142
|
+
codebook=config.get("codebook", "mul1"),
|
|
143
|
+
head_bits=float(config.get("head_bits", 16)),
|
|
144
|
+
full_config=config)
|
|
145
|
+
|
|
146
|
+
def get_quant_method(self, layer: torch.nn.Module, prefix: str) -> QuantizeMethodBase | None:
|
|
147
|
+
from vllm.model_executor.layers.fused_moe import RoutedExperts
|
|
148
|
+
from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead, VocabParallelEmbedding
|
|
149
|
+
|
|
150
|
+
if isinstance(layer, RoutedExperts):
|
|
151
|
+
from .moe import Exl3MoEMethod
|
|
152
|
+
return Exl3MoEMethod(layer.moe_config, prefix)
|
|
153
|
+
if isinstance(layer, ParallelLMHead):
|
|
154
|
+
# A recipe with head_bits below 16 makes lm_head an ordinary EXL3 tensor; ParallelLMHead is exempt from
|
|
155
|
+
# embedding(), so the linear method is correct. It falls back to a dense weight when the pack has one.
|
|
156
|
+
return Exl3LinearMethod(self, prefix)
|
|
157
|
+
if isinstance(layer, VocabParallelEmbedding):
|
|
158
|
+
from .embedding import Exl3EmbeddingMethod
|
|
159
|
+
return Exl3EmbeddingMethod()
|
|
160
|
+
if not isinstance(layer, LinearBase):
|
|
161
|
+
return None
|
|
162
|
+
# A fused layer is quantized iff its constituents are; the manifest is authoritative when present, otherwise
|
|
163
|
+
# every LinearBase is assumed quantized.
|
|
164
|
+
if self.quantized and not any(norm_prefix(c) in self.quantized for c in expand(prefix)):
|
|
165
|
+
from vllm.model_executor.layers.quantization.unquant import UnquantizedLinearMethod
|
|
166
|
+
return UnquantizedLinearMethod()
|
|
167
|
+
return Exl3LinearMethod(self, prefix)
|
|
168
|
+
|
|
169
|
+
def get_scaled_act_names(self):
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Exl3LinearMethod(LinearMethodBase):
|
|
174
|
+
def __init__(self, quant_config: Exl3Config, prefix: str = ""):
|
|
175
|
+
self.quant_config = quant_config
|
|
176
|
+
self.prefix = prefix
|
|
177
|
+
|
|
178
|
+
def create_weights(self, layer, input_size_per_partition, output_partition_sizes,
|
|
179
|
+
input_size, output_size, params_dtype, **extra_weight_attrs):
|
|
180
|
+
# An EXL3 pack is not uniformly quantized: exllamav3 leaves some projections in bf16 (linear_attn.in_proj_a/b,
|
|
181
|
+
# anything given 16 bits) and they arrive as a plain `weight`. Register placeholders for both, decide later.
|
|
182
|
+
for suffix in LINEAR_SUFFIXES:
|
|
183
|
+
layer.register_parameter(suffix, Exl3ShardParam(suffix=suffix))
|
|
184
|
+
layer.exl3_out_total = sum(output_partition_sizes)
|
|
185
|
+
layer.exl3_tp = None
|
|
186
|
+
layer.exl3_row_pad = None
|
|
187
|
+
layer.exl3_col_sel = None
|
|
188
|
+
layer.exl3_out_trim = None
|
|
189
|
+
layer.exl3_out_mul = None # set by a family hook (see hooks.py)
|
|
190
|
+
if input_size != input_size_per_partition or sum(output_partition_sizes) != output_size:
|
|
191
|
+
layer.exl3_tp = dict(rank=getattr(layer, "tp_rank", 0), tp=getattr(layer, "tp_size", 1),
|
|
192
|
+
row=input_size != input_size_per_partition,
|
|
193
|
+
in_local=input_size_per_partition, out_local=list(output_partition_sizes),
|
|
194
|
+
vocab=getattr(layer, "shard_indices", None))
|
|
195
|
+
|
|
196
|
+
def process_weights_after_loading(self, layer) -> None:
|
|
197
|
+
if layer.exl3_tp is not None:
|
|
198
|
+
sel = self._cut_tp(layer, layer.exl3_tp)
|
|
199
|
+
else:
|
|
200
|
+
sel = None
|
|
201
|
+
tr, suh, svh = layer.trellis.shards, layer.suh.shards, layer.svh.shards
|
|
202
|
+
mcg, mul1 = layer.mcg.shards, layer.mul1.shards
|
|
203
|
+
dense = layer.weight.shards
|
|
204
|
+
# padding of a single unfused layer: the pack stores up to 127 extra output columns
|
|
205
|
+
got = sum(v.numel() for v in svh.values()) if tr else layer.exl3_out_total
|
|
206
|
+
if got != layer.exl3_out_total and len(tr) == 1 and 0 < got - layer.exl3_out_total < 128:
|
|
207
|
+
layer.exl3_out_trim, layer.exl3_out_total = layer.exl3_out_total, got
|
|
208
|
+
slots = sorted(tr) or sorted(dense) # shard order == vLLM's concat order
|
|
209
|
+
# The loader may hand these over on CPU; pin everything to this worker's device.
|
|
210
|
+
dev = next((t.device for t in list(tr.values()) + list(dense.values()) if t.device.type == "cuda"),
|
|
211
|
+
torch.device("cuda", torch.cuda.current_device()))
|
|
212
|
+
layer.exl3_shards = None
|
|
213
|
+
layer.exl3_dense = None
|
|
214
|
+
if not tr:
|
|
215
|
+
if not dense:
|
|
216
|
+
if self.prefix.endswith("lm_head"):
|
|
217
|
+
# A DFlash/EAGLE draft ships no lm_head; vLLM replaces this module with the target's after loading
|
|
218
|
+
# (_maybe_share_lm_head). Leave it empty instead of failing.
|
|
219
|
+
release(layer, LINEAR_SUFFIXES)
|
|
220
|
+
return
|
|
221
|
+
raise RuntimeError(f"{self.prefix}: no trellis and no dense weight loaded")
|
|
222
|
+
w = torch.cat([dense[i] for i in slots], 0) if len(slots) > 1 else dense[slots[0]]
|
|
223
|
+
from vllm.config import get_current_vllm_config
|
|
224
|
+
dt = get_current_vllm_config().model_config.dtype
|
|
225
|
+
layer.exl3_dense = torch.nn.Parameter(w.contiguous().to(dev, dt), requires_grad=False)
|
|
226
|
+
else:
|
|
227
|
+
if dense:
|
|
228
|
+
raise RuntimeError(f"{self.prefix}: {len(tr)} trellis and {len(dense)} dense shards; "
|
|
229
|
+
"a fused layer must be all one kind or all the other")
|
|
230
|
+
layer.exl3_shards = [
|
|
231
|
+
dict(trellis=tr[i].contiguous().to(dev), suh=suh[i].to(dev, torch.half).contiguous(),
|
|
232
|
+
svh=svh[i].to(dev, torch.half).contiguous(),
|
|
233
|
+
mcg=mcg.get(i) is not None, mul1=mul1.get(i) is not None,
|
|
234
|
+
K=trellis_k(tr[i].shape[-1]), ic=suh[i].numel(), oc=svh[i].numel())
|
|
235
|
+
for i in slots
|
|
236
|
+
]
|
|
237
|
+
got = sum(s["oc"] for s in layer.exl3_shards)
|
|
238
|
+
if got != layer.exl3_out_total:
|
|
239
|
+
raise RuntimeError(f"{self.prefix}: shards give {got} outputs, layer wants {layer.exl3_out_total}")
|
|
240
|
+
release(layer, LINEAR_SUFFIXES)
|
|
241
|
+
if sel is not None:
|
|
242
|
+
layer.exl3_col_sel = torch.tensor(sel, dtype=torch.long, device=dev)
|
|
243
|
+
for hook in hooks.LINEAR:
|
|
244
|
+
hook(self.prefix, layer)
|
|
245
|
+
|
|
246
|
+
def _cut_tp(self, layer, t):
|
|
247
|
+
"""Keep this rank's slice of the captured tensors. Returns output columns to select, or None."""
|
|
248
|
+
rank, tp = t["rank"], t["tp"]
|
|
249
|
+
tr, suh, svh = layer.trellis.shards, layer.suh.shards, layer.svh.shards
|
|
250
|
+
dense = layer.weight.shards
|
|
251
|
+
if t["row"]:
|
|
252
|
+
a, b = row_range(rank, t["in_local"])
|
|
253
|
+
if tr:
|
|
254
|
+
if not on_block([(a, b)]):
|
|
255
|
+
# e.g. Flash-Next shared_expert.down_proj (640 -> 320 per GPU): the Hadamard block 256:384 straddles
|
|
256
|
+
# the ranks. Keep the whole matrix and zero-pad this rank's input to full width; the all-reduce of
|
|
257
|
+
# W·x_r sums to W·x exactly.
|
|
258
|
+
layer.exl3_row_pad = (a, t["in_local"] * tp - b)
|
|
259
|
+
return None
|
|
260
|
+
for s in list(tr):
|
|
261
|
+
tr[s] = tr[s][a // 16:b // 16].contiguous()
|
|
262
|
+
suh[s] = suh[s][a:b].contiguous()
|
|
263
|
+
for s in list(dense):
|
|
264
|
+
dense[s] = dense[s][:, a:b].contiguous()
|
|
265
|
+
return None
|
|
266
|
+
parts = (layer.trellis if tr else layer.weight).parts
|
|
267
|
+
vocab = t["vocab"]
|
|
268
|
+
sel, off, cut = [], 0, False
|
|
269
|
+
for s in list(tr or dense):
|
|
270
|
+
total = svh[s].numel() if tr else dense[s].shape[0]
|
|
271
|
+
if vocab is not None: # lm_head: this rank's vocabulary rows
|
|
272
|
+
a, b = vocab.org_vocab_start_index, vocab.org_vocab_end_index
|
|
273
|
+
if b - a != t["out_local"][0]:
|
|
274
|
+
raise NotImplementedError(f"{self.prefix}: padded vocabulary partition "
|
|
275
|
+
f"({b - a} rows for {t['out_local'][0]})")
|
|
276
|
+
ranges = [(a, b)]
|
|
277
|
+
else:
|
|
278
|
+
ranges = col_ranges(total, logical_parts(parts.get(s), len(t["out_local"])), t["out_local"], rank, tp)
|
|
279
|
+
if tr and not on_block(ranges):
|
|
280
|
+
# Not on a 128-column block (exllamav3's TP granularity; the reconstruct kernel rejects an output width
|
|
281
|
+
# that is not a multiple of 128, e.g. Flash-Next shared_expert gate_up: 320 per GPU): keep the whole
|
|
282
|
+
# tensor, compute every output and keep this rank's columns.
|
|
283
|
+
sel.extend(i for a, b in ranges for i in range(off + a, off + b))
|
|
284
|
+
off += total
|
|
285
|
+
continue
|
|
286
|
+
off += total
|
|
287
|
+
cut = True
|
|
288
|
+
if tr:
|
|
289
|
+
tr[s] = torch.cat([tr[s][:, a // 16:b // 16] for a, b in ranges], 1).contiguous()
|
|
290
|
+
svh[s] = torch.cat([svh[s][a:b] for a, b in ranges], 0).contiguous()
|
|
291
|
+
else:
|
|
292
|
+
dense[s] = torch.cat([dense[s][a:b] for a, b in ranges], 0).contiguous()
|
|
293
|
+
if not sel:
|
|
294
|
+
return None
|
|
295
|
+
if cut:
|
|
296
|
+
raise NotImplementedError(f"{self.prefix}: fused layer with some shards off the 128-column block")
|
|
297
|
+
layer.exl3_out_total = sum(v.numel() for v in svh.values()) # the kernels produce every output
|
|
298
|
+
return sel
|
|
299
|
+
|
|
300
|
+
def apply(self, layer, x: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor:
|
|
301
|
+
if layer.exl3_row_pad is not None:
|
|
302
|
+
x = F.pad(x, layer.exl3_row_pad)
|
|
303
|
+
if layer.exl3_col_sel is not None:
|
|
304
|
+
out = self._forward(layer, x).index_select(-1, layer.exl3_col_sel)
|
|
305
|
+
elif layer.exl3_out_trim is not None:
|
|
306
|
+
out = self._forward(layer, x)[..., :layer.exl3_out_trim]
|
|
307
|
+
else:
|
|
308
|
+
out = self._forward(layer, x)
|
|
309
|
+
if layer.exl3_out_mul is not None:
|
|
310
|
+
out = out * layer.exl3_out_mul
|
|
311
|
+
return out + bias if bias is not None else out
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _forward(layer, x):
|
|
315
|
+
if layer.exl3_shards is None:
|
|
316
|
+
return F.linear(x, layer.exl3_dense.to(x.dtype)) # a no-op: converted to the model dtype at load
|
|
317
|
+
sh = layer.exl3_shards
|
|
318
|
+
if x.shape[-1] < sh[0]["ic"]:
|
|
319
|
+
x = F.pad(x, (0, sh[0]["ic"] - x.shape[-1]))
|
|
320
|
+
rows = x.numel() // x.shape[-1]
|
|
321
|
+
xf = x.reshape(rows, x.shape[-1]).half()
|
|
322
|
+
if not xf.is_contiguous():
|
|
323
|
+
xf = xf.contiguous() # the kernels read A as contiguous rows
|
|
324
|
+
# One custom op per shard: the extension calls are invisible to Dynamo (see ops.py).
|
|
325
|
+
outs = [torch.ops.quantpack.exl3_gemm(xf, s["trellis"], s["suh"], s["svh"], s["oc"], float(s["K"]),
|
|
326
|
+
s["mcg"], s["mul1"]) for s in sh]
|
|
327
|
+
out = (outs[0] if len(outs) == 1 else torch.cat(outs, dim=-1)).to(x.dtype)
|
|
328
|
+
return out.reshape(*x.shape[:-1], out.shape[-1])
|