[Model] Add LongCat-Flash-Lite (n-gram embedding) (#47857)
Signed-off-by: mgoin <mgoin64@gmail.com>
Michael Goin committed
Jul 10, 2026 at 10:17 UTC
08dfd68610d2e05a0d8ddc99c23488da6163df3f
16 files changed
+630
-12
CMakeLists.txt
+1
@@ -390,6 +390,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
390
"csrc/libtorch_stable/cuda_view.cu"
391
"csrc/libtorch_stable/cuda_utils_kernels.cu"
392
"csrc/libtorch_stable/activation_kernels.cu"
393
+ "csrc/libtorch_stable/ngram_embedding_kernels.cu"
394
"csrc/libtorch_stable/quantization/activation_kernels.cu"
395
"csrc/libtorch_stable/quantization/w8a8/int8/scaled_quant.cu"
396
"csrc/libtorch_stable/quantization/w8a8/fp8/common.cu"
csrc/libtorch_stable/ngram_embedding_kernels.cu
new
+96
@@ -0,0 +1,96 @@
1
+// N-gram embedding index kernel for LongCat-Flash (n-gram embedding variant).
2
+//
3
+// Adapted from SGLang:
4
+// https://github.com/sgl-project/sglang/blob/main/python/sglang/jit_kernel/csrc/ngram_embedding.cuh
5
+//
6
+// For each position, computes the hashed n-gram embedding ids that index the
7
+// concatenated embedder table. Integer tensors are int32 except ``row_indices``
8
+// (int64); the token table is ``[max_running_reqs, max_context_len]`` int32,
9
+// where a negative entry marks an ignored token (e.g. an EOS boundary).
10
+
11
+#include "torch_utils.h"
12
+
13
+#include "ops.h"
14
+
15
+#include <cstdint>
16
+
17
+namespace vllm::ngram_embedding {
18
+
19
+constexpr int kBlockThreads = 256;
20
+
21
+__global__ void ComputeNGramIdsKernel(
22
+ int batch_size, int ne_n, int ne_k,
23
+ int* ne_weights, // [ne_n-1, ne_k, ne_n]
24
+ int* ne_mods, // [ne_n-1, ne_k]
25
+ int* exclusive_ne_embedder_size_sums, // [(ne_n-1)*ne_k + 1]
26
+ int* exclusive_req_len_sums, // [batch_size + 1]
27
+ int* ne_token_table, // [max_running_reqs, max_context_len]
28
+ int max_context_len,
29
+ const int64_t* __restrict__ row_indices, // [batch_size]
30
+ int* column_starts, // [batch_size]
31
+ int* n_gram_ids // [token_num, (ne_n-1)*ne_k]
32
+) {
33
+ const int req_id = blockIdx.x % batch_size;
34
+ const int config_id = (blockIdx.x - req_id) / batch_size;
35
+ // n and k are offset from their physical meaning: n = real_n - 2, k = real_k
36
+ // - 1 (they index into ne_weights / ne_mods).
37
+ const int k = config_id % ne_k;
38
+ const int n = (config_id - config_id % ne_k) / ne_k;
39
+ const int ne_weight_base_idx = n * ne_k * ne_n + k * ne_n;
40
+ const int ne_mod = ne_mods[n * ne_k + k];
41
+ for (int i = exclusive_req_len_sums[req_id] + threadIdx.x;
42
+ i < exclusive_req_len_sums[req_id + 1]; i += blockDim.x) {
43
+ uint64_t n_gram_id = 0;
44
+ const int64_t current_token_offset = i - exclusive_req_len_sums[req_id];
45
+ const int64_t req_token_table_index =
46
+ row_indices[req_id] * static_cast<int64_t>(max_context_len);
47
+ const int64_t current_token_table_index =
48
+ req_token_table_index + column_starts[req_id] + current_token_offset;
49
+ for (int j = 0; j < n + 2; j++) {
50
+ if (current_token_table_index - j < req_token_table_index) {
51
+ break; // outside this request's range
52
+ }
53
+ if (ne_token_table[current_token_table_index - j] < 0) {
54
+ break; // ignored token
55
+ }
56
+ const uint64_t term =
57
+ (uint64_t)ne_token_table[current_token_table_index - j] *
58
+ (uint64_t)ne_weights[ne_weight_base_idx + j];
59
+ n_gram_id += term % ne_mod;
60
+ }
61
+ n_gram_id %= ne_mod;
62
+ n_gram_id += exclusive_ne_embedder_size_sums[n * ne_k + k];
63
+ n_gram_ids[i * (ne_n - 1) * ne_k + n * ne_k + k] = (int)(n_gram_id);
64
+ }
65
+}
66
+
67
+} // namespace vllm::ngram_embedding
68
+
69
+void ngram_compute_n_gram_ids(
70
+ int64_t ne_n, int64_t ne_k, torch::stable::Tensor& ne_weights,
71
+ torch::stable::Tensor& ne_mods,
72
+ torch::stable::Tensor& exclusive_ne_embedder_size_sums,
73
+ torch::stable::Tensor& exclusive_req_len_sums,
74
+ torch::stable::Tensor& ne_token_table, torch::stable::Tensor& row_indices,
75
+ torch::stable::Tensor& column_starts, torch::stable::Tensor& n_gram_ids) {
76
+ const int batch_size = static_cast<int>(exclusive_req_len_sums.size(0) - 1);
77
+ const int max_context_len = static_cast<int>(ne_token_table.size(1));
78
+ const int num_configs = (static_cast<int>(ne_n) - 1) * static_cast<int>(ne_k);
79
+ const int grid_size = num_configs * batch_size;
80
+ if (grid_size <= 0) return;
81
+
82
+ const torch::stable::accelerator::DeviceGuard device_guard(
83
+ ne_weights.get_device_index());
84
+ const cudaStream_t stream = get_current_cuda_stream();
85
+ vllm::ngram_embedding::ComputeNGramIdsKernel<<<
86
+ grid_size, vllm::ngram_embedding::kBlockThreads, 0, stream>>>(
87
+ batch_size, static_cast<int>(ne_n), static_cast<int>(ne_k),
88
+ ne_weights.mutable_data_ptr<int32_t>(),
89
+ ne_mods.mutable_data_ptr<int32_t>(),
90
+ exclusive_ne_embedder_size_sums.mutable_data_ptr<int32_t>(),
91
+ exclusive_req_len_sums.mutable_data_ptr<int32_t>(),
92
+ ne_token_table.mutable_data_ptr<int32_t>(), max_context_len,
93
+ row_indices.const_data_ptr<int64_t>(),
94
+ column_starts.mutable_data_ptr<int32_t>(),
95
+ n_gram_ids.mutable_data_ptr<int32_t>());
96
+}
csrc/libtorch_stable/ops.h
+9
@@ -554,3 +554,12 @@ void cp_gather_indexer_k_quant_cache(
554
// quant_block_size * 4]
555
const torch::stable::Tensor& block_table, // [batch_size, num_blocks]
556
const torch::stable::Tensor& cu_seq_lens); // [batch_size + 1]
557
+
558
+// LongCat n-gram embedding index kernel (see ngram_embedding_kernels.cu).
559
+void ngram_compute_n_gram_ids(
560
+ int64_t ne_n, int64_t ne_k, torch::stable::Tensor& ne_weights,
561
+ torch::stable::Tensor& ne_mods,
562
+ torch::stable::Tensor& exclusive_ne_embedder_size_sums,
563
+ torch::stable::Tensor& exclusive_req_len_sums,
564
+ torch::stable::Tensor& ne_token_table, torch::stable::Tensor& row_indices,
565
+ torch::stable::Tensor& column_starts, torch::stable::Tensor& n_gram_ids);
csrc/libtorch_stable/torch_bindings.cpp
+13
@@ -598,9 +598,22 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
598
"Tensor? initial_state_idx,"
599
"Tensor? cu_chunk_seqlen,"
600
"Tensor? last_chunk_indices) -> ()");
601
+
602
+ // LongCat n-gram embedding index kernel. All tensor args are marked mutable
603
+ // to match the (non-const) stable-Tensor& C++ signature; only ne_token_table
604
+ // and n_gram_ids are actually written in place.
605
+ ops.def(
606
+ "ngram_compute_n_gram_ids(int ne_n, int ne_k, Tensor(a!) ne_weights, "
607
+ "Tensor(b!) ne_mods, Tensor(c!) exclusive_ne_embedder_size_sums, "
608
+ "Tensor(d!) exclusive_req_len_sums, Tensor(e!) ne_token_table, "
609
+ "Tensor(f!) row_indices, Tensor(g!) column_starts, "
610
+ "Tensor(h!) n_gram_ids) -> ()");
611
}
612
613
STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
614
+ // LongCat n-gram embedding index kernel.
615
+ ops.impl("ngram_compute_n_gram_ids", TORCH_BOX(&ngram_compute_n_gram_ids));
616
+
617
// Per-token group quantization
618
ops.impl("per_token_group_fp8_quant", TORCH_BOX(&per_token_group_quant_fp8));
619
ops.impl("per_token_group_fp8_quant_packed",
tests/models/registry.py
+7
@@ -382,6 +382,13 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
382
"LongcatFlashForCausalLM": _HfExamplesInfo(
383
"meituan-longcat/LongCat-Flash-Chat", trust_remote_code=True
384
),
385
+ "LongcatFlashNgramForCausalLM": _HfExamplesInfo(
386
+ "meituan-longcat/LongCat-Flash-Lite",
387
+ trust_remote_code=True,
388
+ # Shrink the ~62GB n-gram tables (ngram_vocab_size_ratio * vocab_size)
389
+ # so the dummy-weight init test fits in CI memory.
390
+ hf_overrides={"ngram_vocab_size_ratio": 1},
391
+ ),
392
"MambaForCausalLM": _HfExamplesInfo("state-spaces/mamba-130m-hf"),
393
"Mamba2ForCausalLM": _HfExamplesInfo(
394
"mistralai/Mamba-Codestral-7B-v0.1",
tests/models/utils.py
+7
-2
@@ -529,8 +529,13 @@ def dummy_hf_overrides(
529
}
530
)
531
532
- # Update num_hidden_layers for non-Longcat architectures
533
- if model_arch != "LongcatFlashForCausalLM" and model_arch != "LongCatFlashMTPModel":
532
+ # Update num_hidden_layers for non-Longcat architectures (Longcat derives it
533
+ # from num_layers for its dual-attention layers).
534
+ if model_arch not in (
535
+ "LongcatFlashForCausalLM",
536
+ "LongCatFlashMTPModel",
537
+ "LongcatFlashNgramForCausalLM",
538
+ ):
539
update_dict["num_hidden_layers"] = num_hidden_layers
540
541
text_config.update(update_dict)
vllm/_custom_ops.py
+31
@@ -235,6 +235,37 @@ def rms_norm(
235
torch.ops._C.rms_norm(out, input, weight, epsilon)
236
237
238
+# LongCat n-gram embedding index kernel (see csrc/.../ngram_embedding_kernels.cu).
239
+def ngram_compute_n_gram_ids(
240
+ ne_n: int,
241
+ ne_k: int,
242
+ ne_weights: torch.Tensor,
243
+ ne_mods: torch.Tensor,
244
+ exclusive_ne_embedder_size_sums: torch.Tensor,
245
+ exclusive_req_len_sums: torch.Tensor,
246
+ ne_token_table: torch.Tensor,
247
+ row_indices: torch.Tensor,
248
+ column_starts: torch.Tensor,
249
+ n_gram_ids: torch.Tensor,
250
+) -> None:
251
+ """Compute concatenated (offset) n-gram ids for a ragged prefill batch.
252
+
253
+ Writes ``n_gram_ids`` of shape ``[token_num, (ne_n-1)*ne_k]``.
254
+ """
255
+ torch.ops._C.ngram_compute_n_gram_ids(
256
+ ne_n,
257
+ ne_k,
258
+ ne_weights,
259
+ ne_mods,
260
+ exclusive_ne_embedder_size_sums,
261
+ exclusive_req_len_sums,
262
+ ne_token_table,
263
+ row_indices,
264
+ column_starts,
265
+ n_gram_ids,
266
+ )
267
+
268
+
269
def fused_add_rms_norm(
270
input: torch.Tensor,
271
residual: torch.Tensor,
vllm/config/speculative.py
+1
-1
@@ -518,7 +518,7 @@ class SpeculativeConfig:
518
"architectures": ["Qwen3_5MoeMTP" if is_moe else "Qwen3_5MTP"],
519
}
520
)
521
- if hf_config.model_type == "longcat_flash":
521
+ if hf_config.model_type in ("longcat_flash", "longcat_flash_ngram"):
522
hf_config.model_type = "longcat_flash_mtp"
523
n_predict = getattr(hf_config, "num_nextn_predict_layers", 1)
524
hf_config.update(
vllm/config/vllm.py
+1
@@ -70,6 +70,7 @@ DEFAULT_V2_MODEL_RUNNER_ARCHITECTURES = frozenset(
70
"DeepseekV2ForCausalLM",
71
"Qwen2MoeForCausalLM",
72
"GraniteMoeForCausalLM",
73
+ "LongcatFlashNgramForCausalLM",
74
}
75
)
76
vllm/model_executor/models/config.py
+19
-1
@@ -499,7 +499,7 @@ class LlamaBidirectionalConfig(VerifyAndUpdateConfig):
499
"last": "LAST",
500
}
501
502
- pooling_type = pooling_type_map.get(hf_config.pooling, None)
502
+ pooling_type = pooling_type_map.get(hf_config.pooling)
503
if pooling_type is None:
504
raise ValueError(f"pool_type {hf_config.pooling!r} not supported")
505
@@ -809,6 +809,23 @@ class VoyageQwen3BidirectionalEmbedModelConfig(VerifyAndUpdateConfig):
809
model_config.hf_config.embedding_size = model_config.hf_config.num_labels
810
811
812
+class LongcatFlashNgramForCausalLMConfig(VerifyAndUpdateConfig):
813
+ @staticmethod
814
+ def verify_and_update_config(vllm_config: "VllmConfig") -> None:
815
+ # LongCat-Flash-Lite's zero-expert MoE trips a data-dependent assert
816
+ # under torch.compile, and its n-gram inputs_embeds are only wired for
817
+ # FULL cudagraph capture (PIECEWISE prefill drops them). Default to
818
+ # no-compile + FULL cudagraph (prefill runs eager) unless the user
819
+ # configured compilation explicitly.
820
+ from vllm.config.compilation import CompilationMode, CUDAGraphMode
821
+
822
+ compilation_config = vllm_config.compilation_config
823
+ if compilation_config.mode is None:
824
+ compilation_config.mode = CompilationMode.NONE
825
+ if compilation_config.cudagraph_mode is None:
826
+ compilation_config.cudagraph_mode = CUDAGraphMode.FULL
827
+
828
+
829
MODELS_CONFIG_MAP: dict[str, type[VerifyAndUpdateConfig]] = {
830
"ColBERTJinaRobertaModel": JinaRobertaModelConfig,
831
"ColQwen3_5": ColQwen3_5Config,
@@ -822,6 +839,7 @@ MODELS_CONFIG_MAP: dict[str, type[VerifyAndUpdateConfig]] = {
839
"Gemma4ForConditionalGeneration": Gemma4Config,
840
"Gemma4UnifiedForConditionalGeneration": Gemma4Config,
841
"GptOssForCausalLM": GptOssForCausalLMConfig,
842
+ "LongcatFlashNgramForCausalLM": LongcatFlashNgramForCausalLMConfig,
843
"GteModel": SnowflakeGteNewModelConfig,
844
"GteNewForSequenceClassification": GteNewModelConfig,
845
"GteNewModel": GteNewModelConfig,
vllm/model_executor/models/longcat_flash.py
+12
-3
@@ -318,7 +318,7 @@ class LongcatMoe(nn.Module):
318
hidden_states = hidden_states.view(-1, hidden_dim)
319
320
# Align to FusedMoE padded hidden size to avoid dim mismatch
321
- padded_hidden = self.experts.hidden_size
321
+ padded_hidden = self.experts.moe_config.hidden_dim
322
if hidden_dim < padded_hidden:
323
hidden_states_padded = torch.nn.functional.pad(
324
hidden_states,
@@ -687,14 +687,23 @@ class FlashModel(nn.Module):
687
).split([self_attn.qk_nope_head_dim, self_attn.v_head_dim], dim=1)
688
self_attn.w_kc = w_kc.transpose(1, 2).contiguous().transpose(1, 2)
689
self_attn.w_vc = w_vc.contiguous().transpose(1, 2)
690
- if self.config.mla_scale_q_lora:
690
+ # Guard against compounding on incremental load_weights calls:
691
+ # the in-place ``*=`` would otherwise re-apply the MLA LoRA
692
+ # scaling to the layernorm weights on each pass.
693
+ if self.config.mla_scale_q_lora and not getattr(
694
+ self_attn, "_mla_q_lora_scaled", False
695
+ ):
696
self_attn.q_a_layernorm.weight.data *= (
697
self.config.hidden_size / self.config.q_lora_rank
698
) ** 0.5
694
- if self.config.mla_scale_kv_lora:
699
+ self_attn._mla_q_lora_scaled = True
700
+ if self.config.mla_scale_kv_lora and not getattr(
701
+ self_attn, "_mla_kv_lora_scaled", False
702
+ ):
703
self_attn.kv_a_layernorm.weight.data *= (
704
self.config.hidden_size / self.config.kv_lora_rank
705
) ** 0.5
706
+ self_attn._mla_kv_lora_scaled = True
707
return loaded_params
708
709
vllm/model_executor/models/longcat_flash_mtp.py
+14
-4
@@ -126,8 +126,10 @@ class LongCatMultiTokenPredictor(nn.Module):
126
class LongCatFlashMTP(nn.Module):
127
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
128
super().__init__()
129
- # LongCat MTP without MoE layers
130
- vllm_config.model_config.hf_config.n_routed_experts = None
129
+ # LongCat MTP has no MoE layers: clear n_routed_experts so the predictor
130
+ # builds a dense MLP. object.__setattr__ bypasses the ngram remote
131
+ # config's strict validation (it rejects setting the int field to None).
132
+ object.__setattr__(vllm_config.model_config.hf_config, "n_routed_experts", None)
133
self.config = FlashConfig(**vllm_config.model_config.hf_config.__dict__)
134
self.quant_config = (
135
None
@@ -287,14 +289,22 @@ class LongCatFlashMTP(nn.Module):
289
).split([self_attn.qk_nope_head_dim, self_attn.v_head_dim], dim=1)
290
self_attn.w_kc = w_kc.transpose(1, 2).contiguous().transpose(1, 2)
291
self_attn.w_vc = w_vc.contiguous().transpose(1, 2)
290
- if self.config.mla_scale_q_lora:
292
+ # Guard against compounding on incremental load_weights calls (the
293
+ # in-place *= would otherwise double-apply the LoRA scaling).
294
+ if self.config.mla_scale_q_lora and not getattr(
295
+ self_attn, "_mla_q_lora_scaled", False
296
+ ):
297
self_attn.q_a_layernorm.weight.data *= (
298
self.config.hidden_size / self.config.q_lora_rank
299
) ** 0.5
294
- if self.config.mla_scale_kv_lora:
300
+ self_attn._mla_q_lora_scaled = True
301
+ if self.config.mla_scale_kv_lora and not getattr(
302
+ self_attn, "_mla_kv_lora_scaled", False
303
+ ):
304
self_attn.kv_a_layernorm.weight.data *= (
305
self.config.hidden_size / self.config.kv_lora_rank
306
) ** 0.5
307
+ self_attn._mla_kv_lora_scaled = True
308
return loaded_params
309
310
def _rewrite_spec_layer_name(
vllm/model_executor/models/longcat_flash_ngram.py
new
+405
@@ -0,0 +1,405 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Inference-only LongCat-Flash-Lite (n-gram embedding) model.
4
+
5
+``LongcatFlashNgramForCausalLM`` is LongCat-Flash (MLA dual-attention +
6
+zero-expert MoE + YaRN) plus an n-gram embedding input layer: each position's
7
+embedding fuses the token embedding with hashed embeddings of the preceding
8
+``n`` tokens. That per-request token history is isolated in a Model-Runner-V2
9
+:class:`LongcatNgramModelState` (mirroring ``DiffusionGemmaModelState``), so
10
+``get_model_state_cls`` makes the model MRV2-only.
11
+"""
12
+
13
+from collections.abc import Iterable
14
+from typing import Any
15
+
16
+import torch
17
+from torch import nn
18
+
19
+from vllm import _custom_ops as ops
20
+from vllm.config import VllmConfig
21
+from vllm.distributed import get_pp_group
22
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
23
+from vllm.model_executor.layers.vocab_parallel_embedding import (
24
+ ParallelLMHead,
25
+ VocabParallelEmbedding,
26
+)
27
+from vllm.v1.core.sched.output import NewRequestData
28
+from vllm.v1.worker.gpu.input_batch import InputBatch
29
+from vllm.v1.worker.gpu.model_states.default import DefaultModelState
30
+from vllm.v1.worker.gpu.states import RequestState
31
+
32
+from .interfaces import SupportsLoRA, SupportsPP
33
+from .longcat_flash import FlashConfig, FlashModel
34
+from .utils import AutoWeightsLoader, PPMissingLayer, maybe_prefix
35
+
36
+
37
+def uses_ngram_embedding(config: FlashConfig) -> bool:
38
+ return getattr(config, "ngram_vocab_size_ratio", None) is not None
39
+
40
+
41
+def _config_dtype(config: FlashConfig) -> torch.dtype:
42
+ dt = getattr(config, "torch_dtype", None) or getattr(config, "dtype", None)
43
+ if isinstance(dt, torch.dtype):
44
+ return dt
45
+ return getattr(torch, str(dt), None) or torch.bfloat16
46
+
47
+
48
+class NgramEmbedding(nn.Module):
49
+ """Token embedding fused with hashed n-gram embeddings.
50
+
51
+ TP-sharded: the ``k*(n-1)`` per-embedder tables are concatenated into one
52
+ :class:`VocabParallelEmbedding` (``oe_embedder``) with per-embedder offsets,
53
+ and the projections are stacked into one ``oe_projection`` applied with a
54
+ single ``bmm``. Hashing math is ported from the HF reference.
55
+ """
56
+
57
+ def __init__(self, config: FlashConfig, base_embeddings: nn.Module) -> None:
58
+ super().__init__()
59
+ self.config = config
60
+ self.word_embeddings = base_embeddings
61
+
62
+ self.m = config.ngram_vocab_size_ratio * config.vocab_size
63
+ self.k = config.emb_split_num
64
+ self.n = config.emb_neighbor_num
65
+ self.pad_id = config.pad_token_id
66
+ self.eos_token_id = config.eos_token_id
67
+ self._dtype = _config_dtype(config)
68
+
69
+ self._init_ngram_embeddings()
70
+
71
+ def _init_ngram_embeddings(self) -> None:
72
+ self.num_embedders = self.k * (self.n - 1)
73
+ oe_dim = self.config.hidden_size // self.num_embedders
74
+ self.oe_dim = oe_dim
75
+
76
+ # Exclusive prefix sums of per-embedder table sizes; each embedder's
77
+ # local id is offset into the single concatenated table.
78
+ sizes = [int(self.m + i * 2 + 1) for i in range(self.num_embedders)]
79
+ offsets = [0]
80
+ for s in sizes:
81
+ offsets.append(offsets[-1] + s)
82
+ self._offsets = offsets # len num_embedders + 1
83
+ self._sizes = sizes
84
+
85
+ self.oe_embedder = VocabParallelEmbedding(
86
+ offsets[-1], oe_dim, params_dtype=self._dtype
87
+ )
88
+ # Stacked projections: oe_projection[i] = post_projs[i].weight.T
89
+ self.oe_projection = nn.Parameter(
90
+ torch.empty(
91
+ self.num_embedders, oe_dim, self.config.hidden_size, dtype=self._dtype
92
+ ),
93
+ requires_grad=False,
94
+ )
95
+
96
+ # Precomputed tables for the CUDA n-gram id kernel (ngram_embedding
97
+ # _kernels.cu): ne_weights[i][j][delta] = vocab^delta mod ne_mods[i][j],
98
+ # ne_mods[i][j] = m + 2*(i*k+j) + 1. Registered as non-persistent buffers
99
+ # so they follow the module to the device (not part of the checkpoint).
100
+ vocab = self.config.vocab_size
101
+ ne_weights = torch.zeros(self.n - 1, self.k, self.n, dtype=torch.int32)
102
+ ne_mods = torch.zeros(self.n - 1, self.k, dtype=torch.int32)
103
+ for i in range(self.n - 1):
104
+ for j in range(self.k):
105
+ mod = int(self.m + 2 * (i * self.k + j) + 1)
106
+ ne_mods[i, j] = mod
107
+ for delta in range(self.n):
108
+ ne_weights[i, j, delta] = pow(vocab, delta, mod)
109
+ self.register_buffer("ne_weights", ne_weights, persistent=False)
110
+ self.register_buffer("ne_mods", ne_mods, persistent=False)
111
+ self.register_buffer(
112
+ "exclusive_sizes",
113
+ torch.tensor(offsets, dtype=torch.int32),
114
+ persistent=False,
115
+ )
116
+
117
+ def load_weight(self, weight_name: str, loaded_weight: torch.Tensor) -> str:
118
+ """Split a per-embedder checkpoint weight into the sharded layout.
119
+
120
+ Returns the destination parameter's qualified name (relative to the
121
+ enclosing model) so the caller can mark it loaded for completeness
122
+ checks.
123
+ """
124
+ if "ngram_embeddings.embedders." in weight_name:
125
+ index = int(
126
+ weight_name.split("ngram_embeddings.embedders.")[1].split(".")[0]
127
+ )
128
+ lo, hi = self._offsets[index], self._offsets[index + 1]
129
+ assert hi - lo == loaded_weight.shape[0], (
130
+ f"{hi - lo=} {loaded_weight.shape[0]=}"
131
+ )
132
+ shard = self.oe_embedder.shard_indices
133
+ tp_start, tp_end = shard.org_vocab_start_index, shard.org_vocab_end_index
134
+ load_start, load_end = max(lo, tp_start), min(hi, tp_end)
135
+ if load_start < load_end:
136
+ self.oe_embedder.weight.data[
137
+ load_start - tp_start : load_end - tp_start
138
+ ] = loaded_weight[load_start - lo : load_end - lo]
139
+ return "ngram_embeddings.oe_embedder.weight"
140
+ elif "ngram_embeddings.post_projs." in weight_name:
141
+ index = int(
142
+ weight_name.split("ngram_embeddings.post_projs.")[1].split(".")[0]
143
+ )
144
+ self.oe_projection.data[index].copy_(loaded_weight.t())
145
+ return "ngram_embeddings.oe_projection"
146
+ else:
147
+ raise AssertionError(f"Unexpected ngram weight: {weight_name}")
148
+
149
+ def embed_batched(
150
+ self, input_ids: torch.Tensor, oe_ids: torch.Tensor
151
+ ) -> torch.Tensor:
152
+ """Fused n-gram embedding for a flat batch given precomputed ids.
153
+
154
+ Args:
155
+ input_ids: ``[num_tokens]`` current token per position.
156
+ oe_ids: ``[num_tokens, num_embedders]`` global (offset) n-gram ids,
157
+ as produced by the ``ngram_compute_n_gram_ids`` kernel.
158
+ Returns: ``[num_tokens, hidden]``.
159
+ """
160
+ word = self.word_embeddings(input_ids) # [N, H]
161
+ flat = oe_ids.permute(1, 0).contiguous() # [num_embedders, N]
162
+ oe = self.oe_embedder(flat) # [num_embedders, N, oe_dim]
163
+ proj = torch.bmm(oe, self.oe_projection) # [num_embedders, N, H]
164
+ all_h = torch.cat([word.unsqueeze(0), proj], dim=0) # [ne+1, N, H]
165
+ return all_h.mean(dim=0) # [N, H]
166
+
167
+
168
+class FlashNgramModel(FlashModel):
169
+ """FlashModel whose input embedding is an :class:`NgramEmbedding`."""
170
+
171
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
172
+ # Each FlashDecoderLayer is a *dual* layer (2 attentions), so the number
173
+ # of decoder layers is ``num_layers``. The ngram HF config sets
174
+ # ``num_hidden_layers`` to a multiple of that (attention-module count),
175
+ # which FlashModel would otherwise build as too many (dead) layers.
176
+ hf = vllm_config.model_config.hf_config
177
+ num_layers = getattr(hf, "num_layers", None)
178
+ if num_layers is not None and hf.num_hidden_layers != num_layers:
179
+ hf.num_hidden_layers = num_layers
180
+ super().__init__(vllm_config=vllm_config, prefix=prefix)
181
+ if get_pp_group().is_first_rank and uses_ngram_embedding(self.config):
182
+ self.ngram_embeddings = NgramEmbedding(self.config, self.embed_tokens)
183
+ else:
184
+ self.ngram_embeddings = None
185
+
186
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
187
+ # Names arrive with the ``model.`` prefix already stripped (routed here
188
+ # by AutoWeightsLoader). Split the concatenated/sharded ngram tables and
189
+ # stacked projections; delegate everything else to FlashModel.
190
+ loaded: set[str] = set()
191
+ rest: list[tuple[str, torch.Tensor]] = []
192
+ for name, w in weights:
193
+ if self.ngram_embeddings is not None and (
194
+ "ngram_embeddings.embedders." in name
195
+ or "ngram_embeddings.post_projs." in name
196
+ ):
197
+ loaded.add(self.ngram_embeddings.load_weight(name, w))
198
+ else:
199
+ rest.append((name, w))
200
+ loaded |= super().load_weights(rest)
201
+ return loaded
202
+
203
+
204
+class LongcatFlashNgramForCausalLM(nn.Module, SupportsLoRA, SupportsPP):
205
+ """LongCat-Flash-Lite for causal LM (MRV2-only, n-gram embedding)."""
206
+
207
+ packed_modules_mapping = {
208
+ "qkv_proj": ["q_proj", "k_proj", "v_proj"],
209
+ "gate_up_proj": ["gate_proj", "up_proj"],
210
+ }
211
+
212
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
213
+ super().__init__()
214
+ if not vllm_config.use_v2_model_runner:
215
+ raise NotImplementedError(
216
+ "LongcatFlashNgramForCausalLM (LongCat-Flash-Lite) requires the "
217
+ "V2 model runner for its n-gram embedding state; it is selected "
218
+ "automatically unless VLLM_USE_V2_MODEL_RUNNER=0 is set."
219
+ )
220
+ config = FlashConfig(**vllm_config.model_config.hf_config.__dict__)
221
+ config.intermediate_size = getattr(
222
+ config, "ffn_hidden_size", config.intermediate_size
223
+ )
224
+ self.config = config
225
+ self.quant_config = vllm_config.quant_config
226
+
227
+ self.model = FlashNgramModel(
228
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
229
+ )
230
+ if get_pp_group().is_last_rank:
231
+ self.lm_head = ParallelLMHead(
232
+ config.vocab_size,
233
+ config.hidden_size,
234
+ quant_config=self.quant_config,
235
+ prefix=maybe_prefix(prefix, "lm_head"),
236
+ )
237
+ else:
238
+ self.lm_head = PPMissingLayer()
239
+ self.logits_processor = LogitsProcessor(config.vocab_size)
240
+ self.make_empty_intermediate_tensors = (
241
+ self.model.make_empty_intermediate_tensors
242
+ )
243
+
244
+ @staticmethod
245
+ def get_model_state_cls() -> type["LongcatNgramModelState"]:
246
+ return LongcatNgramModelState
247
+
248
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
249
+ return self.model.embed_input_ids(input_ids)
250
+
251
+ def forward(
252
+ self,
253
+ input_ids: torch.Tensor | None,
254
+ positions: torch.Tensor,
255
+ intermediate_tensors=None,
256
+ inputs_embeds: torch.Tensor | None = None,
257
+ ):
258
+ # inputs_embeds is produced by LongcatNgramModelState.prepare_inputs.
259
+ return self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
260
+
261
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
262
+ return self.logits_processor(self.lm_head, hidden_states)
263
+
264
+ def get_expert_mapping(self):
265
+ return self.model.get_expert_mapping()
266
+
267
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
268
+ # AutoWeightsLoader routes ``model.*`` to FlashNgramModel.load_weights
269
+ # (which handles the ngram split) and ``lm_head.*`` to the head. MTP
270
+ # weights are not part of this model.
271
+ loader = AutoWeightsLoader(self, skip_prefixes=["model.mtp."])
272
+ return loader.load_weights(weights)
273
+
274
+
275
+class LongcatNgramModelState(DefaultModelState):
276
+ """Per-request n-gram token history for LongCat-Flash-Lite.
277
+
278
+ Maintains a small CPU-side per-slot context (last ``n-1`` processed tokens)
279
+ and a persistent ``inputs_embeds`` buffer. ``prepare_inputs`` computes the
280
+ fused n-gram embedding per request into the buffer, handed to the model
281
+ forward as ``inputs_embeds``.
282
+ """
283
+
284
+ def __init__(self, vllm_config, model, encoder_cache, device) -> None:
285
+ super().__init__(vllm_config, model, encoder_cache, device)
286
+ config = model.config
287
+ self.ngram = model.model.ngram_embeddings
288
+ self.n = int(config.emb_neighbor_num)
289
+ self.ctx_len = self.n - 1
290
+ self.eos_id = int(config.eos_token_id)
291
+
292
+ # Per-slot left-context: last ``n-1`` processed tokens, EOS negated. A
293
+ # negative entry (incl. the -1 fill) marks a context boundary that stops
294
+ # the n-gram walk (matches the kernel's EOS break / fresh-request start).
295
+ self.token_context = torch.full(
296
+ (self.max_num_reqs, self.ctx_len), -1, dtype=torch.int32, device=device
297
+ )
298
+
299
+ self._inputs_embeds_buf = torch.zeros(
300
+ self.max_num_tokens,
301
+ config.hidden_size,
302
+ dtype=self.dtype,
303
+ device=device,
304
+ )
305
+
306
+ def _neg_eos(self, toks: list[int]) -> list[int]:
307
+ return [-t if t == self.eos_id else t for t in toks]
308
+
309
+ def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
310
+ super().add_request(req_index, new_req_data) # rope positions
311
+ # Fresh request -> no left-context (-1 fill). On resume, seed from the
312
+ # already-processed token tail. Use prefill_token_ids (full processed
313
+ # sequence incl. generated tokens on v2 resume), like DefaultModelState;
314
+ # the prompt alone would be too short when resuming after decode.
315
+ ctx = [-1] * self.ctx_len
316
+ ncomp = new_req_data.num_computed_tokens
317
+ toks_src = new_req_data.prefill_token_ids or new_req_data.prompt_token_ids
318
+ if ncomp > 0 and toks_src is not None:
319
+ lo = max(0, ncomp - self.ctx_len)
320
+ toks = self._neg_eos(list(toks_src[lo:ncomp]))
321
+ ctx[self.ctx_len - len(toks) :] = toks
322
+ self.token_context[req_index] = torch.tensor(
323
+ ctx, dtype=torch.int32, device=self.token_context.device
324
+ )
325
+
326
+ def prepare_inputs(
327
+ self, input_batch: InputBatch, req_states: RequestState
328
+ ) -> dict[str, Any]:
329
+ model_inputs = super().prepare_inputs(input_batch, req_states) # positions
330
+ num_tokens = input_batch.num_tokens
331
+ num_padded = input_batch.num_tokens_after_padding
332
+ input_ids = input_batch.input_ids[:num_tokens]
333
+ embeds = self._inputs_embeds_buf[:num_padded]
334
+
335
+ oe_ids = self._compute_oe_ids(input_batch)
336
+ embeds[:num_tokens].copy_(self.ngram.embed_batched(input_ids, oe_ids))
337
+ model_inputs["inputs_embeds"] = embeds
338
+ return model_inputs
339
+
340
+ def prepare_dummy_inputs(self, num_reqs: int, num_tokens: int) -> dict[str, Any]:
341
+ # FULL cudagraph replay reads only the captured buffers, so capture must
342
+ # reference the same persistent ``inputs_embeds`` buffer prepare_inputs
343
+ # re-fills (the base class wires this for multimodal models only).
344
+ model_inputs = super().prepare_dummy_inputs(num_reqs, num_tokens) # positions
345
+ model_inputs["inputs_embeds"] = self._inputs_embeds_buf[:num_tokens]
346
+ return model_inputs
347
+
348
+ def _compute_oe_ids(self, input_batch: InputBatch) -> torch.Tensor:
349
+ """Batched global n-gram ids ``[num_tokens, num_embedders]``.
350
+
351
+ Assembles an ephemeral per-request token table (``[n-1] context ++
352
+ current tokens``, EOS-negated) and runs the ``ngram_compute_n_gram_ids``
353
+ CUDA kernel for the whole batch, then rolls each slot's context forward.
354
+ """
355
+ device = self.token_context.device
356
+ num_tokens = input_batch.num_tokens
357
+ num_reqs = input_batch.num_reqs
358
+ ctx_len = self.ctx_len
359
+ idx_mapping = input_batch.idx_mapping[:num_reqs].long()
360
+ qsl = input_batch.query_start_loc[: num_reqs + 1].to(torch.int32)
361
+ cur = input_batch.input_ids[:num_tokens].to(torch.int32)
362
+
363
+ cur_neg = torch.where(cur == self.eos_id, -cur, cur)
364
+ req_lens = qsl[1:] - qsl[:-1]
365
+ max_len = int(req_lens.max().item())
366
+ width = ctx_len + max_len
367
+
368
+ # table[r] = [context(n-1) | current tokens | pad(-1)]
369
+ table = torch.full((num_reqs, width), -1, dtype=torch.int32, device=device)
370
+ table[:, :ctx_len] = self.token_context[idx_mapping]
371
+ tok_req = torch.repeat_interleave(
372
+ torch.arange(num_reqs, device=device), req_lens.long()
373
+ )
374
+ col = ctx_len + (
375
+ torch.arange(num_tokens, device=device) - qsl[:-1].long()[tok_req]
376
+ )
377
+ table[tok_req, col] = cur_neg
378
+
379
+ column_starts = torch.full(
380
+ (num_reqs,), ctx_len, dtype=torch.int32, device=device
381
+ )
382
+ row_indices = torch.arange(num_reqs, dtype=torch.int64, device=device)
383
+ n_gram_ids = torch.empty(
384
+ num_tokens, self.ngram.num_embedders, dtype=torch.int32, device=device
385
+ )
386
+ ops.ngram_compute_n_gram_ids(
387
+ self.n,
388
+ self.ngram.k,
389
+ self.ngram.ne_weights,
390
+ self.ngram.ne_mods,
391
+ self.ngram.exclusive_sizes,
392
+ qsl,
393
+ table,
394
+ row_indices,
395
+ column_starts,
396
+ n_gram_ids,
397
+ )
398
+
399
+ # Roll context: new context = last n-1 of [context | current] per slot.
400
+ gather = req_lens.long().unsqueeze(1) + torch.arange(
401
+ ctx_len, device=device
402
+ ).unsqueeze(0)
403
+ rows = torch.arange(num_reqs, device=device).unsqueeze(1)
404
+ self.token_context[idx_mapping] = table[rows, gather]
405
+ return n_gram_ids.long()
vllm/model_executor/models/registry.py
+4
@@ -145,6 +145,10 @@ _TEXT_GENERATION_MODELS = {
145
# For decapoda-research/llama-*
146
"LLaMAForCausalLM": ("llama", "LlamaForCausalLM"),
147
"LongcatFlashForCausalLM": ("longcat_flash", "LongcatFlashForCausalLM"),
148
+ "LongcatFlashNgramForCausalLM": (
149
+ "longcat_flash_ngram",
150
+ "LongcatFlashNgramForCausalLM",
151
+ ),
152
"MambaForCausalLM": ("mamba", "MambaForCausalLM"),
153
"Mamba2ForCausalLM": ("mamba2", "Mamba2ForCausalLM"),
154
"MellumForCausalLM": ("mellum", "MellumForCausalLM"),
vllm/transformers_utils/model_arch_config_convertor.py
+1
@@ -268,6 +268,7 @@ class ModelArchConfigConvertorBase:
268
"kimi_k2",
269
"kimi_linear",
270
"longcat_flash",
271
+ "longcat_flash_ngram",
272
"pangu_ultra_moe",
273
"pangu_ultra_moe_mtp",
274
"bailing_hybrid",
vllm/v1/worker/gpu/attn_utils.py
+9
-1
@@ -540,7 +540,15 @@ def init_kv_cache(
540
shared_kv_cache_layers=shared_kv_cache_layers,
541
kv_cache_config=kv_cache_config,
542
)
543
- bind_kv_cache(kv_caches, forward_context, runner_kv_caches)
543
+ # Dual-attention models (e.g. LongCat-Flash) put two Attention modules per
544
+ # decoder layer, so a layer name carries two integers (layer + module index).
545
+ num_attn_module = (
546
+ 2
547
+ if vllm_config.model_config.hf_config.model_type
548
+ in ("longcat_flash", "longcat_flash_ngram")
549
+ else 1
550
+ )
551
+ bind_kv_cache(kv_caches, forward_context, runner_kv_caches, num_attn_module)
552
return kv_caches
553
554