File size: 18,469 Bytes
e1acc15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# SPDX-License-Identifier: Apache-2.0
"""Thin wrappers over the exllamav3 v1.6.0 CUDA extension (exllamav3_ext, MIT, turboderp-org).

Nothing from exllamav3 is copied here: we call its pybind entry points (exllamav3_ext/bindings.cpp)
with tensors laid out the way its kernels expect. Citations are exllamav3 v1.6.0 @ 151539c7 paths.

Entry points used:
  exl3_moe_coop      bindings.cpp:292   fused decode MoE, 2-3 launches (rot / gate+up / down),
                                        bsz * top_k <= 256 slots, no grid barrier, deterministic,
                                        no host sync (quant/exl3_moe_coop.cu:315-364,
                                        quant/exl3_moe_coop_kernel.cuh:1-21)
  exl3_moe           bindings.cpp:290   grouped MoE over expert-sorted rows, 16/32/64-row tiles,
                                        experts with more than R rows are skipped (quant/exl3_moe.cu:189-572)
  exl3_moe_max_concurrency  :289        groups the exl3_moe launch can keep co-resident
  reconstruct        bindings.cpp:170   trellis -> fp16 weight in the rotated basis
  had_r_128          bindings.cpp:175   128-block Hadamard with optional pre (suh) / post (svh) scale
  hgemm              bindings.cpp:209   fp16 GEMM, fp16 or fp32 output

Per-expert bitrates: every launch takes ONE (K_gate == K_up, K_down) pair
(exl3_moe.cu:367-378, exl3_moe_coop.cu:193-194, modules/multilinear.py:25-26), so a layer with
mixed K runs one launch per K group. Experts are stored K-sorted (plan.LayerPlan.perm) so a group
is a contiguous physical range [lo, hi) and the coop kernel's expert-range filter
(MoeCoopParams.min_expert/max_expert, exl3_moe_coop_kernel.cuh:211-223) masks the other groups.
"""

from __future__ import annotations

import os
from dataclasses import dataclass

import torch

MOE_ACT_SILU = 0            # exl3_moe_common.cuh:7
MOE_COOP_ACT_SILU = 0       # exl3_moe_coop.cuh:13
COOP_MAX_SLOTS = 256        # exl3_moe_coop_kernel.cuh:46, exl3_moe_coop.cu:226
MAX_K_GROUPS = 8            # scratch sets for concurrent K groups (a layer has one group per distinct (k_gu, k_d))
MTILE_T1, MTILE_T2 = 16, 32  # row-tile tiers, as exllamav3 uses them (block_sparse_mlp.py:37-38)

_EXT = None


def ext():
    """The compiled extension. Prefer the prebuilt top-level module the exllamav3 wheel installs
    (setup.py: extension_name = "exllamav3_ext"); fall back to exllamav3's JIT loader (needs nvcc)."""
    global _EXT
    if _EXT is None:
        try:
            import exllamav3_ext as m  # type: ignore[import-not-found]
        except ImportError:
            try:
                from exllamav3.ext import exllamav3_ext as m  # type: ignore[import-not-found,no-redef]
            except Exception as e:  # noqa: BLE001
                raise ImportError(
                    "vllm_exl3_moe needs exllamav3 v1.6.0's compiled extension (exllamav3_ext). Build the "
                    "wheel for sm_121 (TORCH_CUDA_ARCH_LIST=12.1) and install it with --no-deps."
                ) from e
        missing = [n for n in ("exl3_moe", "exl3_moe_coop", "exl3_moe_max_concurrency", "reconstruct",
                               "had_r_128", "hgemm") if not hasattr(m, n)]
        if missing:
            raise ImportError(f"exllamav3_ext lacks {missing}: v1.6.0 or newer is required")
        _EXT = m
    return _EXT


def have_ext() -> bool:
    try:
        ext()
        return True
    except ImportError:
        return False


def coop_ctr_len(slots_max: int, rows_max: int, inter: int, out_dim: int) -> int:
    """int32 scratch of the coop kernels: completion counters + run table
    (exl3_moe_coop.cuh:154-158, exl3_moe_coop_ctr_len)."""
    return slots_max * (inter // 128) + rows_max * (out_dim // 128) + 2 + (slots_max + 1) + slots_max


# ---------------------------------------------------------------------------------------------
# Pointer tables


@dataclass
class ProjTables:
    trellis: torch.Tensor   # int64 [n] device: data_ptr of each expert's trellis
    suh: torch.Tensor       # int64 [n]
    svh: torch.Tensor       # int64 [n]

    def slice(self, lo: int, hi: int) -> "ProjTables":
        # 1-D slices of contiguous tensors stay contiguous (the kernels check it)
        return ProjTables(self.trellis[lo:hi], self.suh[lo:hi], self.svh[lo:hi])


@dataclass
class GroupTables:
    k_gu: float
    k_d: float
    lo: int
    hi: int
    g: ProjTables   # gate (w1)
    u: ProjTables   # up (w3)
    d: ProjTables   # down (w2)

    @property
    def size(self) -> int:
        return self.hi - self.lo


# ---------------------------------------------------------------------------------------------
# Scratch, shared by every layer of the process (all routed layers have the same shapes and run
# in stream order; the coop counters reset each other between calls, exl3_moe_coop_kernel.cuh:695-698,
# 833-835, so one zeroed buffer serves every layer)


@dataclass
class CoopScratch:
    rows_max: int
    slots_max: int
    had_g: torch.Tensor     # fp16 [slots_max, H]
    had_u: torch.Tensor     # fp16 [slots_max, H]
    gu_g: torch.Tensor      # fp16 [slots_max, I]
    gu_u: torch.Tensor      # fp16 [slots_max, I]
    act_out: torch.Tensor   # fp16 [slots_max, I]
    d_out: torch.Tensor     # fp32 [slots_max, H]
    ctr: torch.Tensor       # int32 [coop_ctr_len], zero once
    out: tuple[torch.Tensor, torch.Tensor]   # fp32 [rows_max, H] ping-pong (K-group chaining)


@dataclass
class FusedScratch:
    rows: int               # R: rows per expert the fused exl3_moe kernel takes
    concurrency: int        # C
    tsg: torch.Tensor       # fp16 [C, R, H]
    tsu: torch.Tensor
    tig: torch.Tensor       # fp16 [C, R, I]
    tiu: torch.Tensor


_COOP: dict[tuple, CoopScratch] = {}
_FUSED: dict[tuple, FusedScratch] = {}
_WBUF: dict[tuple, torch.Tensor] = {}


def get_coop_scratch(device: torch.device, rows_max: int, top_k: int, hidden: int, inter: int) -> CoopScratch:
    key = (str(device), rows_max, top_k, hidden, inter)
    sc = _COOP.get(key)
    if sc is None:
        sc = _new_coop_scratch(device, rows_max, top_k, hidden, inter)
        _COOP[key] = sc
    return sc


_PAR: dict[tuple, tuple[list[CoopScratch], list[torch.cuda.Stream]]] = {}


def get_parallel_coop(device: torch.device, rows_max: int, top_k: int, hidden: int, inter: int, n: int):
    """n independent scratch sets + n side streams, shared by every layer of the process (layers run in stream order,
    and each call joins its streams back before returning), for run_decode_coop_parallel."""
    key = (str(device), rows_max, top_k, hidden, inter, n)
    v = _PAR.get(key)
    if v is None:
        v = ([_new_coop_scratch(device, rows_max, top_k, hidden, inter) for _ in range(n)],
             [torch.cuda.Stream(device=device) for _ in range(n)])
        _PAR[key] = v
    return v


def _new_coop_scratch(device: torch.device, rows_max: int, top_k: int, hidden: int, inter: int) -> CoopScratch:
    if True:
        slots_max = rows_max * top_k
        if slots_max > COOP_MAX_SLOTS:
            raise ValueError(f"coop path holds at most {COOP_MAX_SLOTS} slots; rows_max {rows_max} x top_k {top_k}")
        f16 = dict(dtype=torch.float16, device=device)
        f32 = dict(dtype=torch.float32, device=device)
        sc = CoopScratch(
            rows_max=rows_max,
            slots_max=slots_max,
            had_g=torch.empty((slots_max, hidden), **f16),
            had_u=torch.empty((slots_max, hidden), **f16),
            gu_g=torch.empty((slots_max, inter), **f16),
            gu_u=torch.empty((slots_max, inter), **f16),
            act_out=torch.empty((slots_max, inter), **f16),
            d_out=torch.empty((slots_max, hidden), **f32),
            ctr=torch.zeros((coop_ctr_len(slots_max, rows_max, inter, hidden),), dtype=torch.int32, device=device),
            out=(torch.zeros((rows_max, hidden), **f32), torch.zeros((rows_max, hidden), **f32)),
        )
    return sc


def get_fused_scratch(device: torch.device, rows: int, hidden: int, inter: int) -> FusedScratch:
    key = (str(device), rows, hidden, inter)
    fs = _FUSED.get(key)
    if fs is None:
        c = int(ext().exl3_moe_max_concurrency(torch.device(device).index or 0))
        f16 = dict(dtype=torch.float16, device=device)
        fs = FusedScratch(
            rows=rows,
            concurrency=c,
            tsg=torch.empty((c, rows, hidden), **f16),
            tsu=torch.empty((c, rows, hidden), **f16),
            tig=torch.empty((c, rows, inter), **f16),
            tiu=torch.empty((c, rows, inter), **f16),
        )
        _FUSED[key] = fs
    return fs


def _wbuf(device: torch.device, numel: int) -> torch.Tensor:
    key = (str(device),)
    b = _WBUF.get(key)
    if b is None or b.numel() < numel:
        b = torch.empty((numel,), dtype=torch.float16, device=device)
        _WBUF[key] = b
    return b[:numel]


# ---------------------------------------------------------------------------------------------
# Decode: exl3_moe_coop, one launch set per K group, chained through the shared-expert input


def run_decode_coop(
    x16: torch.Tensor,          # fp16 [M, H], contiguous
    sel: torch.Tensor,          # int64 [M, top_k], physical (K-sorted) expert slots
    rw16: torch.Tensor,         # fp16 [M, top_k], 0 = inactive slot (exl3_moe_coop_kernel.cuh:213-215)
    groups: list[GroupTables],
    sc: CoopScratch,
    act_limit: float,
    mcg: bool,
    mul1: bool,
) -> torch.Tensor:
    """Routed partial sum of this TP rank, fp32 [M, H].

    Group g writes out_g = (its experts' weighted outputs) + out_{g-1}: the kernel adds its optional
    shared-expert input sh_out to every row it stores (exl3_moe_coop_kernel.cuh:572-610, 929-947) and
    rows with no active slot get sh_out alone, so chaining gives the sum over groups with no extra
    kernel. `out` is always passed whole so rows_max (and the counter layout derived from it,
    exl3_moe_coop.cu:228-238) never changes between calls.

    TODO(untested on sm_121): numerics vs the reconstruct reference, every K in the plan, bsz 1..16.
    """
    m, hidden = x16.shape
    e = ext()
    prev = None
    for i, g in enumerate(groups):
        out = sc.out[i % 2]
        e.exl3_moe_coop(
            x16, sel, rw16,
            g.lo, g.hi,             # expert-range filter: slots outside [lo, hi) are inactive
            hidden,                 # Hi (5120, a multiple of 128: no padding)
            g.g.trellis, g.g.suh, g.g.svh,
            g.u.trellis, g.u.suh, g.u.svh,
            g.d.trellis, g.d.suh, g.d.svh,
            None, None, None,       # no expert biases in DeepSeek-V4.1
            float(g.k_gu), float(g.k_gu), float(g.k_d),
            mcg, mul1,
            MOE_COOP_ACT_SILU, float(act_limit),
            True,                   # gated
            sc.had_g, sc.had_u, sc.gu_g, sc.gu_u, sc.act_out, sc.d_out, sc.ctr,
            out,
            prev,                   # sh_out: running sum of the previous groups
            None,                   # sh_gate_w
        )
        prev = out
    assert prev is not None
    return prev[:m]


def run_decode_coop_parallel(
    x16: torch.Tensor, sel: torch.Tensor, rw16: torch.Tensor, groups: list[GroupTables],
    scs: list[CoopScratch], streams: list[torch.cuda.Stream], act_limit: float, mcg: bool, mul1: bool,
    reduce: bool = True,
):
    """run_decode_coop with the K groups concurrent instead of chained: group i runs on side stream i with its own
    scratch (counters included) and writes only its own experts' sum (no sh_out), then the main stream joins every
    stream and adds the partial outputs in fp32. At decode sizes one layer is up to 8 small launches in sequence
    (2 per group); running the groups side by side lets one group's kernels fill the SMs another group's tail
    leaves idle. The fork / join is legal under CUDA-graph capture (side streams wait on the capturing stream and
    are joined before the call returns). VLLM_EXL3_MOE_PARALLEL_GROUPS=1 selects it."""
    m, hidden = x16.shape
    e = ext()
    main = torch.cuda.current_stream()
    outs = []
    for g, sc, st in zip(groups, scs, streams):
        st.wait_stream(main)
        with torch.cuda.stream(st):
            out = sc.out[0]
            e.exl3_moe_coop(
                x16, sel, rw16, g.lo, g.hi, hidden,
                g.g.trellis, g.g.suh, g.g.svh, g.u.trellis, g.u.suh, g.u.svh, g.d.trellis, g.d.suh, g.d.svh,
                None, None, None,
                float(g.k_gu), float(g.k_gu), float(g.k_d), mcg, mul1,
                MOE_COOP_ACT_SILU, float(act_limit), True,
                sc.had_g, sc.had_u, sc.gu_g, sc.gu_u, sc.act_out, sc.d_out, sc.ctr,
                out, None, None,
            )
        outs.append(out)
    for st in streams[:len(groups)]:
        main.wait_stream(st)
    if not reduce:
        return outs                  # per-group fp32 [rows_max, hidden] scratch; the caller sums rows [:m]
    total = outs[0][:m].clone()
    for o in outs[1:]:
        total.add_(o[:m])
    return total


# ---------------------------------------------------------------------------------------------
# Prefill / large batches: exl3_moe per K group (fused tier) + reconstruct for hot experts


def bucket_rows(phys_flat: torch.Tensor, g: GroupTables, flat_tok: torch.Tensor, flat_w16: torch.Tensor):
    """Sort the batch's assignments by group-local expert id; picks outside the group go to the
    sentinel bin E (exl3_moe.cu:200-203: the last count is ignored). Counts come from scatter_add,
    not bincount: torch.bincount reads max() back to the host, which a CUDA graph cannot take."""
    n = g.size
    loc = phys_flat - g.lo
    inb = (loc >= 0) & (loc < n)
    loc = torch.where(inb, loc, torch.full_like(loc, n))
    order = torch.argsort(loc)
    counts = torch.zeros((n + 1,), dtype=torch.long, device=loc.device)
    counts.scatter_add_(0, loc, torch.ones_like(loc))
    return counts, flat_tok[order].contiguous(), flat_w16[order].contiguous()


def run_exl3_moe(
    x16: torch.Tensor, out32: torch.Tensor, g: GroupTables, counts: torch.Tensor,
    token_sorted: torch.Tensor, weight_sorted: torch.Tensor, fs: FusedScratch,
    act_limit: float, mcg: bool, mul1: bool,
    num_active: int = -1, count_lo: int = 1, count_hi: int | None = None, m_tile: int = 16,
    output_scratch: torch.Tensor | None = None, fused_base: torch.Tensor | None = None,
) -> None:
    """One exl3_moe launch over group g: accumulates into out32 with atomics (not bit-reproducible), or, with
    output_scratch [slots, H] fp32 + fused_base [n] int64, writes every assignment's weighted output into its own slot
    (row r of expert e -> fused_base[e] + r) for exl3_moe_gather to sum in k order (exl3_moe.cu:575-697).
    num_active = -1 under CUDA graphs (exl3_moe.cu:254-257)."""
    ext().exl3_moe(
        x16, out32, counts, token_sorted, weight_sorted,
        fs.tsg, fs.tsu, fs.tig, fs.tiu,
        MOE_ACT_SILU,
        float(g.k_gu), float(g.k_gu), float(g.k_d),
        g.g.trellis, g.g.suh, g.g.svh,
        g.u.trellis, g.u.suh, g.u.svh,
        g.d.trellis, g.d.suh, g.d.svh,
        mcg, mul1, mcg, mul1, mcg, mul1,
        float(act_limit),
        int(num_active),
        output_scratch, fused_base,
        int(count_lo), int(fs.rows if count_hi is None else count_hi), int(m_tile),
    )


def exl3_linear_reconstruct(
    x16: torch.Tensor, trellis: torch.Tensor, suh: torch.Tensor, svh: torch.Tensor,
    k: float, mcg: bool, mul1: bool,
) -> torch.Tensor:
    """y = x @ W for one EXL3 matrix, fp32 out: rotate x by suh + Hadamard, dequantize the trellis in
    the rotated basis (exact in fp16), GEMM, rotate back with svh. Same arithmetic as
    LinearEXL3.reconstruct_hgemm's unfused branch (modules/quant/exl3.py:166-223)."""
    e = ext()
    k_in, n_out = trellis.shape[0] * 16, trellis.shape[1] * 16
    xh = torch.empty_like(x16)
    e.had_r_128(x16, xh, suh, None, 1.0)
    w = _wbuf(x16.device, k_in * n_out).view(k_in, n_out)
    e.reconstruct(w, trellis, float(k), mcg, mul1)
    y = torch.empty((x16.shape[0], n_out), dtype=torch.float32, device=x16.device)
    e.hgemm(xh, w, y)
    e.had_r_128(y, y, None, svh, 1.0)
    return y


def swiglu_limit(gate: torch.Tensor, up: torch.Tensor, limit: float) -> torch.Tensor:
    """silu(min(gate, limit)) * clamp(up, -limit, limit): DeepSeek-V4 / vLLM semantics
    (vllm fused_moe/activation.py:296-310) and exllamav3's act_limit (activation_kernels.cuh:178-182)."""
    if limit:
        gate = gate.clamp(max=limit)
        up = up.clamp(min=-limit, max=limit)
    return torch.nn.functional.silu(gate) * up


def prime(device: torch.device, groups: list[GroupTables], sc: CoopScratch, fs: FusedScratch | None,
          hidden: int, top_k: int, act_limit: float, mcg: bool, mul1: bool) -> None:
    """Eager warm-up before any CUDA graph capture: the first launch of each kernel instance sets its
    shared-memory attribute (exl3_moe_coop.cu:116-125, exl3_moe.cu:495-500) and exl3_moe allocates its
    lock buffer with cudaMalloc on first use (exl3_devctx.cu:89-106), which is illegal while a stream
    is capturing. One single-token call per group activates exactly that group's instances.
    Every serve since Oct 9 captures its graphs after this priming without an allocation error."""
    if torch.cuda.is_current_stream_capturing():
        raise RuntimeError("vllm_exl3_moe.prime() must run before CUDA graph capture")
    x = torch.zeros((1, hidden), dtype=torch.float16, device=device)
    for g in groups:
        # distinct picks inside the group (bsz 1 assumes distinct top-k, exl3_moe_coop_kernel.cuh:224-227);
        # only slot 0 carries weight, the rest are inactive (w == 0)
        sel = (g.lo + torch.arange(top_k, device=device) % g.size).to(torch.long).view(1, top_k)
        rw = torch.zeros((1, top_k), dtype=torch.float16, device=device)
        rw[0, 0] = 1.0
        run_decode_coop(x, sel, rw, [g], sc, act_limit, mcg, mul1)
        if fs is not None:
            flat = sel.reshape(-1)
            counts, tok, w = bucket_rows(flat, g, torch.zeros_like(flat), rw.reshape(-1))
            out = torch.zeros((1, hidden), dtype=torch.float32, device=device)
            run_exl3_moe(x, out, g, counts, tok, w, fs, act_limit, mcg, mul1, num_active=-1)
    torch.cuda.synchronize(device)


def env_int(name: str, default: int) -> int:
    v = os.environ.get(name)
    return int(v) if v not in (None, "") else default