[Model] Add Inkling model support [1/N] (#48799)

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai> Signed-off-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: Giancarlo Delfin <32987265+TheEpicDolphin@users.noreply.github.com> Co-authored-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: Isotr0py <mozf@inferact.ai> Co-authored-by: Jee Jee Li <jeejeelee@inferact.ai> Co-authored-by: Roger Wang <hey@rogerw.io> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Woosuk Kwon committed Jul 15, 2026 at 23:40 UTC 6570c9800cda6cc510525f485141d527f105b46c
95 files changed +12137 -62
.gitignore
+3
@@ -18,6 +18,9 @@ vllm/third_party/deep_gemm/
18 # fmha_sm100 vendored package built from source
19 vllm/third_party/fmha_sm100/
20
21 +# tml-fa4 vendored package built from source
22 +vllm/third_party/tml_fa4/
23 +
24 # triton jit
25 .triton
26
CMakeLists.txt
+1
@@ -1408,6 +1408,7 @@ if (VLLM_GPU_LANG STREQUAL "CUDA")
1408 include(cmake/external_projects/fmha_sm100.cmake)
1409 include(cmake/external_projects/flashmla.cmake)
1410 include(cmake/external_projects/qutlass.cmake)
1411 + include(cmake/external_projects/tml_fa4.cmake)
1412
1413 # vllm-flash-attn should be last as it overwrites some CMake functions
1414 include(cmake/external_projects/vllm_flash_attn.cmake)
cmake/external_projects/tml_fa4.cmake new
+50
@@ -0,0 +1,50 @@
1 +include(FetchContent)
2 +
3 +if(DEFINED ENV{TML_FA4_SRC_DIR})
4 + set(TML_FA4_SRC_DIR $ENV{TML_FA4_SRC_DIR})
5 +endif()
6 +
7 +if(TML_FA4_SRC_DIR)
8 + FetchContent_Declare(
9 + tml_fa4
10 + SOURCE_DIR ${TML_FA4_SRC_DIR}
11 + CONFIGURE_COMMAND ""
12 + BUILD_COMMAND "")
13 +else()
14 + FetchContent_Declare(
15 + tml_fa4
16 + GIT_REPOSITORY https://github.com/vllm-project/tml-fa4.git
17 + GIT_TAG 13374f0c855acc1add1bf30444bd67aebbc24a8e
18 + GIT_PROGRESS TRUE
19 + CONFIGURE_COMMAND ""
20 + BUILD_COMMAND "")
21 +endif()
22 +
23 +FetchContent_GetProperties(tml_fa4)
24 +if(NOT tml_fa4_POPULATED)
25 + FetchContent_Populate(tml_fa4)
26 +endif()
27 +message(STATUS "tml-fa4 is available at ${tml_fa4_SOURCE_DIR}")
28 +
29 +add_custom_target(tml_fa4)
30 +
31 +# Install into a private namespace so this implementation cannot shadow the
32 +# flash_attn package used by vLLM's standard attention backends.
33 +install(CODE "
34 + file(GLOB_RECURSE TML_FA4_PY_FILES
35 + \"${tml_fa4_SOURCE_DIR}/flash_attn/cute/*.py\")
36 + foreach(SRC_FILE \${TML_FA4_PY_FILES})
37 + file(RELATIVE_PATH REL_PATH
38 + \"${tml_fa4_SOURCE_DIR}/flash_attn/cute\" \${SRC_FILE})
39 + set(DST_FILE
40 + \"\${CMAKE_INSTALL_PREFIX}/vllm/third_party/tml_fa4/\${REL_PATH}\")
41 + get_filename_component(DST_DIR \${DST_FILE} DIRECTORY)
42 + file(MAKE_DIRECTORY \${DST_DIR})
43 + file(READ \${SRC_FILE} FILE_CONTENTS)
44 + string(REPLACE
45 + \"flash_attn.cute\"
46 + \"vllm.third_party.tml_fa4\"
47 + FILE_CONTENTS \"\${FILE_CONTENTS}\")
48 + file(WRITE \${DST_FILE} \"\${FILE_CONTENTS}\")
49 + endforeach()
50 +" COMPONENT tml_fa4)
requirements/cuda.txt
+1 -1
@@ -26,7 +26,7 @@ fastsafetensors >= 0.3.2
26
27 # QuACK and Cutlass DSL for FA4 (cute-DSL implementation)
28 nvidia-cutlass-dsl[cu13]==4.5.2
29 -quack-kernels>=0.3.3
29 +quack-kernels>=0.4.0 # Required for tml-fa4
30
31 # Tokenspeed_MLA for faster mla with spec decode
32 tokenspeed-mla==0.1.8; platform_system == "Linux"
rust/Cargo.lock
+2 -1
@@ -2220,7 +2220,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
2220 [[package]]
2221 name = "llm-multimodal"
2222 version = "1.7.1"
2223 -source = "git+https://github.com/smg-project/llm-multimodal?rev=7df38e53f99aefaebe86934f010aa8084ec99b2f#7df38e53f99aefaebe86934f010aa8084ec99b2f"
2223 +source = "git+https://github.com/smg-project/llm-multimodal?rev=5390032d6dc8a3e6fdc83acd320260367eb4b9b5#5390032d6dc8a3e6fdc83acd320260367eb4b9b5"
2224 dependencies = [
2225 "anyhow",
2226 "base64 0.22.1",
@@ -2235,6 +2235,7 @@ dependencies = [
2235 "once_cell",
2236 "pkg-config",
2237 "rayon",
2238 + "realfft",
2239 "reqwest 0.13.4",
2240 "rustfft",
2241 "serde",
rust/Cargo.toml
+1 -1
@@ -53,7 +53,7 @@ hyper-util = { version = "0.1.20", features = [
53 indexmap = "2.13.0"
54 itertools = "0.14.0"
55 libc = "0.2.177"
56 -llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "7df38e53f99aefaebe86934f010aa8084ec99b2f", default-features = false, features = ["native-tls"] }
56 +llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "5390032d6dc8a3e6fdc83acd320260367eb4b9b5", default-features = false, features = ["native-tls"] }
57 mimalloc = "0.1.52"
58 minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
59 minijinja-contrib = { version = "2.0", features = ["pycompat"] }
rust/src/chat/src/backend/hf.rs
+2
@@ -20,6 +20,7 @@ use crate::output::{
20 use crate::renderer::hf::{HfChatRenderer, MultimodalRenderInfo};
21 use crate::renderer::{
22 DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, HarmonyChatRenderer,
23 + InklingChatRenderer,
24 };
25 use crate::request::ChatRequest;
26 use crate::{DynChatOutputProcessor, RendererSelection};
@@ -71,6 +72,7 @@ impl HfChatBackend {
72 RendererSelection::DeepSeekV32 => Arc::new(DeepSeekV32ChatRenderer::new()),
73 RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new()),
74 RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new()?),
75 + RendererSelection::Inkling => Arc::new(InklingChatRenderer::new(tokenizer.clone())?),
76 };
77
78 info!(
rust/src/chat/src/lib.rs
+3 -3
@@ -33,7 +33,7 @@ pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
33 pub use renderer::hf::ChatTemplateContentFormatOption;
34 pub use renderer::{
35 ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer,
36 - HarmonyChatRenderer, RenderedPrompt, RendererSelection,
36 + HarmonyChatRenderer, InklingChatRenderer, RenderedPrompt, RendererSelection,
37 };
38 pub use request::{
39 ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool,
@@ -294,7 +294,7 @@ mod tests {
294 )
295 .unwrap_err();
296
297 - expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
297 + expect_test::expect!["tool parser `definitely_missing_tool_parser` is not registered (choose from: deepseek_v3, deepseek_v31, deepseek_v32, deepseek_v4, gemma4, glm45, glm47, granite4, hermes, hy_v3, inkling, internlm, kimi_k2, llama3_json, llama4_json, minimax_m2, minimax_m3, mistral, phi4_mini_json, qwen3_coder, qwen3_xml)"].assert_eq(&error.to_report_string());
298 }
299
300 #[test]
@@ -305,6 +305,6 @@ mod tests {
305 )
306 .unwrap_err();
307
308 - expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, kimi, kimi_k2, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
308 + expect_test::expect!["reasoning parser `definitely_missing_reasoning_parser` is not registered (choose from: cohere_cmd, deepseek_r1, deepseek_v3, deepseek_v4, gemma4, glm45, inkling, kimi, kimi_k2, minimax_m2, minimax_m3, nemotron_v3, qwen3, seed_oss, step3, step3p5)"].assert_eq(&error.to_report_string());
309 }
310 }
rust/src/chat/src/multimodal/audio.rs
+112
@@ -79,6 +79,38 @@ mod tests {
79 use super::*;
80
81 const AUDIO_PAD_ID: u32 = 151_676;
82 + const INKLING_AUDIO_MARKER_ID: u32 = 200_020;
83 + const INKLING_AUDIO_EMBED_ID: i32 = 200_053;
84 +
85 + fn inkling_info(decoder_dmodel: serde_json::Value) -> MultimodalModelInfo {
86 + let config = serde_json::json!({
87 + "model_type": "inkling_mm_model",
88 + "audio_config": {
89 + "decoder_dmodel": decoder_dmodel,
90 + "n_mel_bins": 80,
91 + "mel_vocab_size": 16,
92 + "dmel_min_value": -7.0,
93 + "dmel_max_value": 2.0
94 + }
95 + });
96 + let tokenizer = TestTokenizer::new()
97 + .with_regular_token("<|content_image|>", 200_005)
98 + .with_regular_token("<|content_audio_input|>", INKLING_AUDIO_MARKER_ID);
99 + let context = MultimodalModelContext {
100 + model_id: "inkling-test".to_string(),
101 + model_type: Some("inkling_mm_model".to_string()),
102 + config,
103 + tokenizer: TokenizerResolver(Arc::new(tokenizer)),
104 + };
105 +
106 + MultimodalModelInfo::from_loaded(
107 + context,
108 + PreProcessorConfig::default(),
109 + PreProcessorConfig::default(),
110 + )
111 + .unwrap()
112 + .expect("Inkling multimodal support")
113 + }
114
115 fn qwen3_asr_info() -> MultimodalModelInfo {
116 let context = MultimodalModelContext {
@@ -120,6 +152,40 @@ mod tests {
152 bytes
153 }
154
155 + #[test]
156 + fn resolves_inkling_audio_from_model_spec() {
157 + let info = inkling_info(serde_json::json!(1024));
158 + let support = info.audio.as_ref().expect("audio support");
159 +
160 + assert_eq!(
161 + info.placeholder_token(Modality::Audio),
162 + Some("<|content_audio_input|>")
163 + );
164 + assert_eq!(support.placeholder.marker_token_id, INKLING_AUDIO_MARKER_ID);
165 + assert_eq!(
166 + support.placeholder.embed_token_id,
167 + INKLING_AUDIO_EMBED_ID as u32
168 + );
169 + assert_eq!(support.spec.primary_key(), AUDIO_PRIMARY_KEY);
170 + assert!(matches!(
171 + &support.spec.field_layouts.encoder_input,
172 + llm_multimodal::FieldLayout::Flat { sizes_key }
173 + if sizes_key == "num_audio_tokens"
174 + ));
175 + assert!(matches!(
176 + support.spec.field_layouts.model_specific.get("num_audio_tokens"),
177 + Some(llm_multimodal::FieldLayout::Batched)
178 + ));
179 + }
180 +
181 + #[test]
182 + fn inkling_decoder_config_gates_audio_capability() {
183 + let info = inkling_info(serde_json::Value::Null);
184 +
185 + assert!(info.audio.is_none());
186 + assert_eq!(info.placeholder_token(Modality::Audio), None);
187 + }
188 +
189 #[test]
190 fn resolves_qwen_audio_from_model_spec() {
191 let info = qwen3_asr_info();
@@ -210,4 +276,50 @@ mod tests {
276 if tensor.dtype == "int64" && tensor.shape.is_empty()
277 ));
278 }
279 +
280 + #[tokio::test]
281 + async fn inkling_tracker_and_processor_use_standard_audio_key() {
282 + let info = inkling_info(serde_json::json!(1024));
283 + let wav = wav_i16_mono(16_000, &[0; 1_600]);
284 + let expected_hash = llm_multimodal::hasher::hash_audio(&wav);
285 + let fetched = info
286 + .fetch_media(vec![MediaContentPart::AudioData {
287 + data: wav,
288 + mime_type: Some("audio/wav".to_string()),
289 + uuid: Some("audio-1".to_string()),
290 + }])
291 + .await
292 + .unwrap();
293 +
294 + let prepared = info.prepare_audios(fetched.audios, fetched.audio_uuids).await.unwrap();
295 +
296 + assert_eq!(prepared.replacements.len(), 1);
297 + assert_eq!(
298 + prepared.replacements[0].tokens[0],
299 + INKLING_AUDIO_MARKER_ID as i32
300 + );
301 + assert!(
302 + prepared.replacements[0].tokens[1..]
303 + .iter()
304 + .all(|token| *token == INKLING_AUDIO_EMBED_ID)
305 + );
306 + let item = &prepared.items[0];
307 + assert_eq!(item.hash, expected_hash);
308 + assert_eq!(item.uuid.as_deref(), Some("audio-1"));
309 +
310 + let features = &item.data[AUDIO_PRIMARY_KEY];
311 + assert!(matches!(&features.field, MmField::Flat(_)));
312 + assert!(matches!(
313 + features.data.as_ref(),
314 + Some(MmKwargValue::Tensor(tensor))
315 + if tensor.dtype == "float32" && tensor.shape.get(1) == Some(&80)
316 + ));
317 + let count = &item.data["num_audio_tokens"];
318 + assert!(matches!(&count.field, MmField::Batched(_)));
319 + assert!(matches!(
320 + count.data.as_ref(),
321 + Some(MmKwargValue::Tensor(tensor))
322 + if tensor.dtype == "int64" && tensor.shape.is_empty()
323 + ));
324 + }
325 }
rust/src/chat/src/parser/reasoning/mod.rs
+2
@@ -23,6 +23,7 @@ pub mod names {
23 pub const DEEPSEEK_V3: &str = "deepseek_v3";
24 pub const DEEPSEEK_V4: &str = "deepseek_v4";
25 pub const GEMMA4: &str = "gemma4";
26 + pub const INKLING: &str = "inkling";
27 pub const GLM45: &str = "glm45";
28 pub const KIMI: &str = "kimi";
29 pub const KIMI_K2: &str = "kimi_k2";
@@ -63,6 +64,7 @@ impl ReasoningParserFactory {
64 .register_parser::<DeepSeekV3ReasoningParser>(names::DEEPSEEK_V3)
65 .register_parser::<DeepSeekV4ReasoningParser>(names::DEEPSEEK_V4)
66 .register_unified_dummy(names::GEMMA4)
67 + .register_unified_dummy(names::INKLING)
68 .register_parser::<Glm45ReasoningParser>(names::GLM45)
69 .register_parser::<KimiReasoningParser>(names::KIMI)
70 .register_parser::<KimiK2ReasoningParser>(names::KIMI_K2)
rust/src/chat/src/parser/tool/mod.rs
+2
@@ -25,6 +25,7 @@ pub mod names {
25 pub const GLM45: &str = "glm45";
26 pub const GLM47: &str = "glm47";
27 pub const GEMMA4: &str = "gemma4";
28 + pub const INKLING: &str = "inkling";
29 pub const GRANITE4: &str = "granite4";
30 pub const HERMES: &str = "hermes";
31 pub const HY_V3: &str = "hy_v3";
@@ -70,6 +71,7 @@ impl ToolParserFactory {
71 .register_parser::<Glm45MoeToolParser>(names::GLM45)
72 .register_parser::<Glm47MoeToolParser>(names::GLM47)
73 .register_unified_dummy(names::GEMMA4)
74 + .register_unified_dummy(names::INKLING)
75 .register_parser::<Granite4ToolParser>(names::GRANITE4)
76 .register_parser::<HermesToolParser>(names::HERMES)
77 .register_parser::<HyV3ToolParser>(names::HY_V3)
rust/src/chat/src/parser/unified.rs
+24 -2
@@ -5,7 +5,7 @@
5
6 use std::sync::LazyLock;
7
8 -pub use vllm_parser::unified::{Gemma4UnifiedParser, UnifiedParser};
8 +pub use vllm_parser::unified::{Gemma4UnifiedParser, InklingUnifiedParser, UnifiedParser};
9 use vllm_tokenizer::DynTokenizer;
10
11 use crate::parser::ParserFactory;
@@ -14,6 +14,7 @@ use crate::request::ChatTool;
14 /// Canonical public names for registered unified parsers.
15 pub mod names {
16 pub const GEMMA4: &str = "gemma4";
17 + pub const INKLING: &str = "inkling";
18 }
19
20 /// Constructor signature for one registered unified parser implementation.
@@ -37,10 +38,12 @@ impl UnifiedParserFactory {
38 let mut factory = Self::default();
39
40 factory.register_parser::<Gemma4UnifiedParser>(names::GEMMA4);
41 + factory.register_parser::<InklingUnifiedParser>(names::INKLING);
42
43 factory
44 .register_pattern("gemma-4", names::GEMMA4)
43 - .register_pattern("gemma4", names::GEMMA4);
45 + .register_pattern("gemma4", names::GEMMA4)
46 + .register_pattern("inkling", names::INKLING);
47
48 factory
49 }
@@ -88,6 +91,13 @@ mod tests {
91 .with_regular_token("<channel|>", 257)
92 }
93
94 + fn inkling_tokenizer() -> TestTokenizer {
95 + TestTokenizer::new()
96 + .with_regular_token("<|message_model|>", 200001)
97 + .with_regular_token("<|content_text|>", 200004)
98 + .with_regular_token("<|content_thinking|>", 200008)
99 + }
100 +
101 #[test]
102 fn factory_registers_gemma4() {
103 let factory = UnifiedParserFactory::new();
@@ -99,4 +109,16 @@ mod tests {
109 );
110 factory.create(names::GEMMA4, &[], Arc::new(tokenizer())).unwrap();
111 }
112 +
113 + #[test]
114 + fn factory_registers_inkling() {
115 + let factory = UnifiedParserFactory::new();
116 +
117 + assert!(factory.contains(names::INKLING));
118 + assert_eq!(
119 + factory.resolve_name_for_model("thinkingmachines/Inkling"),
120 + Some(names::INKLING)
121 + );
122 + factory.create(names::INKLING, &[], Arc::new(inkling_tokenizer())).unwrap();
123 + }
124 }
rust/src/chat/src/renderer/inkling/fixtures/text_audio_input.json new
+21
@@ -0,0 +1,21 @@
1 +{
2 + "messages": [
3 + {
4 + "role": "user",
5 + "content": [
6 + {
7 + "type": "text",
8 + "text": "transcribe"
9 + },
10 + {
11 + "type": "input_audio",
12 + "data": ""
13 + },
14 + {
15 + "type": "audio_url",
16 + "audio_url": "data:audio/wav;base64,"
17 + }
18 + ]
19 + }
20 + ]
21 +}
rust/src/chat/src/renderer/inkling/fixtures/text_audio_output.txt new
+1
@@ -0,0 +1 @@
1 +<|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_user|><|content_text|>transcribe<|end_message|><|message_user|><|content_audio_input|><|audio_end|><|end_message|><|message_user|><|content_audio_input|><|audio_end|><|end_message|><|message_model|>
rust/src/chat/src/renderer/inkling/fixtures/text_image_input.json new
+17
@@ -0,0 +1,17 @@
1 +{
2 + "messages": [
3 + {
4 + "role": "user",
5 + "content": [
6 + {
7 + "type": "text",
8 + "text": "look"
9 + },
10 + {
11 + "type": "image_url",
12 + "image_url": "data:image/png;base64,"
13 + }
14 + ]
15 + }
16 + ]
17 +}
rust/src/chat/src/renderer/inkling/fixtures/text_image_output.txt new
+1
@@ -0,0 +1 @@
1 +<|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_user|><|content_text|>look<|end_message|><|message_user|><|content_image|><|end_message|><|message_model|>
rust/src/chat/src/renderer/inkling/fixtures/tool_declare_input.json new
+46
@@ -0,0 +1,46 @@
1 +{
2 + "tools": [
3 + {
4 + "type": "function",
5 + "function": {
6 + "name": "get_weather",
7 + "description": "Get weather information",
8 + "parameters": {
9 + "type": "object",
10 + "required": [
11 + "city"
12 + ],
13 + "properties": {
14 + "city": {
15 + "type": "string"
16 + }
17 + }
18 + }
19 + }
20 + }
21 + ],
22 + "messages": [
23 + {
24 + "role": "developer",
25 + "content": "rules",
26 + "tools": [
27 + {
28 + "type": "function",
29 + "function": {
30 + "name": "local_tool",
31 + "parameters": {
32 + "z": 1,
33 + "a": {
34 + "b": 2
35 + }
36 + }
37 + }
38 + }
39 + ]
40 + },
41 + {
42 + "role": "user",
43 + "content": "hi"
44 + }
45 + ]
46 +}
rust/src/chat/src/renderer/inkling/fixtures/tool_declare_output.txt new
+1
@@ -0,0 +1 @@
1 +<|message_system|>tool_declare<|content_xml|>[{"description":"Get weather information","name":"get_weather","parameters":{"properties":{"city":{"type":"string"}},"required":["city"],"type":"object"},"type":"function"},{"description":"","name":"local_tool","parameters":{"a":{"b":2},"z":1},"type":"function"}]<|end_message|><|message_system|><|content_text|>rules<|end_message|><|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_user|><|content_text|>hi<|end_message|><|message_model|>
rust/src/chat/src/renderer/inkling/fixtures/tool_round_trip_input.json new
+24
@@ -0,0 +1,24 @@
1 +{
2 + "add_generation_prompt": false,
3 + "messages": [
4 + {
5 + "role": "assistant",
6 + "reasoning_content": "think",
7 + "content": "answer",
8 + "tool_calls": [
9 + {
10 + "id": "call_1",
11 + "function": {
12 + "name": "get_weather",
13 + "arguments": "{\"city\":\"SF\"}"
14 + }
15 + }
16 + ]
17 + },
18 + {
19 + "role": "tool",
20 + "tool_call_id": "call_1",
21 + "content": "sunny"
22 + }
23 + ]
24 +}
rust/src/chat/src/renderer/inkling/fixtures/tool_round_trip_output.txt new
+1
@@ -0,0 +1 @@
1 +<|message_system|><|content_text|>Thinking effort level: 0.9<|end_message|><|message_model|><|content_thinking|>think<|end_message|><|message_model|><|content_text|>answer<|end_message|><|message_model|>get_weather<|content_invoke_tool_json|>{"name":"get_weather","args":{"city":"SF"}}<|end_message|><|content_model_end_sampling|><|message_tool|>get_weather<|content_text|>sunny<|end_message|>
rust/src/chat/src/renderer/inkling/mod.rs new
+442
@@ -0,0 +1,442 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use std::collections::HashMap;
5 +
6 +use serde_json::{Map, Value, json};
7 +use thiserror_ext::AsReport as _;
8 +use vllm_text::Prompt;
9 +use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
10 +
11 +use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
12 +use crate::error::{Error, Result};
13 +use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool};
14 +use crate::{AssistantContentBlock, AssistantToolCall};
15 +
16 +const MESSAGE_USER: &str = "<|message_user|>";
17 +const MESSAGE_MODEL: &str = "<|message_model|>";
18 +const MESSAGE_SYSTEM: &str = "<|message_system|>";
19 +const MESSAGE_TOOL: &str = "<|message_tool|>";
20 +const CONTENT_TEXT: &str = "<|content_text|>";
21 +const CONTENT_IMAGE: &str = "<|content_image|>";
22 +const CONTENT_MODEL_END_SAMPLING: &str = "<|content_model_end_sampling|>";
23 +const CONTENT_AUDIO_INPUT: &str = "<|content_audio_input|>";
24 +const CONTENT_THINKING: &str = "<|content_thinking|>";
25 +// Inkling renderer semantics use this slot for structured payloads such as tool
26 +// declarations.
27 +const CONTENT_XML: &str = "<|content_xml|>";
28 +const CONTENT_INVOKE_TOOL_JSON: &str = "<|content_invoke_tool_json|>";
29 +const END_MESSAGE: &str = "<|end_message|>";
30 +const AUDIO_END: &str = "<|audio_end|>";
31 +const MAX_REASONING_EFFORT: f64 = 0.99;
32 +
33 +/// Native Inkling renderer that emits token IDs directly.
34 +#[derive(Clone)]
35 +pub struct InklingChatRenderer {
36 + tokenizer: DynTokenizer,
37 + special: InklingSpecialTokenIds,
38 +}
39 +
40 +#[derive(Debug, Clone, Copy)]
41 +struct InklingSpecialTokenIds {
42 + message_user: u32,
43 + message_model: u32,
44 + message_system: u32,
45 + message_tool: u32,
46 + content_text: u32,
47 + content_image: u32,
48 + content_model_end_sampling: u32,
49 + content_audio_input: u32,
50 + content_thinking: u32,
51 + content_xml: u32,
52 + content_invoke_tool_json: u32,
53 + end_message: u32,
54 + audio_end: u32,
55 +}
56 +
57 +impl InklingSpecialTokenIds {
58 + fn resolve(tokenizer: &dyn Tokenizer) -> Result<Self> {
59 + Ok(Self {
60 + message_user: resolve_special_token(tokenizer, MESSAGE_USER)?,
61 + message_model: resolve_special_token(tokenizer, MESSAGE_MODEL)?,
62 + message_system: resolve_special_token(tokenizer, MESSAGE_SYSTEM)?,
63 + message_tool: resolve_special_token(tokenizer, MESSAGE_TOOL)?,
64 + content_text: resolve_special_token(tokenizer, CONTENT_TEXT)?,
65 + content_image: resolve_special_token(tokenizer, CONTENT_IMAGE)?,
66 + content_model_end_sampling: resolve_special_token(
67 + tokenizer,
68 + CONTENT_MODEL_END_SAMPLING,
69 + )?,
70 + content_audio_input: resolve_special_token(tokenizer, CONTENT_AUDIO_INPUT)?,
71 + content_thinking: resolve_special_token(tokenizer, CONTENT_THINKING)?,
72 + content_xml: resolve_special_token(tokenizer, CONTENT_XML)?,
73 + content_invoke_tool_json: resolve_special_token(tokenizer, CONTENT_INVOKE_TOOL_JSON)?,
74 + end_message: resolve_special_token(tokenizer, END_MESSAGE)?,
75 + audio_end: resolve_special_token(tokenizer, AUDIO_END)?,
76 + })
77 + }
78 +}
79 +
80 +impl InklingChatRenderer {
81 + pub fn new(tokenizer: DynTokenizer) -> Result<Self> {
82 + let special = InklingSpecialTokenIds::resolve(tokenizer.as_ref())?;
83 + Ok(Self { tokenizer, special })
84 + }
85 +
86 + fn write_text_tokens(&self, out: &mut Vec<u32>, text: &str) -> Result<()> {
87 + out.extend(self.tokenizer.encode(text, false)?);
88 + Ok(())
89 + }
90 +
91 + fn write_message_start(
92 + &self,
93 + out: &mut Vec<u32>,
94 + role_token_id: u32,
95 + author_name: Option<&str>,
96 + ) -> Result<()> {
97 + out.push(role_token_id);
98 + if let Some(author_name) = author_name
99 + && !author_name.is_empty()
100 + {
101 + self.write_text_tokens(out, author_name)?;
102 + }
103 + Ok(())
104 + }
105 +
106 + fn write_text_block(
107 + &self,
108 + out: &mut Vec<u32>,
109 + role_token_id: u32,
110 + author_name: Option<&str>,
111 + text: &str,
112 + ) -> Result<()> {
113 + self.write_message_start(out, role_token_id, author_name)?;
114 + out.push(self.special.content_text);
115 + self.write_text_tokens(out, text)?;
116 + out.push(self.special.end_message);
117 + Ok(())
118 + }
119 +
120 + /// Write one image block holding only the `<|content_image|>` marker.
121 + /// Multimodal preprocessing later expands the marker into per-patch
122 + /// image placeholder tokens once the patch count is known, mirroring the
123 + /// Python `InklingMultiModalProcessor` marker-anchored prompt updates.
124 + fn write_image_block(&self, out: &mut Vec<u32>, role_token_id: u32) {
125 + out.push(role_token_id);
126 + out.push(self.special.content_image);
127 + out.push(self.special.end_message);
128 + }
129 +
130 + /// Write one audio block holding the `<|content_audio_input|>` marker
131 + /// followed by the `<|audio_end|>` terminator. Multimodal preprocessing
132 + /// later expands the marker into per-frame audio placeholder tokens
133 + /// (landing before `<|audio_end|>`) once the clip length is known.
134 + fn write_audio_block(&self, out: &mut Vec<u32>, role_token_id: u32) {
135 + out.push(role_token_id);
136 + out.push(self.special.content_audio_input);
137 + out.push(self.special.audio_end);
138 + out.push(self.special.end_message);
139 + }
140 +
141 + fn write_reasoning_block(&self, out: &mut Vec<u32>, text: &str) -> Result<()> {
142 + if text.is_empty() {
143 + return Ok(());
144 + }
145 + out.push(self.special.message_model);
146 + out.push(self.special.content_thinking);
147 + self.write_text_tokens(out, text)?;
148 + out.push(self.special.end_message);
149 + Ok(())
150 + }
151 +
152 + fn write_reasoning_effort(&self, out: &mut Vec<u32>, effort: f64) -> Result<()> {
153 + if !(0.0..=MAX_REASONING_EFFORT).contains(&effort) {
154 + return Err(Error::ChatTemplate(format!(
155 + "Inkling reasoning_effort must be in [0.0, 0.99], got {effort}"
156 + )));
157 + }
158 + let formatted = format!("{effort:.2}");
159 + let effort = formatted.trim_end_matches('0').trim_end_matches('.');
160 + let effort = if matches!(effort, "0" | "-0") {
161 + "0.0"
162 + } else {
163 + effort
164 + };
165 + self.write_text_block(
166 + out,
167 + self.special.message_system,
168 + None,
169 + &format!("Thinking effort level: {effort}"),
170 + )
171 + }
172 +
173 + fn write_tool_declarations(&self, out: &mut Vec<u32>, tools: &[&ChatTool]) -> Result<()> {
174 + if tools.is_empty() {
175 + return Ok(());
176 + }
177 +
178 + let mut specs = Vec::with_capacity(tools.len());
179 + for tool in tools {
180 + specs.push(json!({
181 + "description": tool.description.as_deref().unwrap_or(""),
182 + "name": tool.name,
183 + "parameters": sort_json(&tool.parameters),
184 + "type": "function",
185 + }));
186 + }
187 + let payload = compact_json(&sort_json(&Value::Array(specs)))?;
188 +
189 + self.write_message_start(out, self.special.message_system, Some("tool_declare"))?;
190 + out.push(self.special.content_xml);
191 + self.write_text_tokens(out, &payload)?;
192 + out.push(self.special.end_message);
193 + Ok(())
194 + }
195 +
196 + fn write_chat_content(
197 + &self,
198 + out: &mut Vec<u32>,
199 + role_token_id: u32,
200 + content: &ChatContent,
201 + ) -> Result<()> {
202 + match content {
203 + ChatContent::Text(text) => {
204 + if !text.is_empty() {
205 + self.write_text_block(out, role_token_id, None, text)?;
206 + }
207 + }
208 + ChatContent::Parts(parts) => {
209 + for part in parts {
210 + match part {
211 + ChatContentPart::Text { text } => {
212 + self.write_text_block(out, role_token_id, None, text)?;
213 + }
214 + ChatContentPart::ImageUrl { .. } => {
215 + self.write_image_block(out, role_token_id);
216 + }
217 + ChatContentPart::InputAudio { .. } | ChatContentPart::AudioUrl { .. } => {
218 + self.write_audio_block(out, role_token_id);
219 + }
220 + // Inkling has no video modality.
221 + ChatContentPart::VideoUrl { .. } => {
222 + return Err(Error::UnsupportedMultimodalContent("video_url"));
223 + }
224 + }
225 + }
226 + }
227 + }
228 + Ok(())
229 + }
230 +
231 + fn write_assistant_tool_call(
232 + &self,
233 + out: &mut Vec<u32>,
234 + tool_call: &AssistantToolCall,
235 + ) -> Result<()> {
236 + let payload = tool_call_json(tool_call)?;
237 +
238 + self.write_message_start(out, self.special.message_model, Some(&tool_call.name))?;
239 + out.push(self.special.content_invoke_tool_json);
240 + self.write_text_tokens(out, &payload)?;
241 + out.push(self.special.end_message);
242 + Ok(())
243 + }
244 +
245 + fn write_assistant_content(
246 + &self,
247 + out: &mut Vec<u32>,
248 + content: &[AssistantContentBlock],
249 + tool_call_id_to_name: &mut HashMap<String, String>,
250 + ) -> Result<()> {
251 + for block in content {
252 + match block {
253 + AssistantContentBlock::Reasoning { text } => {
254 + self.write_reasoning_block(out, text)?;
255 + }
256 + AssistantContentBlock::Text { text } => {
257 + if !text.is_empty() {
258 + self.write_text_block(out, self.special.message_model, None, text)?;
259 + }
260 + }
261 + AssistantContentBlock::ToolCall(tool_call) => {
262 + if !tool_call.id.is_empty() {
263 + tool_call_id_to_name.insert(tool_call.id.clone(), tool_call.name.clone());
264 + }
265 + self.write_assistant_tool_call(out, tool_call)?;
266 + }
267 + }
268 + }
269 + out.push(self.special.content_model_end_sampling);
270 + Ok(())
271 + }
272 +
273 + fn write_tool_response(
274 + &self,
275 + out: &mut Vec<u32>,
276 + content: &ChatContent,
277 + tool_call_id: &str,
278 + tool_call_id_to_name: &HashMap<String, String>,
279 + ) -> Result<()> {
280 + let text = content.try_flatten_to_text()?;
281 + let tool_name = tool_call_id_to_name.get(tool_call_id).map(String::as_str).unwrap_or("");
282 + self.write_text_block(out, self.special.message_tool, Some(tool_name), &text)
283 + }
284 +}
285 +
286 +impl ChatRenderer for InklingChatRenderer {
287 + fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
288 + request.validate()?;
289 + if request.chat_options.continue_final_message() {
290 + return Err(Error::ChatTemplate(
291 + "Inkling renderer does not support continue_final_message".to_string(),
292 + ));
293 + }
294 +
295 + let mut out = Vec::new();
296 + let mut tool_call_id_to_name = HashMap::new();
297 + let effective_template_kwargs = request_template_kwargs(request);
298 + let tools = rendered_tools(request);
299 + self.write_tool_declarations(&mut out, &tools)?;
300 + let mut reasoning_effort =
301 + resolve_reasoning_effort(effective_template_kwargs.get("reasoning_effort"));
302 +
303 + for message in &request.messages {
304 + if !matches!(
305 + message,
306 + ChatMessage::System { .. } | ChatMessage::Developer { .. }
307 + ) && let Some(effort) = reasoning_effort.take()
308 + {
309 + self.write_reasoning_effort(&mut out, effort)?;
310 + }
311 +
312 + match message {
313 + ChatMessage::System { content } => {
314 + self.write_chat_content(&mut out, self.special.message_system, content)?;
315 + }
316 + ChatMessage::Developer { content, .. } => {
317 + self.write_chat_content(&mut out, self.special.message_system, content)?;
318 + }
319 + ChatMessage::User { content } => {
320 + self.write_chat_content(&mut out, self.special.message_user, content)?;
321 + }
322 + ChatMessage::Assistant { content } => {
323 + self.write_assistant_content(&mut out, content, &mut tool_call_id_to_name)?;
324 + }
325 + ChatMessage::ToolResponse {
326 + content,
327 + tool_call_id,
328 + } => {
329 + self.write_tool_response(
330 + &mut out,
331 + content,
332 + tool_call_id,
333 + &tool_call_id_to_name,
334 + )?;
335 + }
336 + }
337 + }
338 +
339 + if let Some(effort) = reasoning_effort {
340 + self.write_reasoning_effort(&mut out, effort)?;
341 + }
342 +
343 + if request.chat_options.add_generation_prompt() {
344 + out.push(self.special.message_model);
345 + }
346 +
347 + Ok(RenderedPrompt {
348 + prompt: Prompt::TokenIds(out),
349 + effective_template_kwargs,
350 + })
351 + }
352 +}
353 +
354 +fn resolve_reasoning_effort(value: Option<&Value>) -> Option<f64> {
355 + let Some(value) = value else {
356 + return Some(0.9);
357 + };
358 + match value {
359 + Value::String(name) => match name.as_str() {
360 + "none" => Some(0.0),
361 + "minimal" => Some(0.1),
362 + "low" => Some(0.2),
363 + "medium" => Some(0.7),
364 + "high" => Some(0.9),
365 + "xhigh" | "max" => Some(0.99),
366 + _ => None,
367 + },
368 + Value::Number(number) => number.as_f64(),
369 + _ => None,
370 + }
371 +}
372 +
373 +fn resolve_special_token(tokenizer: &dyn Tokenizer, token: &str) -> Result<u32> {
374 + tokenizer.token_to_id(token).ok_or_else(|| {
375 + Error::ChatTemplate(format!(
376 + "Inkling tokenizer is missing special token `{token}`"
377 + ))
378 + })
379 +}
380 +
381 +fn rendered_tools(request: &ChatRequest) -> Vec<&ChatTool> {
382 + if !request.tool_parsing_enabled() {
383 + return Vec::new();
384 + }
385 +
386 + let mut tools = Vec::with_capacity(request.tools.len());
387 + tools.extend(request.tools.iter());
388 + for message in &request.messages {
389 + if let ChatMessage::Developer {
390 + tools: Some(local_tools),
391 + ..
392 + } = message
393 + {
394 + tools.extend(local_tools.iter());
395 + }
396 + }
397 + tools
398 +}
399 +
400 +fn tool_call_json(tool_call: &AssistantToolCall) -> Result<String> {
401 + let name_json = serde_json::to_string(&tool_call.name)
402 + .map_err(|error| Error::ChatTemplate(error.as_report().to_string()))?;
403 + let arguments = if tool_call.arguments.trim().is_empty() {
404 + Value::Object(Map::new())
405 + } else {
406 + serde_json::from_str(&tool_call.arguments).map_err(|error| {
407 + Error::ChatTemplate(format!(
408 + "Inkling tool call arguments must decode to a JSON object: {error}"
409 + ))
410 + })?
411 + };
412 + let Value::Object(_) = arguments else {
413 + return Err(Error::ChatTemplate(
414 + "Inkling tool call arguments must decode to a JSON object".to_string(),
415 + ));
416 + };
417 + let args_json = compact_json(&sort_json(&arguments))?;
418 + Ok(format!("{{\"name\":{name_json},\"args\":{args_json}}}"))
419 +}
420 +
421 +fn compact_json(value: &Value) -> Result<String> {
422 + serde_json::to_string(value).map_err(|error| Error::ChatTemplate(error.as_report().to_string()))
423 +}
424 +
425 +fn sort_json(value: &Value) -> Value {
426 + match value {
427 + Value::Array(items) => Value::Array(items.iter().map(sort_json).collect()),
428 + Value::Object(map) => {
429 + let mut sorted = Map::new();
430 + let mut keys = map.keys().collect::<Vec<_>>();
431 + keys.sort();
432 + for key in keys {
433 + sorted.insert(key.clone(), sort_json(&map[key]));
434 + }
435 + Value::Object(sorted)
436 + }
437 + _ => value.clone(),
438 + }
439 +}
440 +
441 +#[cfg(test)]
442 +mod tests;
rust/src/chat/src/renderer/inkling/tests.rs new
+375
@@ -0,0 +1,375 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use std::path::PathBuf;
5 +use std::sync::Arc;
6 +
7 +use expect_test::{ExpectFile, expect_file};
8 +use serde_json::json;
9 +use thiserror_ext::AsReport;
10 +use vllm_text::tokenizer::Tokenizer;
11 +
12 +use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
13 +
14 +use super::{
15 + AUDIO_END, CONTENT_AUDIO_INPUT, CONTENT_IMAGE, CONTENT_INVOKE_TOOL_JSON, CONTENT_TEXT,
16 + CONTENT_THINKING, CONTENT_XML, END_MESSAGE, InklingChatRenderer, MESSAGE_MODEL, MESSAGE_SYSTEM,
17 + MESSAGE_TOOL, MESSAGE_USER,
18 +};
19 +use crate::event::{AssistantContentBlock, AssistantToolCall};
20 +use crate::request::{ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort};
21 +use crate::{ChatRenderer, Error};
22 +
23 +struct FixtureTokenizer;
24 +
25 +impl Tokenizer for FixtureTokenizer {
26 + fn encode(
27 + &self,
28 + text: &str,
29 + _add_special_tokens: bool,
30 + ) -> vllm_text::tokenizer::Result<Vec<u32>> {
31 + Ok(text.bytes().map(u32::from).collect())
32 + }
33 +
34 + fn decode(
35 + &self,
36 + token_ids: &[u32],
37 + _skip_special_tokens: bool,
38 + ) -> vllm_text::tokenizer::Result<String> {
39 + Ok(token_ids
40 + .iter()
41 + .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
42 + .collect())
43 + }
44 +
45 + fn token_to_id(&self, token: &str) -> Option<u32> {
46 + match token {
47 + "<|message_user|>" => Some(200000),
48 + "<|message_model|>" => Some(200001),
49 + "<|message_system|>" => Some(200002),
50 + "<|message_tool|>" => Some(200003),
51 + "<|content_text|>" => Some(200004),
52 + "<|content_image|>" => Some(200005),
53 + "<|content_model_end_sampling|>" => Some(200006),
54 + "<|content_thinking|>" => Some(200008),
55 + "<|end_message|>" => Some(200010),
56 + "<|content_audio_input|>" => Some(200020),
57 + CONTENT_XML => Some(200024),
58 + "<|audio_end|>" => Some(200043),
59 + "<|content_invoke_tool_json|>" => Some(200049),
60 + _ => None,
61 + }
62 + }
63 +
64 + fn id_to_token(&self, id: u32) -> Option<String> {
65 + let token = match id {
66 + 200000 => "<|message_user|>",
67 + 200001 => "<|message_model|>",
68 + 200002 => "<|message_system|>",
69 + 200003 => "<|message_tool|>",
70 + 200004 => "<|content_text|>",
71 + 200005 => "<|content_image|>",
72 + 200006 => "<|content_model_end_sampling|>",
73 + 200008 => "<|content_thinking|>",
74 + 200010 => "<|end_message|>",
75 + 200020 => "<|content_audio_input|>",
76 + 200024 => CONTENT_XML,
77 + 200043 => "<|audio_end|>",
78 + 200049 => "<|content_invoke_tool_json|>",
79 + _ => return None,
80 + };
81 + Some(token.to_string())
82 + }
83 +}
84 +
85 +fn renderer() -> InklingChatRenderer {
86 + InklingChatRenderer::new(Arc::new(FixtureTokenizer)).unwrap()
87 +}
88 +
89 +fn render_token_ids(request: &ChatRequest) -> Vec<u32> {
90 + renderer()
91 + .render(request)
92 + .unwrap()
93 + .prompt
94 + .into_token_ids()
95 + .expect("Inkling renderer returns token IDs")
96 +}
97 +
98 +fn fixture_request(name: &str) -> ChatRequest {
99 + fixture_chat_request(&fixture_path(name), inkling_fixture_options())
100 +}
101 +
102 +fn inkling_fixture_options() -> FixtureRequestOptions {
103 + FixtureRequestOptions {
104 + enable_thinking: false,
105 + no_generation_prompt_when_last_assistant: false,
106 + }
107 +}
108 +
109 +fn fixture_path(name: &str) -> PathBuf {
110 + PathBuf::from(env!("CARGO_MANIFEST_DIR"))
111 + .join("src/renderer/inkling")
112 + .join("fixtures")
113 + .join(name)
114 +}
115 +
116 +fn assert_fixture(input_name: &str, expected: ExpectFile) {
117 + let request = fixture_request(input_name);
118 + let rendered = format!("{}\n", render_symbolic_tokens(&render_token_ids(&request)));
119 +
120 + expected.assert_eq(&rendered);
121 +}
122 +
123 +fn render_symbolic_tokens(token_ids: &[u32]) -> String {
124 + let mut out = String::new();
125 + let mut text_bytes = Vec::new();
126 + for token_id in token_ids {
127 + if let Some(marker) = symbolic_tokens()
128 + .iter()
129 + .find_map(|(marker, marker_id)| (token_id == marker_id).then_some(*marker))
130 + {
131 + flush_text_bytes(&mut out, &mut text_bytes);
132 + out.push_str(marker);
133 + continue;
134 + }
135 +
136 + let byte = u8::try_from(*token_id)
137 + .unwrap_or_else(|_| panic!("unexpected non-byte fixture text token {token_id}"));
138 + text_bytes.push(byte);
139 + }
140 + flush_text_bytes(&mut out, &mut text_bytes);
141 + out
142 +}
143 +
144 +fn flush_text_bytes(out: &mut String, text_bytes: &mut Vec<u8>) {
145 + if text_bytes.is_empty() {
146 + return;
147 + }
148 + out.push_str(std::str::from_utf8(text_bytes).unwrap());
149 + text_bytes.clear();
150 +}
151 +
152 +fn symbolic_tokens() -> [(&'static str, u32); 13] {
153 + [
154 + (MESSAGE_USER, 200000),
155 + (MESSAGE_MODEL, 200001),
156 + (MESSAGE_SYSTEM, 200002),
157 + (MESSAGE_TOOL, 200003),
158 + (CONTENT_TEXT, 200004),
159 + (CONTENT_IMAGE, 200005),
160 + (super::CONTENT_MODEL_END_SAMPLING, 200006),
161 + (CONTENT_THINKING, 200008),
162 + (END_MESSAGE, 200010),
163 + (CONTENT_AUDIO_INPUT, 200020),
164 + (CONTENT_XML, 200024),
165 + (AUDIO_END, 200043),
166 + (CONTENT_INVOKE_TOOL_JSON, 200049),
167 + ]
168 +}
169 +
170 +#[test]
171 +fn renders_text_and_image_as_inkling_tokens() {
172 + assert_fixture(
173 + "text_image_input.json",
174 + expect_file!["fixtures/text_image_output.txt"],
175 + );
176 +}
177 +
178 +#[test]
179 +fn renders_audio_marker_only_blocks() {
180 + assert_fixture(
181 + "text_audio_input.json",
182 + expect_file!["fixtures/text_audio_output.txt"],
183 + );
184 +}
185 +
186 +#[test]
187 +fn renders_reasoning_text_tool_call_and_tool_response() {
188 + assert_fixture(
189 + "tool_round_trip_input.json",
190 + expect_file!["fixtures/tool_round_trip_output.txt"],
191 + );
192 +}
193 +
194 +#[test]
195 +fn renders_request_and_developer_tools_as_tool_declare() {
196 + assert_fixture(
197 + "tool_declare_input.json",
198 + expect_file!["fixtures/tool_declare_output.txt"],
199 + );
200 +}
201 +
202 +#[test]
203 +fn renders_named_reasoning_effort_after_tool_declarations() {
204 + for (effort, expected) in [
205 + (ReasoningEffort::None, "0.0"),
206 + (ReasoningEffort::Minimal, "0.1"),
207 + (ReasoningEffort::Low, "0.2"),
208 + (ReasoningEffort::Medium, "0.7"),
209 + (ReasoningEffort::High, "0.9"),
210 + (ReasoningEffort::XHigh, "0.99"),
211 + (ReasoningEffort::Max, "0.99"),
212 + ] {
213 + let mut request = fixture_request("tool_declare_input.json");
214 + request.chat_options.reasoning_effort = Some(effort);
215 +
216 + let rendered = render_symbolic_tokens(&render_token_ids(&request));
217 + let tool_end = rendered.find("<|end_message|>").unwrap();
218 + let effort_block = format!(
219 + "<|message_system|><|content_text|>Thinking effort level: \
220 + {expected}<|end_message|>"
221 + );
222 + let effort_start = rendered.find(&effort_block).unwrap();
223 + let system_end = rendered.find("rules<|end_message|>").unwrap();
224 +
225 + assert!(effort_start > tool_end);
226 + assert!(effort_start > system_end);
227 + }
228 +}
229 +
230 +#[test]
231 +fn emits_one_reasoning_effort_for_multi_turn_conversation() {
232 + let request = ChatRequest {
233 + messages: vec![
234 + ChatMessage::system("rules"),
235 + ChatMessage::user("user1"),
236 + ChatMessage::assistant_text("assistant1"),
237 + ChatMessage::user("user2"),
238 + ],
239 + ..ChatRequest::for_test()
240 + };
241 +
242 + let rendered = render_symbolic_tokens(&render_token_ids(&request));
243 + let effort = "<|message_system|><|content_text|>Thinking effort level: 0.9\
244 + <|end_message|>";
245 +
246 + assert_eq!(rendered.matches(effort).count(), 1);
247 + assert!(rendered.find("rules").unwrap() < rendered.find(effort).unwrap());
248 + assert!(rendered.find(effort).unwrap() < rendered.find("user1").unwrap());
249 + assert!(rendered.find("user1").unwrap() < rendered.find("assistant1").unwrap());
250 + assert!(rendered.find("assistant1").unwrap() < rendered.find("user2").unwrap());
251 +}
252 +
253 +#[test]
254 +fn canonicalizes_numeric_zero_reasoning_effort() {
255 + for value in [0.0, -0.0] {
256 + let mut request = ChatRequest::for_test();
257 + request
258 + .chat_options
259 + .template_kwargs
260 + .insert("reasoning_effort".to_string(), json!(value));
261 +
262 + assert!(
263 + render_symbolic_tokens(&render_token_ids(&request)).starts_with(
264 + "<|message_system|><|content_text|>Thinking effort level: 0.0\
265 + <|end_message|>"
266 + )
267 + );
268 + }
269 +}
270 +
271 +#[test]
272 +fn defaults_reasoning_effort_to_high() {
273 + let request = ChatRequest::for_test();
274 +
275 + assert!(
276 + render_symbolic_tokens(&render_token_ids(&request)).starts_with(
277 + "<|message_system|><|content_text|>Thinking effort level: 0.9\
278 + <|end_message|>"
279 + )
280 + );
281 +}
282 +
283 +#[test]
284 +fn renders_numeric_reasoning_effort_template_kwarg() {
285 + let mut request = ChatRequest::for_test();
286 + request
287 + .chat_options
288 + .template_kwargs
289 + .insert("reasoning_effort".to_string(), json!(0.8));
290 +
291 + assert_eq!(
292 + render_symbolic_tokens(&render_token_ids(&request)),
293 + "<|message_system|><|content_text|>Thinking effort level: 0.8\
294 + <|end_message|><|message_user|><|content_text|>test\
295 + <|end_message|><|message_model|>"
296 + );
297 +}
298 +
299 +#[test]
300 +fn ignores_unsupported_reasoning_effort_values() {
301 + for value in [json!(true), json!("invalid"), json!(null)] {
302 + let mut request = ChatRequest::for_test();
303 + request
304 + .chat_options
305 + .template_kwargs
306 + .insert("reasoning_effort".to_string(), value);
307 +
308 + assert_eq!(
309 + render_symbolic_tokens(&render_token_ids(&request)),
310 + "<|message_user|><|content_text|>test<|end_message|><|message_model|>"
311 + );
312 + }
313 +}
314 +
315 +#[test]
316 +fn rejects_out_of_range_reasoning_effort() {
317 + for value in [0.990_000_1, 1.0, 1.5, -0.1] {
318 + let mut request = ChatRequest::for_test();
319 + request
320 + .chat_options
321 + .template_kwargs
322 + .insert("reasoning_effort".to_string(), json!(value));
323 +
324 + let error = renderer().render(&request).unwrap_err();
325 + assert!(error.as_report().to_string().contains("must be in [0.0, 0.99]"));
326 + }
327 +}
328 +
329 +#[test]
330 +fn rejects_continue_final_message() {
331 + let request = ChatRequest {
332 + messages: vec![ChatMessage::assistant_text("partial")],
333 + chat_options: crate::ChatOptions {
334 + generation_prompt_mode: GenerationPromptMode::ContinueFinalAssistant,
335 + ..Default::default()
336 + },
337 + ..ChatRequest::for_test()
338 + };
339 +
340 + let error = renderer().render(&request).unwrap_err();
341 + assert!(matches!(error, Error::ChatTemplate(_)));
342 + assert!(error.as_report().to_string().contains("continue_final_message"));
343 +}
344 +
345 +#[test]
346 +fn renders_developer_messages_as_system() {
347 + let request = ChatRequest {
348 + messages: vec![ChatMessage::developer("rules", None)],
349 + ..ChatRequest::for_test()
350 + };
351 +
352 + assert_eq!(
353 + render_symbolic_tokens(&render_token_ids(&request)),
354 + "<|message_system|><|content_text|>rules<|end_message|>\
355 + <|message_system|><|content_text|>Thinking effort level: 0.9\
356 + <|end_message|><|message_model|>"
357 + );
358 +}
359 +
360 +#[test]
361 +fn rejects_non_object_tool_call_arguments() {
362 + let request = ChatRequest {
363 + messages: vec![ChatMessage::assistant_blocks(vec![
364 + AssistantContentBlock::ToolCall(AssistantToolCall {
365 + id: "call_1".to_string(),
366 + name: "get_weather".to_string(),
367 + arguments: "[]".to_string(),
368 + }),
369 + ])],
370 + ..ChatRequest::for_test()
371 + };
372 +
373 + let error = renderer().render(&request).unwrap_err();
374 + assert!(error.as_report().to_string().contains("JSON object"));
375 +}
rust/src/chat/src/renderer/mod.rs
+2
@@ -14,6 +14,7 @@ pub mod deepseek_v32;
14 pub mod deepseek_v4;
15 pub mod harmony;
16 pub mod hf;
17 +mod inkling;
18 mod selection;
19 #[cfg(test)]
20 mod test_utils;
@@ -21,6 +22,7 @@ mod test_utils;
22 pub use deepseek_v4::DeepSeekV4ChatRenderer;
23 pub use deepseek_v32::DeepSeekV32ChatRenderer;
24 pub use harmony::HarmonyChatRenderer;
25 +pub use inkling::InklingChatRenderer;
26 pub use selection::RendererSelection;
27
28 /// Rendered chat prompt submitted to the text backend.
rust/src/chat/src/renderer/selection.rs
+9 -1
@@ -24,6 +24,8 @@ pub enum RendererSelection {
24 DeepSeekV4,
25 /// Force the GPT-OSS Harmony renderer.
26 Harmony,
27 + /// Force the Inkling native token renderer.
28 + Inkling,
29 }
30
31 impl RendererSelection {
@@ -33,6 +35,8 @@ impl RendererSelection {
35 pub const GPT_OSS_MODEL_TYPE: &str = "gpt_oss";
36 pub const HARMONY_LITERAL: &str = "harmony";
37 pub const HF_LITERAL: &str = "hf";
38 + pub const INKLING_LITERAL: &str = "inkling";
39 + pub const INKLING_MODEL_TYPE: &str = "inkling_mm_model";
40
41 /// Resolve the renderer selection using the given model type string, if
42 /// it's `Auto`.
@@ -42,6 +46,7 @@ impl RendererSelection {
46 Self::DEEPSEEK_V32_LITERAL => Self::DeepSeekV32,
47 Self::DEEPSEEK_V4_LITERAL => Self::DeepSeekV4,
48 Self::GPT_OSS_MODEL_TYPE => Self::Harmony,
49 + Self::INKLING_MODEL_TYPE => Self::Inkling,
50 _ => Self::Hf,
51 },
52 selection => selection,
@@ -63,6 +68,8 @@ impl FromStr for RendererSelection {
68 Ok(Self::DeepSeekV4)
69 } else if value.eq_ignore_ascii_case(Self::HARMONY_LITERAL) {
70 Ok(Self::Harmony)
71 + } else if value.eq_ignore_ascii_case(Self::INKLING_LITERAL) {
72 + Ok(Self::Inkling)
73 } else {
74 Err(format!(
75 "unknown renderer `{value}` (expected one of: {})",
@@ -80,6 +87,7 @@ impl fmt::Display for RendererSelection {
87 Self::DeepSeekV32 => f.write_str(Self::DEEPSEEK_V32_LITERAL),
88 Self::DeepSeekV4 => f.write_str(Self::DEEPSEEK_V4_LITERAL),
89 Self::Harmony => f.write_str(Self::HARMONY_LITERAL),
90 + Self::Inkling => f.write_str(Self::INKLING_LITERAL),
91 }
92 }
93 }
@@ -106,7 +114,7 @@ mod tests {
114 fn renderer_selection_expected_error_message() {
115 let err = RendererSelection::from_str("unknown").unwrap_err();
116 expect_test::expect![
109 - "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony)"
117 + "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling)"
118 ]
119 .assert_eq(&err);
120 }
rust/src/chat/src/renderer/test_utils.rs
+11
@@ -103,6 +103,8 @@ pub(crate) enum FixtureContent {
103 pub(crate) enum FixtureContentPart {
104 Text { text: String },
105 ImageUrl { image_url: String },
106 + InputAudio { data: String },
107 + AudioUrl { audio_url: String },
108 }
109
110 #[derive(Debug, Deserialize)]
@@ -226,6 +228,15 @@ fn to_chat_content(content: FixtureContent) -> ChatContent {
228 FixtureContentPart::ImageUrl { image_url } => {
229 ChatContentPart::image_url(image_url)
230 }
231 + FixtureContentPart::InputAudio { data } => ChatContentPart::InputAudio {
232 + data,
233 + format: None,
234 + uuid: None,
235 + },
236 + FixtureContentPart::AudioUrl { audio_url } => ChatContentPart::AudioUrl {
237 + audio_url,
238 + uuid: None,
239 + },
240 })
241 .collect(),
242 ),
rust/src/chat/tests/roundtrip.rs
+16 -2
@@ -263,19 +263,32 @@ impl RoundtripCase {
263 sort_json_keys: false,
264 }
265 }
266 +
267 + /// Inkling typed content blocks with native token-id rendering.
268 + fn inkling() -> Self {
269 + Self {
270 + model_id: "thinkingmachines/Inkling",
271 + assistant_stop_suffix: "",
272 + tool_call_parser: ParserSelection::Auto,
273 + reasoning_parser: ParserSelection::Auto,
274 + thinking_behavior: ThinkingBehavior::Always { value: true },
275 + json_fmt: compact_json_fmt(),
276 + sort_json_keys: true,
277 + }
278 + }
279 }
280
281 macro_rules! roundtrip_tests {
269 - ($($case:ident => [$($(#[$fixture_attr:meta])* $fixture:ident),* $(,)?]),+ $(,)?) => {
282 + ($($case:ident => $(#[$case_attr:meta])* [$($fixture:ident),* $(,)?]),+ $(,)?) => {
283 paste::paste! {
284 $(
285 #[tokio::test]
286 #[file_serial([<hf_ $case>])]
287 + $(#[$case_attr])*
288 async fn [<roundtrip_ $case>]() -> Result<()> {
289 let case = RoundtripCase::$case();
290 let backends = load_roundtrip_backends(&case).await?;
291 $(
278 - $(#[$fixture_attr])*
292 [<run_roundtrip_ $fixture>](&case, &backends).await?;
293 )*
294 Ok(())
@@ -300,6 +313,7 @@ roundtrip_tests! {
313 gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
314 kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
315 gpt_oss => [tool_call_mix], // Harmony strips reasoning in history if there's no tool call
316 + inkling => [reasoning_and_content, tool_call_mix],
317 }
318
319 /// Run the fixed reasoning+content fixture for one model/parser case.
rust/src/cmd/src/cli/tests.rs
+1 -1
@@ -646,7 +646,7 @@ fn serve_args_reject_unknown_renderer_value() {
646 .unwrap_err();
647
648 expect![[r#"
649 - error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony)
649 + error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony, inkling)
650
651 For more information, try '--help'.
652 "#]]
rust/src/parser/src/tool/json/mod.rs
+12 -12
@@ -31,24 +31,24 @@ use super::utils::{
31 };
32 use super::{Result, ToolCallDelta, ToolParserOutput};
33
34 -type JsonToolInput<'i> = Partial<&'i str>;
34 +pub(crate) type JsonToolInput<'i> = Partial<&'i str>;
35
36 #[derive(Debug, Clone, Copy)]
37 -struct JsonToolCallConfig {
38 - parser_name: &'static str,
39 - start_marker: &'static str,
40 - end_marker: &'static str,
41 - marker_whitespace: JsonToolCallWhitespace,
42 - delimiter: Option<&'static str>,
43 - name_key: &'static str,
37 +pub(crate) struct JsonToolCallConfig {
38 + pub parser_name: &'static str,
39 + pub start_marker: &'static str,
40 + pub end_marker: &'static str,
41 + pub marker_whitespace: JsonToolCallWhitespace,
42 + pub delimiter: Option<&'static str>,
43 + pub name_key: &'static str,
44 /// Candidate JSON keys naming the arguments payload, tried in order.
45 /// Most parsers use a single key like `["arguments"]`, but some accept
46 /// multiple (e.g. InternLM2 accepts `parameters` or `arguments`).
47 - arguments_key: &'static [&'static str],
47 + pub arguments_key: &'static [&'static str],
48 }
49
50 #[derive(Debug, Clone, Copy)]
51 -enum JsonToolCallWhitespace {
51 +pub(crate) enum JsonToolCallWhitespace {
52 Optional,
53 Exact(&'static str),
54 }
@@ -61,7 +61,7 @@ enum JsonToolCallMode {
61 }
62
63 #[derive(Debug, Clone, PartialEq, Eq)]
64 -enum JsonToolCallEvent {
64 +pub(crate) enum JsonToolCallEvent {
65 Text { len: usize },
66 ToolCallStart,
67 ToolCallHeader { function_name: String },
@@ -220,7 +220,7 @@ fn tool_call_start_event(
220
221 /// Parse a marker-wrapped JSON tool-call header before the raw arguments
222 /// payload.
223 -fn tool_call_header_event(
223 +pub(crate) fn tool_call_header_event(
224 input: &mut JsonToolInput<'_>,
225 config: JsonToolCallConfig,
226 ) -> ModalResult<JsonToolCallEvent> {
rust/src/parser/src/tool/mod.rs
+1 -1
@@ -9,7 +9,7 @@ mod deepseek_dsml;
9 pub(crate) mod deepseek_json;
10 mod glm_xml;
11 mod hy_v3;
12 -mod json;
12 +pub(crate) mod json;
13 mod kimi_k2;
14 mod minimax_m2;
15 mod minimax_m3;
rust/src/parser/src/unified/gemma4.rs
+5 -14
@@ -10,7 +10,7 @@ use winnow::prelude::*;
10 use winnow::stream::{Partial, Stream};
11 use winnow::token::{literal, take_till, take_until};
12
13 -use super::{Result, UnifiedParser, UnifiedParserError, UnifiedParserOutput};
13 +use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
14 use crate::reasoning::last_reasoning_boundary;
15 use crate::tool::{Tool, ToolCallDelta};
16 use crate::unified::parsing_failed;
@@ -79,17 +79,8 @@ pub struct Gemma4UnifiedParser {
79 impl Gemma4UnifiedParser {
80 /// Create a Gemma4 parser.
81 pub fn new(_tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
82 - let channel_start_token_id = tokenizer.token_to_id(CHANNEL_START).ok_or_else(|| {
83 - UnifiedParserError::MissingToken {
84 - token: CHANNEL_START.to_string(),
85 - }
86 - })?;
87 - let channel_end_token_id =
88 - tokenizer
89 - .token_to_id(CHANNEL_END)
90 - .ok_or_else(|| UnifiedParserError::MissingToken {
91 - token: CHANNEL_END.to_string(),
92 - })?;
82 + let channel_start_token_id = token_id(tokenizer.as_ref(), CHANNEL_START)?;
83 + let channel_end_token_id = token_id(tokenizer.as_ref(), CHANNEL_END)?;
84
85 Ok(Self {
86 buffer: String::new(),
@@ -524,10 +515,10 @@ mod tests {
515
516 use super::{
517 CHANNEL_END, CHANNEL_START, Gemma4UnifiedParser, ToolCallDelta, UnifiedParser,
527 - UnifiedParserError, UnifiedParserOutput, gemma4_array_content, parse_gemma4_args,
518 + UnifiedParserOutput, gemma4_array_content, parse_gemma4_args,
519 };
520 use crate::tool::Tool;
530 - use crate::unified::{UnifiedParserEvent, parsing_failed};
521 + use crate::unified::{UnifiedParserError, UnifiedParserEvent, parsing_failed};
522
523 const CHANNEL_START_ID: u32 = 256;
524 const CHANNEL_END_ID: u32 = 257;
rust/src/parser/src/unified/inkling.rs new
+768
@@ -0,0 +1,768 @@
1 +// SPDX-License-Identifier: Apache-2.0
2 +// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +use winnow::ascii::multispace0 as ws0;
5 +use winnow::combinator::{alt, seq};
6 +use winnow::error::ModalResult;
7 +use winnow::prelude::*;
8 +use winnow::stream::Partial;
9 +use winnow::token::literal;
10 +
11 +use vllm_tokenizer::DynTokenizer;
12 +
13 +use super::{Result, UnifiedParser, UnifiedParserOutput, token_id};
14 +use crate::tool::json::{
15 + JsonToolCallConfig, JsonToolCallEvent, JsonToolCallWhitespace, JsonToolInput,
16 + tool_call_header_event,
17 +};
18 +use crate::tool::{Tool, ToolCallDelta};
19 +use crate::unified::parsing_failed;
20 +use crate::utils::{
21 + JsonObjectScanState, parse_buffered_event, safe_text_len_mul, take_json_object,
22 +};
23 +
24 +const CONTENT_TEXT: &str = "<|content_text|>";
25 +const CONTENT_THINKING: &str = "<|content_thinking|>";
26 +const CONTENT_INVOKE_TOOL_JSON: &str = "<|content_invoke_tool_json|>";
27 +const CONTENT_INVOKE_TOOL_TEXT: &str = "<|content_invoke_tool_text|>";
28 +const CONTENT_MODEL_END_SAMPLING: &str = "<|content_model_end_sampling|>";
29 +const CONTENT_TOOL_ERROR: &str = "<|content_tool_error|>";
30 +const MESSAGE_MODEL: &str = "<|message_model|>";
31 +const END_MESSAGE: &str = "<|end_message|>";
32 +
33 +const IDLE_MARKERS: &[&str] = &[
34 + MESSAGE_MODEL,
35 + CONTENT_TEXT,
36 + CONTENT_THINKING,
37 + CONTENT_INVOKE_TOOL_JSON,
38 + CONTENT_INVOKE_TOOL_TEXT,
39 + CONTENT_MODEL_END_SAMPLING,
40 + CONTENT_TOOL_ERROR,
41 + END_MESSAGE,
42 +];
43 +const BLOCK_END_MARKERS: &[&str] = &[END_MESSAGE, CONTENT_MODEL_END_SAMPLING];
44 +
45 +const INKLING_TOOL_CONFIG: JsonToolCallConfig = JsonToolCallConfig {
46 + parser_name: "Inkling",
47 + start_marker: CONTENT_INVOKE_TOOL_JSON,
48 + end_marker: END_MESSAGE,
49 + marker_whitespace: JsonToolCallWhitespace::Optional,
50 + delimiter: None,
51 + name_key: "name",
52 + arguments_key: &["args"],
53 +};
54 +
55 +type InklingInput<'i> = Partial<&'i str>;
56 +
57 +#[derive(Debug, Clone, PartialEq, Eq)]
58 +enum InklingEvent {
59 + Text { len: usize },
60 + Reasoning { len: usize },
61 + TextStart,
62 + ReasoningStart,
63 + MessageStart,
64 + Header,
65 + ToolJsonStart,
66 + ToolJsonHeader { name: String },
67 + ToolJsonArgs { len: usize, complete: bool },
68 + BlockEnd,
69 +}
70 +
71 +#[derive(Debug, Clone, Default, PartialEq, Eq)]
72 +enum InklingMode {
73 + #[default]
74 + Idle,
75 + MessageHeader,
76 + Text,
77 + Reasoning,
78 + ToolJsonHeader,
79 + ToolJsonArgs {
80 + json_scan: JsonObjectScanState,
81 + },
82 + ToolJsonClose,
83 +}
84 +
85 +/// Unified parser for Inkling typed content blocks.
86 +pub struct InklingUnifiedParser {
87 + buffer: String,
88 + mode: InklingMode,
89 + emitted_tool_count: usize,
90 + active_tool_index: Option<usize>,
91 + tokenizer: DynTokenizer,
92 + message_model_token_id: u32,
93 + content_text_token_id: u32,
94 + content_thinking_token_id: u32,
95 +}
96 +
97 +impl InklingUnifiedParser {
98 + /// Create a Inkling parser.
99 + pub fn new(_tools: &[Tool], tokenizer: DynTokenizer) -> Result<Self> {
100 + let message_model_token_id = token_id(tokenizer.as_ref(), MESSAGE_MODEL)?;
101 + let content_text_token_id = token_id(tokenizer.as_ref(), CONTENT_TEXT)?;
102 + let content_thinking_token_id = token_id(tokenizer.as_ref(), CONTENT_THINKING)?;
103 +
104 + Ok(Self {
105 + buffer: String::new(),
106 + mode: InklingMode::Idle,
107 + emitted_tool_count: 0,
108 + active_tool_index: None,
109 + tokenizer,
110 + message_model_token_id,
111 + content_text_token_id,
112 + content_thinking_token_id,
113 + })
114 + }
115 +
116 + fn initialize_mode(&mut self, prompt_token_ids: &[u32]) {
117 + self.mode = InklingMode::Idle;
118 + for token_id in prompt_token_ids.iter().rev().copied() {
119 + if token_id == self.message_model_token_id {
120 + self.mode = InklingMode::MessageHeader;
121 + return;
122 + }
123 + if token_id == self.content_thinking_token_id {
124 + self.mode = InklingMode::Reasoning;
125 + return;
126 + }
127 + if token_id == self.content_text_token_id {
128 + self.mode = InklingMode::Text;
129 + return;
130 + }
131 + if self.tokenizer.is_special_id(token_id) {
132 + return;
133 + }
134 + }
135 + }
136 +
137 + fn apply_event(&mut self, event: InklingEvent, output: &mut UnifiedParserOutput) -> Result<()> {
138 + match event {
139 + InklingEvent::Text { len } => output.push_text(self.buffer[..len].to_string()),
140 + InklingEvent::Reasoning { len } => {
141 + output.push_reasoning(self.buffer[..len].to_string());
142 + }
143 + InklingEvent::MessageStart => self.mode = InklingMode::MessageHeader,
144 + InklingEvent::Header => {}
145 + InklingEvent::TextStart => self.mode = InklingMode::Text,
146 + InklingEvent::ReasoningStart => self.mode = InklingMode::Reasoning,
147 + InklingEvent::ToolJsonStart => self.mode = InklingMode::ToolJsonHeader,
148 + InklingEvent::ToolJsonHeader { name } => {
149 + let tool_index = self.emitted_tool_count;
150 + self.emitted_tool_count += 1;
151 + self.active_tool_index = Some(tool_index);
152 + self.mode = InklingMode::ToolJsonArgs {
153 + json_scan: JsonObjectScanState::default(),
154 + };
155 + output.push_call(ToolCallDelta {
156 + tool_index,
157 + name: Some(name),
158 + arguments: String::new(),
159 + });
160 + }
161 + InklingEvent::ToolJsonArgs { len, complete } => {
162 + let Some(tool_index) = self.active_tool_index else {
163 + return Err(parsing_failed!(
164 + "Inkling arguments without an active tool call"
165 + ));
166 + };
167 + output.push_call(ToolCallDelta {
168 + tool_index,
169 + name: None,
170 + arguments: self.buffer[..len].to_string(),
171 + });
172 + if complete {
173 + self.mode = InklingMode::ToolJsonClose;
174 + }
175 + }
176 + InklingEvent::BlockEnd => {
177 + self.mode = InklingMode::Idle;
178 + self.active_tool_index = None;
179 + }
180 + }
181 + Ok(())
182 + }
183 +
184 + fn reset(&mut self) -> String {
185 + self.mode = InklingMode::Idle;
186 + self.active_tool_index = None;
187 + self.emitted_tool_count = 0;
188 + std::mem::take(&mut self.buffer)
189 + }
190 +}
191 +
192 +impl UnifiedParser for InklingUnifiedParser {
193 + fn create(tools: &[Tool], tokenizer: DynTokenizer) -> Result<Box<dyn UnifiedParser>>
194 + where
195 + Self: Sized + 'static,
196 + {
197 + Self::new(tools, tokenizer).map(|parser| Box::new(parser) as Box<dyn UnifiedParser>)
198 + }
199 +
200 + fn initialize(&mut self, prompt_token_ids: &[u32]) -> Result<()> {
201 + self.buffer.clear();
202 + self.emitted_tool_count = 0;
203 + self.active_tool_index = None;
204 + self.initialize_mode(prompt_token_ids);
205 + Ok(())
206 + }
207 +
208 + fn preserve_special_tokens(&self) -> bool {
209 + true
210 + }
211 +
212 + fn parse_into(&mut self, chunk: &str, output: &mut UnifiedParserOutput) -> Result<()> {
213 + self.buffer.push_str(chunk);
214 +
215 + while let Some((event, consumed_len)) = parse_buffered_event(&self.buffer, |input| {
216 + parse_next_inkling_event(input, &mut self.mode)
217 + })? {
218 + self.apply_event(event, output)?;
219 + self.buffer.drain(..consumed_len);
220 + }
221 +
222 + Ok(())
223 + }
224 +
225 + fn finish(&mut self) -> Result<UnifiedParserOutput> {
226 + let mut output = UnifiedParserOutput::default();
227 +
228 + match &self.mode {
229 + InklingMode::Idle | InklingMode::Text => {
230 + output.push_text(std::mem::take(&mut self.buffer))
231 + }
232 + InklingMode::MessageHeader => self.buffer.clear(),
233 + InklingMode::Reasoning => output.push_reasoning(std::mem::take(&mut self.buffer)),
234 + InklingMode::ToolJsonHeader
235 + | InklingMode::ToolJsonArgs { .. }
236 + | InklingMode::ToolJsonClose => {
237 + return Err(parsing_failed!("incomplete Inkling tool call"));
238 + }
239 + }
240 +
241 + let _ = self.reset();
242 + Ok(output)
243 + }
244 +
245 + fn reset(&mut self) -> String {
246 + InklingUnifiedParser::reset(self)
247 + }
248 +}
249 +
250 +/// Parse one Inkling event from buffered streaming input.
251 +fn parse_next_inkling_event(
252 + input: &mut InklingInput<'_>,
253 + mode: &mut InklingMode,
254 +) -> ModalResult<InklingEvent> {
255 + match mode {
256 + InklingMode::Idle => parse_idle_event(input),
257 + InklingMode::MessageHeader => parse_message_header_event(input),
258 + InklingMode::Text => parse_text_event(input),
259 + InklingMode::Reasoning => parse_reasoning_event(input),
260 + InklingMode::ToolJsonHeader => parse_tool_json_header_event(input),
261 + InklingMode::ToolJsonArgs { json_scan } => parse_tool_json_args_event(input, json_scan),
262 + InklingMode::ToolJsonClose => parse_tool_json_close_event(input),
263 + }
264 +}
265 +
266 +/// Parse an event while waiting for a Inkling content kind.
267 +fn parse_idle_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
268 + alt((
269 + message_start_event,
270 + reasoning_start_event,
271 + text_start_event,
272 + tool_json_start_event,
273 + raw_text_start_event,
274 + block_end_event,
275 + safe_idle_text_event,
276 + ))
277 + .parse_next(input)
278 +}
279 +
280 +/// Parse a Inkling model-authored message start marker.
281 +fn message_start_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
282 + literal(MESSAGE_MODEL).value(InklingEvent::MessageStart).parse_next(input)
283 +}
284 +
285 +/// Parse an event while waiting for an Inkling message content kind.
286 +fn parse_message_header_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
287 + alt((
288 + reasoning_start_event,
289 + text_start_event,
290 + tool_json_start_event,
291 + raw_text_start_event,
292 + block_end_event,
293 + safe_header_event,
294 + ))
295 + .parse_next(input)
296 +}
297 +
298 +/// Parse an event inside a Inkling text block.
299 +fn parse_text_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
300 + alt((block_end_event, safe_text_event)).parse_next(input)
301 +}
302 +
303 +/// Parse an event inside a Inkling reasoning block.
304 +fn parse_reasoning_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
305 + alt((block_end_event, safe_reasoning_event)).parse_next(input)
306 +}
307 +
308 +/// Parse a Inkling text start marker.
309 +fn text_start_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
310 + literal(CONTENT_TEXT).value(InklingEvent::TextStart).parse_next(input)
311 +}
312 +
313 +/// Parse a Inkling reasoning start marker.
314 +fn reasoning_start_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
315 + literal(CONTENT_THINKING).value(InklingEvent::ReasoningStart).parse_next(input)
316 +}
317 +
318 +/// Parse a Inkling JSON tool-call start marker.
319 +fn tool_json_start_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
320 + literal(CONTENT_INVOKE_TOOL_JSON)
321 + .value(InklingEvent::ToolJsonStart)
322 + .parse_next(input)
323 +}
324 +
325 +/// Parse a Inkling content kind treated as visible text.
326 +fn raw_text_start_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
327 + alt((
328 + literal(CONTENT_INVOKE_TOOL_TEXT),
329 + literal(CONTENT_TOOL_ERROR),
330 + ))
331 + .value(InklingEvent::TextStart)
332 + .parse_next(input)
333 +}
334 +
335 +/// Parse a Inkling block end marker.
336 +fn block_end_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
337 + alt((literal(END_MESSAGE), literal(CONTENT_MODEL_END_SAMPLING)))
338 + .value(InklingEvent::BlockEnd)
339 + .parse_next(input)
340 +}
341 +
342 +/// Parse safe text while waiting for the next Inkling marker.
343 +fn safe_idle_text_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
344 + safe_text_len_mul(input, IDLE_MARKERS).map(|len| InklingEvent::Text { len })
345 +}
346 +
347 +/// Parse safe header text before the next Inkling marker.
348 +fn safe_header_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
349 + safe_text_len_mul(input, IDLE_MARKERS).map(|_| InklingEvent::Header)
350 +}
351 +
352 +/// Parse safe text before the end of a Inkling text block.
353 +fn safe_text_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
354 + safe_text_len_mul(input, BLOCK_END_MARKERS).map(|len| InklingEvent::Text { len })
355 +}
356 +
357 +/// Parse safe reasoning before the end of a Inkling reasoning block.
358 +fn safe_reasoning_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
359 + safe_text_len_mul(input, BLOCK_END_MARKERS).map(|len| InklingEvent::Reasoning { len })
360 +}
361 +
362 +/// Parse a Inkling JSON tool-call header.
363 +fn parse_tool_json_header_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
364 + match tool_call_header_event(input, INKLING_TOOL_CONFIG)? {
365 + JsonToolCallEvent::ToolCallHeader { function_name } => Ok(InklingEvent::ToolJsonHeader {
366 + name: function_name,
367 + }),
368 + _ => unreachable!("tool_call_header_event only emits ToolCallHeader"),
369 + }
370 +}
371 +
372 +/// Parse raw Inkling JSON tool-call argument bytes.
373 +fn parse_tool_json_args_event(
374 + input: &mut JsonToolInput<'_>,
375 + json_scan: &mut JsonObjectScanState,
376 +) -> ModalResult<InklingEvent> {
377 + let len = take_json_object(input, json_scan)?;
378 + Ok(InklingEvent::ToolJsonArgs {
379 + len,
380 + complete: json_scan.complete(),
381 + })
382 +}
383 +
384 +/// Parse the close of a Inkling JSON tool-call block.
385 +fn parse_tool_json_close_event(input: &mut InklingInput<'_>) -> ModalResult<InklingEvent> {
386 + seq!(
387 + _: ws0,
388 + _: literal("}"),
389 + _: ws0,
390 + _: literal(END_MESSAGE),
391 + )
392 + .value(InklingEvent::BlockEnd)
393 + .parse_next(input)
394 +}
395 +
396 +#[cfg(test)]
397 +mod tests {
398 + use std::sync::Arc;
399 +
400 + use super::{CONTENT_TEXT, CONTENT_THINKING, InklingUnifiedParser, MESSAGE_MODEL};
401 + use crate::tool::Tool;
402 + use crate::unified::{UnifiedParser, UnifiedParserEvent, UnifiedParserOutput};
403 + use thiserror_ext::AsReport;
404 + use vllm_tokenizer::Tokenizer;
405 +
406 + struct FakeTokenizer;
407 +
408 + impl Tokenizer for FakeTokenizer {
409 + fn encode(
410 + &self,
411 + text: &str,
412 + _add_special_tokens: bool,
413 + ) -> vllm_tokenizer::Result<Vec<u32>> {
414 + Ok(text.chars().map(u32::from).collect())
415 + }
416 +
417 + fn decode(
418 + &self,
419 + token_ids: &[u32],
420 + _skip_special_tokens: bool,
421 + ) -> vllm_tokenizer::Result<String> {
422 + Ok(token_ids
423 + .iter()
424 + .map(|token_id| char::from_u32(*token_id).unwrap_or('\u{FFFD}'))
425 + .collect())
426 + }
427 +
428 + fn token_to_id(&self, token: &str) -> Option<u32> {
429 + match token {
430 + MESSAGE_MODEL => Some(200001),
431 + "<|content_text|>" => Some(200004),
432 + "<|content_model_end_sampling|>" => Some(200006),
433 + "<|content_thinking|>" => Some(200008),
434 + "<|end_message|>" => Some(200010),
435 + "<|content_tool_error|>" => Some(200022),
436 + "<|content_invoke_tool_json|>" => Some(200049),
437 + "<|content_invoke_tool_text|>" => Some(200057),
438 + _ => None,
439 + }
440 + }
441 +
442 + fn id_to_token(&self, id: u32) -> Option<String> {
443 + let token = match id {
444 + 200001 => MESSAGE_MODEL,
445 + 200004 => "<|content_text|>",
446 + 200006 => "<|content_model_end_sampling|>",
447 + 200008 => "<|content_thinking|>",
448 + 200010 => "<|end_message|>",
449 + 200022 => "<|content_tool_error|>",
450 + 200049 => "<|content_invoke_tool_json|>",
451 + 200057 => "<|content_invoke_tool_text|>",
452 + _ => return None,
453 + };
454 + Some(token.to_string())
455 + }
456 +
457 + fn is_special_id(&self, token_id: u32) -> bool {
458 + (199999..=200057).contains(&token_id)
459 + }
460 + }
461 +
462 + trait UnifiedParserTestExt {
463 + fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput>;
464 + fn parse_complete(&mut self, text: &str) -> super::Result<UnifiedParserOutput>;
465 + }
466 +
467 + impl<T: UnifiedParser + ?Sized> UnifiedParserTestExt for T {
468 + fn parse_chunk(&mut self, chunk: &str) -> super::Result<UnifiedParserOutput> {
469 + let mut output = UnifiedParserOutput::default();
470 + self.parse_into(chunk, &mut output)?;
471 + Ok(output)
472 + }
473 +
474 + fn parse_complete(&mut self, text: &str) -> super::Result<UnifiedParserOutput> {
475 + let mut output = self.parse_chunk(text)?;
476 + output.append(self.finish()?);
477 + Ok(output)
478 + }
479 + }
480 +
481 + trait UnifiedParserOutputTestExt {
482 + fn normal_text(&self) -> String;
483 + fn reasoning_text(&self) -> String;
484 + fn calls(&self) -> Vec<crate::tool::ToolCallDelta>;
485 + }
486 +
487 + impl UnifiedParserOutputTestExt for UnifiedParserOutput {
488 + fn normal_text(&self) -> String {
489 + self.events
490 + .iter()
491 + .filter_map(|event| match event {
492 + UnifiedParserEvent::Text(text) => Some(text.as_str()),
493 + _ => None,
494 + })
495 + .collect()
496 + }
497 +
498 + fn reasoning_text(&self) -> String {
499 + self.events
500 + .iter()
501 + .filter_map(|event| match event {
502 + UnifiedParserEvent::Reasoning(text) => Some(text.as_str()),
503 + _ => None,
504 + })
505 + .collect()
506 + }
507 +
508 + fn calls(&self) -> Vec<crate::tool::ToolCallDelta> {
509 + self.events
510 + .iter()
511 + .filter_map(|event| match event {
512 + UnifiedParserEvent::ToolCall(call) => Some(call.clone()),
513 + _ => None,
514 + })
515 + .collect()
516 + }
517 + }
518 +
519 + fn test_tools() -> Vec<Tool> {
520 + vec![Tool {
521 + name: "get_weather".to_string(),
522 + description: Some("Get weather".to_string()),
523 + parameters: serde_json::json!({
524 + "type": "object",
525 + "properties": {
526 + "city": {"type": "string"}
527 + },
528 + }),
529 + strict: None,
530 + }]
531 + }
532 +
533 + fn test_parser() -> InklingUnifiedParser {
534 + InklingUnifiedParser::new(&test_tools(), Arc::new(FakeTokenizer)).unwrap()
535 + }
536 +
537 + fn collect_stream(chunks: &[&str]) -> UnifiedParserOutput {
538 + let mut parser = test_parser();
539 + let mut output = UnifiedParserOutput::default();
540 + for chunk in chunks {
541 + output.append(parser.parse_chunk(chunk).unwrap());
542 + }
543 + output.append(parser.finish().unwrap());
544 + output
545 + }
546 +
547 + #[test]
548 + fn inkling_streaming_emits_reasoning_then_text() {
549 + let output = collect_stream(&[concat!(
550 + "<|content_thinking|>reason<|end_message|>",
551 + "<|message_model|><|content_text|>answer<|end_message|>",
552 + "<|content_model_end_sampling|>"
553 + )]);
554 +
555 + assert_eq!(output.reasoning_text(), "reason");
556 + assert_eq!(output.normal_text(), "answer");
557 + assert!(output.calls().is_empty());
558 + }
559 +
560 + #[test]
561 + fn inkling_streaming_holds_split_markers() {
562 + let output = collect_stream(&[
563 + "<|content_thin",
564 + "king|>rea",
565 + "son<|end_mes",
566 + "sage|><|message_",
567 + "model|><|content_text|>answer",
568 + "<|end_message|>",
569 + ]);
570 +
571 + assert_eq!(output.reasoning_text(), "reason");
572 + assert_eq!(output.normal_text(), "answer");
573 + }
574 +
575 + #[test]
576 + fn inkling_streaming_accepts_content_blocks_without_message_start() {
577 + let output = collect_stream(&[concat!(
578 + "<|content_thinking|>reason<|end_message|>",
579 + "<|content_text|>answer<|end_message|>",
580 + )]);
581 +
582 + assert_eq!(output.reasoning_text(), "reason");
583 + assert_eq!(output.normal_text(), "answer");
584 + }
585 +
586 + #[test]
587 + fn inkling_tool_json_streams_name_then_argument_deltas() {
588 + let mut parser = test_parser();
589 + let chunks = [
590 + "<|message_model|><|content_invoke_tool_json|>{\"name\":\"get_weather\",\"args\":",
591 + "{\"city\":",
592 + "\"SF\"",
593 + "}}<|end_message|>",
594 + ];
595 +
596 + let mut output = UnifiedParserOutput::default();
597 + let mut observed_args = Vec::new();
598 + for chunk in chunks {
599 + let next = parser.parse_chunk(chunk).unwrap();
600 + observed_args.extend(
601 + next.calls()
602 + .into_iter()
603 + .filter(|call| call.name.is_none())
604 + .map(|call| call.arguments),
605 + );
606 + output.append(next);
607 + }
608 + output.append(parser.finish().unwrap());
609 +
610 + let calls = output.calls();
611 + assert_eq!(calls[0].name.as_deref(), Some("get_weather"));
612 + assert_eq!(observed_args, ["{\"city\":", "\"SF\"", "}"]);
613 + assert_eq!(
614 + calls.iter().map(|call| call.arguments.as_str()).collect::<String>(),
615 + "{\"city\":\"SF\"}"
616 + );
617 + assert!(output.normal_text().is_empty());
618 + }
619 +
620 + #[test]
621 + fn inkling_discards_tool_name_from_message_header() {
622 + let output = collect_stream(&[concat!(
623 + "<|message_model|>get_weather<|content_invoke_tool_json|>",
624 + "{\"name\":\"get_weather\",\"args\":{}}<|end_message|>"
625 + )]);
626 +
627 + assert!(output.normal_text().is_empty());
628 + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
629 + }
630 +
631 + #[test]
632 + fn inkling_streaming_handles_multiple_tool_blocks() {
633 + let output = collect_stream(&[concat!(
634 + "<|content_invoke_tool_json|>{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}<|end_message|>",
635 + "<|message_model|><|content_invoke_tool_json|>{\"name\":\"get_weather\",\"args\":{\"city\":\"NYC\"}}<|end_message|>"
636 + )]);
637 +
638 + let calls = output.calls();
639 + assert_eq!(calls.iter().filter(|call| call.name.is_some()).count(), 2);
640 + assert_eq!(calls[0].tool_index, 0);
641 + assert_eq!(calls[2].tool_index, 1);
642 + }
643 +
644 + #[test]
645 + fn inkling_initialize_open_text_prompt_starts_in_text() {
646 + let mut parser = test_parser();
647 + parser.initialize(&[200004]).unwrap();
648 +
649 + let output = parser.parse_complete("answer<|end_message|>").unwrap();
650 +
651 + assert_eq!(output.normal_text(), "answer");
652 + assert!(output.reasoning_text().is_empty());
653 + }
654 +
655 + #[test]
656 + fn inkling_initialize_open_reasoning_prompt_starts_in_reasoning() {
657 + let mut parser = test_parser();
658 + parser.initialize(&[200008]).unwrap();
659 +
660 + let output = parser.parse_complete("reason<|end_message|>").unwrap();
661 +
662 + assert_eq!(output.reasoning_text(), "reason");
663 + assert!(output.normal_text().is_empty());
664 + }
665 +
666 + #[test]
667 + fn inkling_initialize_model_opener_starts_in_message_header() {
668 + let mut parser = test_parser();
669 + parser.initialize(&[200001]).unwrap();
670 +
671 + let output = parser
672 + .parse_complete(concat!(
673 + "get_weather<|content_invoke_tool_json|>",
674 + "{\"name\":\"get_weather\",\"args\":{}}<|end_message|>"
675 + ))
676 + .unwrap();
677 +
678 + assert!(output.normal_text().is_empty());
679 + assert_eq!(output.calls()[0].name.as_deref(), Some("get_weather"));
680 + }
681 +
682 + #[test]
683 + fn inkling_plain_text_falls_through_as_text() {
684 + let output = collect_stream(&["plain ", "answer"]);
685 +
686 + assert_eq!(output.normal_text(), "plain answer");
687 + assert!(output.reasoning_text().is_empty());
688 + }
689 +
690 + #[test]
691 + fn inkling_raw_tool_text_and_tool_error_are_visible_text() {
692 + let output = collect_stream(&[concat!(
693 + "<|content_invoke_tool_text|>search SF<|end_message|>",
694 + "<|content_tool_error|>failed<|end_message|>"
695 + )]);
696 +
697 + assert_eq!(output.normal_text(), "search SFfailed");
698 + assert!(output.calls().is_empty());
699 + }
700 +
701 + #[test]
702 + fn inkling_finish_fails_incomplete_tool_call() {
703 + let mut parser = test_parser();
704 + parser
705 + .parse_chunk("<|content_invoke_tool_json|>{\"name\":\"get_weather\",\"args\":{\"city\"")
706 + .unwrap();
707 +
708 + let error = parser.finish().unwrap_err();
709 +
710 + assert!(error.as_report().to_string().contains("incomplete Inkling tool call"));
711 + }
712 +
713 + #[test]
714 + fn inkling_rejects_non_object_args() {
715 + let mut parser = test_parser();
716 + let error = parser
717 + .parse_chunk("<|content_invoke_tool_json|>{\"name\":\"get_weather\",\"args\":42")
718 + .unwrap_err();
719 +
720 + assert!(error.as_report().to_string().contains("JSON object argument"));
721 + }
722 +
723 + #[test]
724 + fn inkling_missing_token_fails_create() {
725 + struct MissingTokenizer;
726 +
727 + impl Tokenizer for MissingTokenizer {
728 + fn encode(
729 + &self,
730 + _text: &str,
731 + _add_special_tokens: bool,
732 + ) -> vllm_tokenizer::Result<Vec<u32>> {
733 + Ok(vec![])
734 + }
735 +
736 + fn decode(
737 + &self,
738 + _token_ids: &[u32],
739 + _skip_special_tokens: bool,
740 + ) -> vllm_tokenizer::Result<String> {
741 + Ok(String::new())
742 + }
743 +
744 + fn token_to_id(&self, token: &str) -> Option<u32> {
745 + match token {
746 + MESSAGE_MODEL => Some(200001),
747 + CONTENT_TEXT => Some(200004),
748 + _ => None,
749 + }
750 + }
751 +
752 + fn id_to_token(&self, id: u32) -> Option<String> {
753 + match id {
754 + 200001 => Some(MESSAGE_MODEL.to_string()),
755 + 200004 => Some(CONTENT_TEXT.to_string()),
756 + _ => None,
757 + }
758 + }
759 + }
760 +
761 + let error = match InklingUnifiedParser::new(&[], Arc::new(MissingTokenizer)) {
762 + Ok(_) => panic!("expected parser creation to fail"),
763 + Err(error) => error,
764 + };
765 +
766 + assert!(error.as_report().to_string().contains(CONTENT_THINKING));
767 + }
768 +}
rust/src/parser/src/unified/mod.rs
+9
@@ -5,9 +5,11 @@
5
6 mod combined;
7 mod gemma4;
8 +mod inkling;
9
10 pub use combined::CombinedParser;
11 pub use gemma4::Gemma4UnifiedParser;
12 +pub use inkling::InklingUnifiedParser;
13 use thiserror::Error;
14 use thiserror_ext::Macro;
15 use vllm_tokenizer::DynTokenizer;
@@ -208,3 +210,10 @@ pub enum UnifiedParserError {
210 #[error(transparent)]
211 Tool(#[from] ToolParserError),
212 }
213 +
214 +/// Returns the ID for the given token, or an error if it's not found.
215 +fn token_id(tokenizer: &dyn vllm_tokenizer::Tokenizer, token: &str) -> Result<u32> {
216 + tokenizer.token_to_id(token).ok_or_else(|| UnifiedParserError::MissingToken {
217 + token: token.to_string(),
218 + })
219 +}
rust/src/text/src/backend/hf/config.rs
+46 -3
@@ -95,6 +95,7 @@ impl HfSpecialTokens {
95 pub struct ModelConfig {
96 model_type: Option<String>,
97 vocab_size: Option<u32>,
98 + eos_token_id: Option<OneOrManyTokenIds>,
99 num_experts: Option<OneOrManyExpertCount>,
100 moe_num_experts: Option<OneOrManyExpertCount>,
101 n_routed_experts: Option<OneOrManyExpertCount>,
@@ -125,12 +126,16 @@ pub(super) enum OneOrManyTokenIds {
126 }
127
128 impl OneOrManyTokenIds {
128 - pub(super) fn into_set(self) -> BTreeSet<u32> {
129 + pub(super) fn as_slice(&self) -> &[u32] {
130 match self {
130 - Self::One(id) => BTreeSet::from([id]),
131 - Self::Many(ids) => ids.into_iter().collect(),
131 + Self::One(id) => std::slice::from_ref(id),
132 + Self::Many(ids) => ids.as_slice(),
133 }
134 }
135 +
136 + pub(super) fn into_set(self) -> BTreeSet<u32> {
137 + self.as_slice().iter().copied().collect()
138 + }
139 }
140
141 /// Hugging Face configs may expose the expert count either as one integer or
@@ -195,6 +200,18 @@ impl ModelConfig {
200 }
201 }
202
203 + /// Return the effective model-side EOS token ids, following the same
204 + /// simplified text-config selection as `vocab_size`.
205 + pub(super) fn eos_token_ids(&self) -> &[u32] {
206 + if let Some(eos_token_id) = self.eos_token_id.as_ref() {
207 + eos_token_id.as_slice()
208 + } else if let Some(text_config) = self.text_config.as_deref() {
209 + text_config.eos_token_ids()
210 + } else {
211 + &[]
212 + }
213 + }
214 +
215 /// Match Python's current expert-count priority on the selected text
216 /// config.
217 ///
@@ -354,6 +371,32 @@ mod tests {
371 assert_eq!(config.vocab_size().unwrap(), 151936);
372 }
373
374 + #[test]
375 + fn model_config_reads_top_level_eos_token_ids() {
376 + let single: ModelConfig = serde_json::from_str(r#"{"eos_token_id":151645}"#).unwrap();
377 + let many: ModelConfig =
378 + serde_json::from_str(r#"{"eos_token_id":[128001,128008,128009]}"#).unwrap();
379 + let null: ModelConfig = serde_json::from_str(r#"{"eos_token_id":null}"#).unwrap();
380 +
381 + assert_eq!(single.eos_token_ids(), &[151645]);
382 + assert_eq!(many.eos_token_ids(), &[128001, 128008, 128009]);
383 + assert!(null.eos_token_ids().is_empty());
384 + }
385 +
386 + #[test]
387 + fn model_config_uses_nested_eos_token_ids_when_top_level_is_absent() {
388 + let config: ModelConfig = serde_json::from_str(
389 + r#"{
390 + "text_config": {
391 + "eos_token_id": [59246, 59253, 59255]
392 + }
393 + }"#,
394 + )
395 + .unwrap();
396 +
397 + assert_eq!(config.eos_token_ids(), &[59246, 59253, 59255]);
398 + }
399 +
400 #[test]
401 fn model_config_rejects_missing_vocab_size() {
402 let config: ModelConfig = serde_json::from_str(r#"{}"#).unwrap();
rust/src/text/src/backend/hf/mod.rs
+119 -15
@@ -8,7 +8,9 @@ use std::collections::BTreeSet;
8 use std::sync::Arc;
9
10 use tracing::info;
11 -use vllm_tokenizer::{DynTokenizer, HuggingFaceTokenizer, TekkenTokenizer, TiktokenTokenizer};
11 +use vllm_tokenizer::{
12 + DynTokenizer, HuggingFaceTokenizer, TekkenTokenizer, TiktokenTokenizer, Tokenizer,
13 +};
14
15 use self::config::{GenerationConfig, load_generation_config};
16 pub use self::config::{
@@ -56,23 +58,15 @@ impl HfTextBackend {
58 pub fn from_resolved_model_files(files: ResolvedModelFiles, model_id: String) -> Result<Self> {
59 let tokenizer_config = load_tokenizer_config(files.tokenizer_config_path.as_deref())?;
60 let tokenizer = load_tokenizer(&files.tokenizer)?;
59 - let primary_eos_token_id = tokenizer_config
60 - .special_tokens
61 - .eos_token
62 - .as_ref()
63 - .and_then(|token| tokenizer.token_to_id(token.as_str()));
64 -
61 let model_config = load_model_config(files.config_path.as_deref())?;
62 let model_vocab_size = model_config.vocab_size()? as usize;
63 let generation_config = load_generation_config(files.generation_config_path.as_deref())?;
68 - let mut extra_eos_token_ids = generation_config
69 - .eos_token_id
70 - .clone()
71 - .map(|value| value.into_set())
72 - .unwrap_or_default();
73 - if let Some(primary_eos_token_id) = primary_eos_token_id {
74 - extra_eos_token_ids.remove(&primary_eos_token_id);
75 - }
64 + let (primary_eos_token_id, extra_eos_token_ids) = resolve_eos_token_ids(
65 + &tokenizer_config,
66 + &model_config,
67 + &generation_config,
68 + tokenizer.as_ref(),
69 + );
70
71 info!(
72 model_id,
@@ -98,6 +92,42 @@ impl HfTextBackend {
92 }
93 }
94
95 +/// Resolve EOS hints from tokenizer, model, and generation configs.
96 +///
97 +/// Resolution rules:
98 +/// 1. Use the tokenizer-side EOS token as the primary EOS when it resolves to a
99 +/// token id.
100 +/// 2. Fall back to the first model-config EOS id when the tokenizer config does
101 +/// not provide a primary EOS.
102 +/// 3. Keep any remaining model/generation EOS ids as extra stop-token ids so
103 +/// they still participate in stopping and min-token handling.
104 +fn resolve_eos_token_ids(
105 + tokenizer_config: &HfTokenizerConfig,
106 + model_config: &ModelConfig,
107 + generation_config: &GenerationConfig,
108 + tokenizer: &dyn Tokenizer,
109 +) -> (Option<u32>, BTreeSet<u32>) {
110 + let model_config_eos_token_ids = model_config.eos_token_ids();
111 + let primary_eos_token_id = tokenizer_config
112 + .special_tokens
113 + .eos_token
114 + .as_ref()
115 + .and_then(|token| tokenizer.token_to_id(token.as_str()))
116 + .or_else(|| model_config_eos_token_ids.first().copied());
117 +
118 + let mut extra_eos_token_ids = generation_config
119 + .eos_token_id
120 + .clone()
121 + .map(|value| value.into_set())
122 + .unwrap_or_default();
123 + extra_eos_token_ids.extend(model_config_eos_token_ids.iter().copied());
124 + if let Some(primary_eos_token_id) = primary_eos_token_id {
125 + extra_eos_token_ids.remove(&primary_eos_token_id);
126 + }
127 +
128 + (primary_eos_token_id, extra_eos_token_ids)
129 +}
130 +
131 impl TextBackend for HfTextBackend {
132 fn tokenizer(&self) -> DynTokenizer {
133 self.tokenizer.clone()
@@ -128,3 +158,77 @@ impl TextBackend for HfTextBackend {
158 })
159 }
160 }
161 +
162 +#[cfg(test)]
163 +mod tests {
164 + use std::collections::BTreeSet;
165 +
166 + use super::{GenerationConfig, HfTokenizerConfig, ModelConfig, resolve_eos_token_ids};
167 + use vllm_tokenizer::Tokenizer;
168 +
169 + struct FakeTokenizer;
170 +
171 + impl Tokenizer for FakeTokenizer {
172 + fn encode(
173 + &self,
174 + _text: &str,
175 + _add_special_tokens: bool,
176 + ) -> vllm_tokenizer::Result<Vec<u32>> {
177 + Ok(vec![])
178 + }
179 +
180 + fn decode(
181 + &self,
182 + _token_ids: &[u32],
183 + _skip_special_tokens: bool,
184 + ) -> vllm_tokenizer::Result<String> {
185 + Ok(String::new())
186 + }
187 +
188 + fn token_to_id(&self, token: &str) -> Option<u32> {
189 + (token == "</s>").then_some(2)
190 + }
191 +
192 + fn id_to_token(&self, id: u32) -> Option<String> {
193 + (id == 2).then(|| "</s>".to_string())
194 + }
195 + }
196 +
197 + #[test]
198 + fn eos_resolution_uses_model_config_when_tokenizer_has_no_eos() {
199 + let tokenizer_config: HfTokenizerConfig = serde_json::from_str("{}").unwrap();
200 + let model_config: ModelConfig =
201 + serde_json::from_str(r#"{"eos_token_id":[200006,200010]}"#).unwrap();
202 + let generation_config: GenerationConfig = serde_json::from_str("{}").unwrap();
203 +
204 + let (primary, extra) = resolve_eos_token_ids(
205 + &tokenizer_config,
206 + &model_config,
207 + &generation_config,
208 + &FakeTokenizer,
209 + );
210 +
211 + assert_eq!(primary, Some(200006));
212 + assert_eq!(extra, BTreeSet::from([200010]));
213 + }
214 +
215 + #[test]
216 + fn eos_resolution_keeps_tokenizer_eos_primary() {
217 + let tokenizer_config: HfTokenizerConfig =
218 + serde_json::from_str(r#"{"eos_token":"</s>"}"#).unwrap();
219 + let model_config: ModelConfig =
220 + serde_json::from_str(r#"{"eos_token_id":[2,200006]}"#).unwrap();
221 + let generation_config: GenerationConfig =
222 + serde_json::from_str(r#"{"eos_token_id":[2,200010]}"#).unwrap();
223 +
224 + let (primary, extra) = resolve_eos_token_ids(
225 + &tokenizer_config,
226 + &model_config,
227 + &generation_config,
228 + &FakeTokenizer,
229 + );
230 +
231 + assert_eq!(primary, Some(2));
232 + assert_eq!(extra, BTreeSet::from([200006, 200010]));
233 + }
234 +}
setup.py
+15
@@ -445,6 +445,17 @@ class cmake_build_ext(build_ext):
445 dirs_exist_ok=True,
446 )
447
448 + tml_fa4_build = os.path.join(
449 + self.build_lib, "vllm", "third_party", "tml_fa4"
450 + )
451 + if os.path.exists(tml_fa4_build):
452 + print(f"Copying {tml_fa4_build} to vllm/third_party/tml_fa4")
453 + shutil.copytree(
454 + tml_fa4_build,
455 + "vllm/third_party/tml_fa4",
456 + dirs_exist_ok=True,
457 + )
458 +
459
460 class precompiled_build_ext(build_ext):
461 """Disables extension building when using precompiled binaries."""
@@ -803,6 +814,7 @@ class precompiled_wheel_utils:
814 # DeepGEMM: extract all files (.py, .so, .cuh, .h, .hpp, etc.)
815 deep_gemm_regex = re.compile(r"vllm/third_party/deep_gemm/.*")
816 fmha_sm100_regex = re.compile(r"vllm/third_party/fmha_sm100/.*")
817 + tml_fa4_regex = re.compile(r"vllm/third_party/tml_fa4/.*")
818 file_members = []
819 for member in wheel.filelist:
820 if member.filename in exact_members:
@@ -828,6 +840,7 @@ class precompiled_wheel_utils:
840 or triton_kernels_regex.match(member.filename)
841 or flashmla_regex.match(member.filename)
842 or deep_gemm_regex.match(member.filename)
843 + or tml_fa4_regex.match(member.filename)
844 or fmha_sm100_regex.match(member.filename)
845 ):
846 file_members.append(member)
@@ -1146,6 +1159,8 @@ if _is_cuda():
1159 ext_modules.append(CMakeExtension(name="vllm._qutlass_C", optional=True))
1160 # fmha_sm100 is a Python/CuTe-DSL package installed into vllm.third_party.
1161 ext_modules.append(CMakeExtension(name="vllm.fmha_sm100", optional=True))
1162 + # tml-fa4 is copied into an isolated vllm.third_party package.
1163 + ext_modules.append(CMakeExtension(name="vllm.tml_fa4", optional=True))
1164
1165 if _is_cpu():
1166 import platform
tests/kernels/test_ll_bf16_gemm.py
+11 -2
@@ -190,8 +190,8 @@ def test_arbitrary_N_splitk(N):
190
191 @pytest.mark.parametrize(
192 "N,K",
193 - [(256, 7168), (256, 14400), (8, 4096), (384, 7168)],
194 - ids=["DSV3", "DSV4-Flash", "Mixtral", "DSV4-Pro"],
193 + [(256, 7168), (256, 14400), (8, 4096), (384, 7168), (264, 6144)],
194 + ids=["DSV3", "DSV4-Flash", "Mixtral", "DSV4-Pro", "Inkling"],
195 )
196 def test_single_token(N, K):
197 torch.manual_seed(42)
@@ -202,6 +202,15 @@ def test_single_token(N, K):
202 _assert_close(out, _ref(a, b), context=f"M=1 {N}x{K}")
203
204
205 +def test_inkling_max_tokens():
206 + torch.manual_seed(42)
207 + a = torch.randn(64, 6144, dtype=torch.bfloat16, device="cuda")
208 + b = torch.randn(264, 6144, dtype=torch.bfloat16, device="cuda")
209 + out = _gemm(a, b)
210 + assert out.shape == (64, 264)
211 + _assert_close(out, _ref(a, b), context="Inkling M=64")
212 +
213 +
214 # =================================================================
215 # Numerical robustness
216 # =================================================================
tests/models/inkling/test_contract_validation.py new
+50
@@ -0,0 +1,50 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import numpy as np
5 +import pytest
6 +
7 +from vllm.config.compilation import CompilationConfig, CUDAGraphMode
8 +from vllm.models.inkling.common.mm_preprocess import InklingMultiModalDataParser
9 +from vllm.models.inkling.configs import (
10 + InklingAudioConfig,
11 + InklingVisionConfig,
12 +)
13 +from vllm.models.inkling.nvidia.sconv_swa_attn import (
14 + InklingSconvMetadataBuilder,
15 +)
16 +from vllm.v1.attention.backend import AttentionCGSupport
17 +
18 +
19 +@pytest.mark.parametrize(
20 + ("config_cls", "kwargs", "missing"),
21 + [
22 + (InklingAudioConfig, {"decoder_dmodel": 16}, "n_mel_bins"),
23 + (InklingVisionConfig, {"decoder_dmodel": 16}, "vision_encoder_type"),
24 + ],
25 +)
26 +def test_enabled_tower_requires_architecture_fields(config_cls, kwargs, missing):
27 + with pytest.raises(ValueError, match=missing):
28 + config_cls(**kwargs)
29 +
30 +
31 +def test_inkling_raw_2d_audio_is_rejected_as_ambiguous():
32 + parser = InklingMultiModalDataParser(target_sr=16_000, target_channels=1)
33 + with pytest.raises(ValueError, match="ambiguous channel layout"):
34 + parser._parse_audio_data(np.zeros((2, 100), dtype=np.float32))
35 +
36 +
37 +def test_inkling_supports_full_decode_only_cudagraphs():
38 + support = InklingSconvMetadataBuilder.get_cudagraph_support
39 + assert support(None, None) == AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE
40 +
41 + compilation_config = CompilationConfig(
42 + cudagraph_mode=CUDAGraphMode.FULL,
43 + splitting_ops=[],
44 + )
45 + resolved_mode = compilation_config.resolve_cudagraph_mode_and_sizes(
46 + AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE,
47 + "InklingSconvBackend",
48 + )
49 +
50 + assert resolved_mode == CUDAGraphMode.FULL_DECODE_ONLY
tests/models/inkling/test_fa4_rel_attention.py new
+381
@@ -0,0 +1,381 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Correctness test for the Inkling FA4 relative-attention score-mod kernel.
4 +
5 +Checks ``inkling_fa4_rel_attention`` against a pure-PyTorch reference that
6 +implements the relative bias exactly as documented in the Inkling architecture
7 +guide::
8 +
9 + logit(i, j, h) = (1 / head_dim) * dot(q[i, h], k[j, h]) + rel_bias(i, j, h)
10 + rel_bias(i, j, h) = rel_logits[i, h, i - j] if 0 <= i - j < rel_extent
11 + = 0 otherwise
12 +
13 +with causal (and optionally sliding-window) masking handled by the backend.
14 +"""
15 +
16 +import pytest
17 +import torch
18 +
19 +from vllm.models.inkling.nvidia.attention import (
20 + InklingAttention,
21 + compute_log_scaling_tau,
22 +)
23 +from vllm.models.inkling.nvidia.ops.fa4_rel_attention import (
24 + inkling_fa4_num_splits,
25 + inkling_fa4_rel_attention,
26 +)
27 +from vllm.platforms import current_platform
28 +from vllm.platforms.interface import DeviceCapability
29 +
30 +_cap = current_platform.get_device_capability() if current_platform.is_cuda() else None
31 +
32 +NUM_HEADS = [(4, 4), (8, 2)] # (num_heads, num_kv_heads)
33 +GLOBAL_REL_EXTENTS = [128, 1024]
34 +LOCAL_REL_EXTENTS = [128, 256]
35 +HEAD_DIM = 128
36 +BLOCK_SIZE = 16
37 +DTYPE = torch.bfloat16
38 +
39 +
40 +def test_log_scaling_tau_matches_reference():
41 + positions = torch.tensor([0, 127999, 128000, 999999], dtype=torch.int64)
42 + actual = compute_log_scaling_tau(positions, 128000, 0.1)
43 + expected = 1.0 + 0.1 * torch.log(
44 + torch.clamp((positions + 1).float() / 128000.0, min=1.0)
45 + )
46 + torch.testing.assert_close(actual, expected, rtol=0, atol=0)
47 +
48 +
49 +def test_split_packed_kv_cache():
50 + attention = InklingAttention.__new__(InklingAttention)
51 + torch.nn.Module.__init__(attention)
52 + attention.head_dim = 8
53 + attention.kv_cache = torch.arange(2 * 3 * 4 * 16).reshape(2, 3, 4, 16)
54 +
55 + key_cache, value_cache = attention._split_kv_cache()
56 +
57 + assert key_cache.shape == value_cache.shape == (2, 4, 3, 8)
58 + torch.testing.assert_close(key_cache, attention.kv_cache[..., :8].transpose(1, 2))
59 + torch.testing.assert_close(value_cache, attention.kv_cache[..., 8:].transpose(1, 2))
60 +
61 +
62 +def test_num_splits_hopper_is_unsplit(monkeypatch):
63 + monkeypatch.setattr(
64 + current_platform,
65 + "get_device_capability",
66 + lambda: DeviceCapability(major=9, minor=0),
67 + )
68 + assert (
69 + inkling_fa4_num_splits(
70 + is_local=False,
71 + batch_size=1,
72 + max_query_len=1,
73 + num_heads=16,
74 + num_kv_heads=2,
75 + max_kv_len=1_048_576,
76 + )
77 + == 1
78 + )
79 +
80 +
81 +@pytest.fixture
82 +def blackwell_platform(monkeypatch):
83 + monkeypatch.setattr(
84 + current_platform,
85 + "get_device_capability",
86 + lambda: DeviceCapability(major=10, minor=0),
87 + )
88 +
89 +
90 +@pytest.mark.parametrize(
91 + ("batch_size", "max_query_len", "expected"),
92 + [
93 + (1, 1, (16, 32, 128, 128)),
94 + (8, 1, (2, 4, 8, 16)),
95 + (32, 1, (1, 1, 2, 4)),
96 + (1, 128, (2, 4, 8, 16)),
97 + (1, 2048, (1, 1, 1, 1)),
98 + ],
99 +)
100 +def test_num_splits_all_tp(blackwell_platform, batch_size, max_query_len, expected):
101 + actual = tuple(
102 + inkling_fa4_num_splits(
103 + is_local=False,
104 + batch_size=batch_size,
105 + max_query_len=max_query_len,
106 + num_heads=64 // tp,
107 + num_kv_heads=8 // tp,
108 + max_kv_len=131072,
109 + )
110 + for tp in (1, 2, 4, 8)
111 + )
112 + assert actual == expected
113 +
114 +
115 +@pytest.mark.parametrize("tp", [1, 2, 4, 8])
116 +def test_num_splits_local_is_unsplit(tp):
117 + assert (
118 + inkling_fa4_num_splits(
119 + is_local=True,
120 + batch_size=1,
121 + max_query_len=1,
122 + num_heads=64 // tp,
123 + num_kv_heads=16 // tp,
124 + max_kv_len=512,
125 + )
126 + == 1
127 + )
128 +
129 +
130 +@pytest.mark.parametrize(
131 + ("max_kv_len", "expected"),
132 + [(8192, 32), (65536, 64), (1048576, 128)],
133 +)
134 +@pytest.mark.parametrize("tp", [4, 8])
135 +def test_num_splits_long_context_bound(blackwell_platform, tp, max_kv_len, expected):
136 + assert (
137 + inkling_fa4_num_splits(
138 + is_local=False,
139 + batch_size=1,
140 + max_query_len=1,
141 + num_heads=64 // tp,
142 + num_kv_heads=8 // tp,
143 + max_kv_len=max_kv_len,
144 + )
145 + == expected
146 + )
147 +
148 +
149 +def _ref_rel_attn(
150 + q: torch.Tensor, # [total_q, H, D]
151 + key_cache: torch.Tensor, # [num_blocks, block, Hkv, D]
152 + value_cache: torch.Tensor,
153 + rel_logits: torch.Tensor, # [total_q, H, rel_extent]
154 + *,
155 + q_lens: list[int],
156 + kv_lens: list[int],
157 + block_table: torch.Tensor,
158 + scale: float,
159 + rel_extent: int,
160 + window_left: int | None,
161 +) -> torch.Tensor:
162 + num_kv_heads = key_cache.shape[2]
163 + num_heads = q.shape[1]
164 + g = num_heads // num_kv_heads
165 + bt = block_table.cpu().numpy()
166 + out = torch.empty_like(q)
167 +
168 + start = 0
169 + for i, (ql, kl) in enumerate(zip(q_lens, kv_lens)):
170 + qi = q[start : start + ql].float() # [ql, H, D]
171 + rl = rel_logits[start : start + ql].float() # [ql, H, rel_extent]
172 +
173 + nblk = (kl + BLOCK_SIZE - 1) // BLOCK_SIZE
174 + blk = bt[i, :nblk]
175 + k = key_cache[blk].reshape(-1, num_kv_heads, HEAD_DIM)[:kl].float()
176 + v = value_cache[blk].reshape(-1, num_kv_heads, HEAD_DIM)[:kl].float()
177 + k = k.repeat_interleave(g, dim=1) # [kl, H, D]
178 + v = v.repeat_interleave(g, dim=1)
179 +
180 + # [H, ql, kl]
181 + scores = torch.einsum("qhd,khd->hqk", qi, k) * scale
182 +
183 + dev = q.device
184 + qpos = torch.arange(ql, device=dev).view(ql, 1) + (kl - ql) # query pos
185 + kpos = torch.arange(kl, device=dev).view(1, kl)
186 + dist = qpos - kpos # [ql, kl] = i - j
187 +
188 + # Relative bias: rel_logits[i, h, dist] when 0 <= dist < rel_extent.
189 + in_rng = (dist >= 0) & (dist < rel_extent) # [ql, kl]
190 + idx = dist.clamp(0, rel_extent - 1)
191 + # gather per head: bias[h, i, j] = rl[i, h, idx[i, j]]
192 + bias = rl.permute(1, 0, 2).gather( # [H, ql, rel_extent]
193 + 2, idx.unsqueeze(0).expand(num_heads, -1, -1)
194 + ) # [H, ql, kl]
195 + bias = torch.where(in_rng.unsqueeze(0), bias, torch.zeros_like(bias))
196 + scores = scores + bias
197 +
198 + mask = dist < 0 # causal
199 + if window_left is not None:
200 + mask = mask | (dist > window_left)
201 + scores.masked_fill_(mask.unsqueeze(0), float("-inf"))
202 +
203 + probs = torch.softmax(scores, dim=-1)
204 + out[start : start + ql] = torch.einsum("hqk,khd->qhd", probs, v).to(q.dtype)
205 + start += ql
206 + return out
207 +
208 +
209 +def _run_case(seq_lens, num_heads, num_kv_heads, rel_extent, window_left, seed=0):
210 + torch.manual_seed(seed)
211 + device = "cuda"
212 + q_lens = [s[0] for s in seq_lens]
213 + kv_lens = [s[1] for s in seq_lens]
214 + total_q = sum(q_lens)
215 + num_seqs = len(seq_lens)
216 +
217 + scale = 1.0 / HEAD_DIM
218 +
219 + # q/k are RMS-normed in the model (unit-ish norm); normalize here so the
220 + # logit magnitudes are realistic and the bias is not numerically dwarfed.
221 + q = torch.randn(total_q, num_heads, HEAD_DIM, device=device, dtype=DTYPE)
222 + q = torch.nn.functional.normalize(q.float(), dim=-1).to(DTYPE)
223 +
224 + # Paged KV cache.
225 + max_blocks = (max(kv_lens) + BLOCK_SIZE - 1) // BLOCK_SIZE
226 + num_blocks = num_seqs * max_blocks + 1
227 + key_cache = torch.randn(
228 + num_blocks, BLOCK_SIZE, num_kv_heads, HEAD_DIM, device=device, dtype=DTYPE
229 + )
230 + key_cache = torch.nn.functional.normalize(key_cache.float(), dim=-1).to(DTYPE)
231 + value_cache = torch.randn(
232 + num_blocks, BLOCK_SIZE, num_kv_heads, HEAD_DIM, device=device, dtype=DTYPE
233 + )
234 +
235 + # Distinct blocks per sequence (block 0 left as a never-referenced pad).
236 + block_table = torch.zeros(num_seqs, max_blocks, dtype=torch.int32, device=device)
237 + for i in range(num_seqs):
238 + block_table[i] = torch.arange(
239 + 1 + i * max_blocks, 1 + (i + 1) * max_blocks, dtype=torch.int32
240 + )
241 +
242 + cu_seqlens_q = torch.tensor(
243 + [0, *torch.cumsum(torch.tensor(q_lens), 0).tolist()],
244 + dtype=torch.int32,
245 + device=device,
246 + )
247 + cache_seqlens = torch.tensor(kv_lens, dtype=torch.int32, device=device)
248 +
249 + rel_logits = torch.randn(total_q, num_heads, rel_extent, device=device, dtype=DTYPE)
250 +
251 + window_size = (-1, -1) if window_left is None else (window_left, 0)
252 +
253 + preallocated_out = torch.empty_like(q)
254 + out = inkling_fa4_rel_attention(
255 + q,
256 + key_cache,
257 + value_cache,
258 + block_table=block_table,
259 + cache_seqlens=cache_seqlens,
260 + cu_seqlens_q=cu_seqlens_q,
261 + max_seqlen_q=max(q_lens),
262 + softmax_scale=scale,
263 + causal=True,
264 + window_size=window_size,
265 + rel_extent=rel_extent,
266 + rel_logits=rel_logits,
267 + out=preallocated_out,
268 + )
269 + assert out.data_ptr() == preallocated_out.data_ptr()
270 + out = out.view(total_q, num_heads, HEAD_DIM)
271 +
272 + ref = _ref_rel_attn(
273 + q,
274 + key_cache,
275 + value_cache,
276 + rel_logits,
277 + q_lens=q_lens,
278 + kv_lens=kv_lens,
279 + block_table=block_table,
280 + scale=scale,
281 + rel_extent=rel_extent,
282 + window_left=window_left,
283 + )
284 +
285 + torch.testing.assert_close(out.float(), ref.float(), atol=2e-2, rtol=2e-2)
286 +
287 +
288 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="requires CUDA")
289 +@pytest.mark.skipif(
290 + _cap is None or _cap.major < 9,
291 + reason="FA4 score-mod requires Hopper+ (SM90+)",
292 +)
293 +@pytest.mark.parametrize("num_heads", NUM_HEADS)
294 +@pytest.mark.parametrize(
295 + "seq_lens",
296 + [
297 + [(64, 64)], # single full prefill
298 + [(64, 64), (33, 33), (17, 17)], # ragged prefill batch
299 + [(512, 512)], # seq_len >> rel_extent (most keys get zero bias)
300 + [(300, 300), (512, 512), (129, 129)], # large ragged batch
301 + ],
302 +)
303 +@pytest.mark.parametrize("rel_extent", GLOBAL_REL_EXTENTS)
304 +@torch.inference_mode()
305 +def test_full_attention(seq_lens, num_heads, rel_extent):
306 + # rel_extent=128 exercises the out-of-range (zero bias) path; 1024 covers all.
307 + # With the 512-token cases and rel_extent=128, query/seq lengths are far
308 + # larger than rel_extent so the vast majority of (i, j) pairs are out of
309 + # range and must contribute zero bias.
310 + _run_case(seq_lens, num_heads[0], num_heads[1], rel_extent, window_left=None)
311 +
312 +
313 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="requires CUDA")
314 +@pytest.mark.skipif(
315 + _cap is None or _cap.major < 9,
316 + reason="FA4 score-mod requires Hopper+ (SM90+)",
317 +)
318 +@pytest.mark.parametrize("num_heads", NUM_HEADS)
319 +@pytest.mark.parametrize(
320 + "seq_lens",
321 + [
322 + [(200, 512)], # chunked prefill: q_len=200 (> rel_extent), 312 cached
323 + [(200, 512), (50, 300), (1, 400)], # mixed chunked + decode
324 + ],
325 +)
326 +@pytest.mark.parametrize("rel_extent", GLOBAL_REL_EXTENTS)
327 +@torch.inference_mode()
328 +def test_chunked_prefill(seq_lens, num_heads, rel_extent):
329 + # q_len < kv_len with q_len itself larger than rel_extent (for the 128 case):
330 + # exercises the seqlen_k - seqlen_q offset together with the out-of-range path.
331 + _run_case(seq_lens, num_heads[0], num_heads[1], rel_extent, window_left=None)
332 +
333 +
334 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="requires CUDA")
335 +@pytest.mark.skipif(
336 + _cap is None or _cap.major < 9,
337 + reason="FA4 score-mod requires Hopper+ (SM90+)",
338 +)
339 +@pytest.mark.parametrize("num_heads", NUM_HEADS)
340 +@pytest.mark.parametrize(
341 + "seq_lens",
342 + [
343 + [(64, 64), (40, 40)], # seq_len > window
344 + [(512, 512), (300, 300)], # seq_len/query_len >> window
345 + [(1, 512)], # decode with kv_len >> window
346 + ],
347 +)
348 +@pytest.mark.parametrize("local_extent", LOCAL_REL_EXTENTS)
349 +@torch.inference_mode()
350 +def test_sliding_window(seq_lens, num_heads, local_extent):
351 + # Local layers use window_size=(local_extent-1, 0) and rel_extent==local_extent.
352 + # With the 512-token cases, query/seq lengths far exceed the window so most
353 + # keys are masked out by the sliding window.
354 + _run_case(
355 + seq_lens,
356 + num_heads[0],
357 + num_heads[1],
358 + rel_extent=local_extent,
359 + window_left=local_extent - 1,
360 + )
361 +
362 +
363 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="requires CUDA")
364 +@pytest.mark.skipif(
365 + _cap is None or _cap.major < 9,
366 + reason="FA4 score-mod requires Hopper+ (SM90+)",
367 +)
368 +@pytest.mark.parametrize("num_heads", NUM_HEADS)
369 +@pytest.mark.parametrize(
370 + "seq_lens",
371 + [
372 + [(1, 50)],
373 + [(1, 50), (1, 7), (1, 200)],
374 + [(1, 512), (1, 333)], # kv_len >> rel_extent
375 + ],
376 +)
377 +@pytest.mark.parametrize("rel_extent", GLOBAL_REL_EXTENTS)
378 +@torch.inference_mode()
379 +def test_decode(seq_lens, num_heads, rel_extent):
380 + # q_len=1 with kv_len>q_len: the score-mod's seqlen_k - seqlen_q offset path.
381 + _run_case(seq_lens, num_heads[0], num_heads[1], rel_extent, window_left=None)
tests/models/inkling/test_fa4_warmup.py new
+69
@@ -0,0 +1,69 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +
6 +from vllm.models.inkling.nvidia.ops.fa4_rel_attention import (
7 + bucket_max_seqlen_q,
8 + inkling_fa4_num_splits,
9 +)
10 +from vllm.models.inkling.nvidia.ops.fa4_warmup import (
11 + InklingFA4WarmupConfig,
12 + _iter_compile_units,
13 + _num_warps_bucket,
14 +)
15 +
16 +
17 +def test_bucket_max_seqlen_q():
18 + assert [bucket_max_seqlen_q(n) for n in range(1, 10)] == [
19 + 1,
20 + 2,
21 + 4,
22 + 4,
23 + 8,
24 + 8,
25 + 8,
26 + 8,
27 + 16,
28 + ]
29 +
30 +
31 +def test_warmup_enumerates_every_runtime_compile_class():
32 + config = InklingFA4WarmupConfig(
33 + num_heads=16,
34 + num_kv_heads=2,
35 + head_dim=128,
36 + rel_extent=1024,
37 + window_size=(-1, -1),
38 + is_local=False,
39 + max_kv_len=65536,
40 + dtype=torch.bfloat16,
41 + kv_dtype=torch.bfloat16,
42 + block_size=16,
43 + max_num_reqs=64,
44 + max_num_batched_tokens=192,
45 + )
46 + warmed_keys = {unit.key[-1] for unit in _iter_compile_units(config)}
47 +
48 + for query_len in range(1, config.max_num_batched_tokens + 1):
49 + max_seqlen_q = bucket_max_seqlen_q(query_len)
50 + max_num_reqs = min(
51 + config.max_num_reqs,
52 + config.max_num_batched_tokens - query_len + 1,
53 + )
54 + for num_reqs in range(1, max_num_reqs + 1):
55 + num_splits = inkling_fa4_num_splits(
56 + is_local=config.is_local,
57 + batch_size=num_reqs,
58 + max_query_len=max_seqlen_q,
59 + num_heads=config.num_heads,
60 + num_kv_heads=config.num_kv_heads,
61 + max_kv_len=config.max_kv_len,
62 + )
63 + runtime_key = (
64 + max_seqlen_q,
65 + num_splits,
66 + _num_warps_bucket(num_reqs) if num_splits > 1 else None,
67 + num_reqs > 1024,
68 + )
69 + assert runtime_key in warmed_keys
tests/models/inkling/test_mm_towers.py new
+138
@@ -0,0 +1,138 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Correctness tests for the fused Inkling vision/audio tower kernels.
4 +
5 +Each kernel is checked against a pure-PyTorch reference implementing the
6 +towers' original op sequence (native ``rms_norm`` semantics: fp32 variance,
7 +bf16-rounded weight multiply; exact-erf GELU; fp32-accumulated embedding sum).
8 +The fused kernels keep the same accumulation dtype and rounding points, so
9 +outputs differ from the reference only by reduction order — a few bf16 ulps.
10 +"""
11 +
12 +import pytest
13 +import torch
14 +import torch.nn.functional as F
15 +
16 +from vllm.platforms import current_platform
17 +
18 +if not current_platform.is_cuda():
19 + pytest.skip("requires CUDA", allow_module_level=True)
20 +
21 +from vllm.models.inkling.common.towers import fold_timespace_to_depth
22 +from vllm.models.inkling.nvidia.ops.mm_towers import dmel_embed_sum_norm, rmsnorm_gelu
23 +
24 +DTYPE = torch.bfloat16
25 +
26 +
27 +def _bf16_spacing(x: torch.Tensor) -> torch.Tensor:
28 + return torch.exp2(torch.floor(torch.log2(x.float().abs().clamp(min=1e-30)))) * (
29 + 2**-7
30 + )
31 +
32 +
33 +def _assert_close_ulps(
34 + got: torch.Tensor,
35 + ref: torch.Tensor,
36 + max_ulps: float = 4.0,
37 + atol: float = 1e-3,
38 + pre_act: torch.Tensor | None = None,
39 +) -> None:
40 + """Fused vs reference differ only by fp32 reduction order, which shows up
41 + as a few bf16 ulps at any magnitude — a fixed rtol misrepresents that, so
42 + compare against the reference's local ulp spacing. ``pre_act`` (the
43 + reference pre-activation) adds derivative-propagated slack: near
44 + ``gelu(x) ~ 0`` an ulp-level input flip legitimately moves the output by
45 + many of ITS (tiny) ulps, bounded by |gelu'| <= 1.13 times the input ulps."""
46 + g, r = got.float(), ref.float()
47 + tol = torch.clamp(max_ulps * _bf16_spacing(ref), min=atol)
48 + if pre_act is not None:
49 + tol = tol + 2.5 * _bf16_spacing(pre_act)
50 + bad = (g - r).abs() > tol
51 + assert not bad.any(), (
52 + f"{int(bad.sum())}/{ref.numel()} elements beyond tolerance; "
53 + f"max abs diff {(g - r).abs().max().item():.3e}"
54 + )
55 +
56 +
57 +def _ref_rms_norm(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
58 + # Matches ir.ops.rms_norm: fp32 normalize, bf16 weight multiply.
59 + x32 = x.float()
60 + var = x32.pow(2).mean(dim=-1, keepdim=True)
61 + xn = x32 * torch.rsqrt(var + eps)
62 + return (xn.to(weight.dtype) * weight).to(x.dtype)
63 +
64 +
65 +@pytest.mark.parametrize("rows", [1, 7, 1200, 38400])
66 +@pytest.mark.parametrize("dim", [128, 320, 4800, 6144])
67 +@pytest.mark.parametrize("gelu", [True, False])
68 +def test_rmsnorm_gelu(rows: int, dim: int, gelu: bool) -> None:
69 + torch.manual_seed(rows * dim)
70 + x = torch.randn(rows, dim, device="cuda", dtype=DTYPE)
71 + w = torch.randn(dim, device="cuda", dtype=DTYPE)
72 +
73 + h = _ref_rms_norm(x, w, 1e-5)
74 + ref = F.gelu(h) if gelu else h
75 + got = rmsnorm_gelu(x, w, 1e-5, gelu=gelu)
76 +
77 + _assert_close_ulps(got, ref, pre_act=h if gelu else None)
78 +
79 +
80 +@pytest.mark.parametrize("n", [1, 5, 64])
81 +@pytest.mark.parametrize(
82 + "shape,fold",
83 + [
84 + ((2, 8, 8, 128), (1, 2)), # the real L0 -> L1 vision transition
85 + ((2, 4, 4, 320), (1, 2)),
86 + ((2, 8, 8, 128), (2, 2)), # temporal + spatial fold
87 + ],
88 +)
89 +def test_rmsnorm_gelu_folded_store(
90 + n: int, shape: tuple[int, ...], fold: tuple[int, int]
91 +) -> None:
92 + torch.manual_seed(n)
93 + x = torch.randn(n, *shape, device="cuda", dtype=DTYPE)
94 + w = torch.randn(shape[-1], device="cuda", dtype=DTYPE)
95 +
96 + plain = rmsnorm_gelu(x, w, 1e-5, gelu=True)
97 + ref = fold_timespace_to_depth(plain, *fold)
98 + got = rmsnorm_gelu(x, w, 1e-5, gelu=True, fold=fold)
99 +
100 + # The folded store is a pure permutation of the unfolded output.
101 + assert got.shape == ref.shape
102 + torch.testing.assert_close(got, ref, rtol=0, atol=0)
103 +
104 +
105 +@pytest.mark.parametrize("num_frames", [1, 3, 100, 4097])
106 +@pytest.mark.parametrize("with_norm", [True, False])
107 +def test_dmel_embed_sum_norm(num_frames: int, with_norm: bool) -> None:
108 + torch.manual_seed(num_frames)
109 + n_bins, vocab, dim = 80, 16, 6144
110 + idx = torch.randint(
111 + 0, vocab, (num_frames, n_bins), device="cuda", dtype=torch.int32
112 + )
113 + table = torch.randn(n_bins * vocab, dim, device="cuda", dtype=DTYPE)
114 + norm_w = torch.randn(dim, device="cuda", dtype=DTYPE)
115 +
116 + flat = (torch.arange(n_bins, device="cuda", dtype=torch.int32) * vocab).unsqueeze(
117 + 0
118 + ) + idx
119 + ref = (
120 + F.embedding(flat.reshape(-1).long(), table)
121 + .reshape(num_frames, n_bins, dim)
122 + .sum(dim=1)
123 + )
124 + if with_norm:
125 + ref = _ref_rms_norm(ref, norm_w, 1e-6)
126 +
127 + got = dmel_embed_sum_norm(idx, table, norm_w if with_norm else None, 1e-6)
128 + _assert_close_ulps(got, ref)
129 +
130 +
131 +def test_empty_inputs() -> None:
132 + x = torch.empty(0, 320, device="cuda", dtype=DTYPE)
133 + w = torch.randn(320, device="cuda", dtype=DTYPE)
134 + assert rmsnorm_gelu(x, w, 1e-5).shape == (0, 320)
135 +
136 + idx = torch.empty(0, 80, device="cuda", dtype=torch.int32)
137 + table = torch.randn(1280, 6144, device="cuda", dtype=DTYPE)
138 + assert dmel_embed_sum_norm(idx, table, None, 0.0).shape == (0, 6144)
tests/models/inkling/test_moe_weight_layout.py new
+118
@@ -0,0 +1,118 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from types import SimpleNamespace
5 +
6 +import pytest
7 +import torch
8 +
9 +from vllm.models.inkling.nvidia import moe
10 +from vllm.platforms import current_platform
11 +
12 +
13 +def test_gate_loads_directly_into_padded_runtime_weight() -> None:
14 + gate = moe.InklingGate(
15 + d_model=4,
16 + n_routed_experts=5,
17 + n_shared_experts=2,
18 + experts_per_token=2,
19 + route_scale=1.0,
20 + )
21 + loaded = torch.arange(28, dtype=gate.weight.dtype).reshape(7, 4)
22 +
23 + gate.weight.weight_loader(gate.weight, loaded)
24 +
25 + assert gate.weight.shape == (8, 4)
26 + torch.testing.assert_close(gate.weight[:7], loaded)
27 + torch.testing.assert_close(gate.weight[7], torch.zeros(4))
28 +
29 +
30 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="requires CUDA")
31 +@pytest.mark.parametrize(("num_tokens", "expected_calls"), [(1, 1), (64, 1), (65, 0)])
32 +def test_gate_uses_ll_bf16_gemm_through_token_limit(
33 + monkeypatch, num_tokens, expected_calls
34 +) -> None:
35 + gate = moe.InklingGate(
36 + d_model=8,
37 + n_routed_experts=5,
38 + n_shared_experts=2,
39 + experts_per_token=2,
40 + route_scale=1.0,
41 + ).to(device="cuda", dtype=torch.bfloat16)
42 + hidden_states = torch.randn(num_tokens, 8, device="cuda", dtype=torch.bfloat16)
43 + calls = []
44 +
45 + def fake_ll_bf16_gemm(x, weight):
46 + calls.append((x, weight))
47 + return torch.ones(num_tokens, 8, device="cuda", dtype=torch.float32)
48 +
49 + monkeypatch.setattr(
50 + moe.current_platform, "has_device_capability", lambda capability: True
51 + )
52 + monkeypatch.setattr(moe.ll_bf16, "is_available", lambda: True)
53 + monkeypatch.setattr(moe.ll_bf16, "ll_bf16_gemm", fake_ll_bf16_gemm)
54 +
55 + logits = gate.compute_logits(hidden_states)
56 +
57 + assert len(calls) == expected_calls
58 + if calls:
59 + assert calls[0][0] is hidden_states
60 + assert calls[0][1] is gate.weight
61 + assert logits.shape == (num_tokens, 8)
62 + assert logits.dtype == torch.float32
63 +
64 +
65 +@pytest.mark.parametrize(("projection", "amax"), [("w13", 4.375), ("w2", 2960.0)])
66 +def test_moe_loads_calibrated_input_scale(projection: str, amax: float) -> None:
67 + experts = SimpleNamespace(
68 + w13_input_scale=torch.nn.Parameter(torch.empty(3, 2)),
69 + w2_input_scale=torch.nn.Parameter(torch.empty(3)),
70 + )
71 + layer = SimpleNamespace(experts=SimpleNamespace(routed_experts=experts))
72 +
73 + loaded = moe.InklingMoE.load_expert_weight(
74 + layer,
75 + f"experts.{projection}_weight.input_amax",
76 + torch.tensor([amax]),
77 + )
78 +
79 + scale = getattr(experts, f"{projection}_input_scale")
80 + expected = torch.full_like(scale, amax / (448.0 * 6.0))
81 + torch.testing.assert_close(scale, expected)
82 + assert loaded == [f"experts.routed_experts.{projection}_input_scale"]
83 +
84 +
85 +def test_sink_down_projection_is_packed_during_load(monkeypatch) -> None:
86 + monkeypatch.setattr(moe, "get_tensor_model_parallel_world_size", lambda: 2)
87 + monkeypatch.setattr(moe, "get_tensor_model_parallel_rank", lambda: 1)
88 + sink = moe.InklingSinkExperts(n_experts=2, d_model=3, d_mlp=8)
89 + loaded = torch.arange(48, dtype=sink.w2_weight.dtype).reshape(2, 3, 8)
90 +
91 + sink.load_weight("w2_weight", loaded)
92 +
93 + expected = loaded[:, :, 4:].permute(1, 0, 2).reshape(3, 8)
94 + torch.testing.assert_close(sink.w2_weight, expected)
95 +
96 +
97 +@pytest.mark.skipif(not current_platform.is_cuda(), reason="requires CUDA")
98 +def test_sink_packed_weight_forward_matches_expert_sum(monkeypatch) -> None:
99 + monkeypatch.setattr(moe, "get_tensor_model_parallel_world_size", lambda: 2)
100 + monkeypatch.setattr(moe, "get_tensor_model_parallel_rank", lambda: 1)
101 + sink = moe.InklingSinkExperts(n_experts=2, d_model=3, d_mlp=8).to(
102 + device="cuda", dtype=torch.bfloat16
103 + )
104 + torch.manual_seed(1)
105 + w13 = torch.randn(2, 16, 3, dtype=torch.bfloat16)
106 + w2 = torch.randn(2, 3, 8, dtype=torch.bfloat16)
107 + sink.load_weight("w13_weight", w13)
108 + sink.load_weight("w2_weight", w2)
109 + x = torch.randn(5, 3, device="cuda", dtype=torch.bfloat16)
110 + gammas = torch.randn(5, 2, device="cuda")
111 +
112 + output = sink(x, gammas)
113 +
114 + raw = torch.einsum("td,efd->tef", x, w13[:, 8:].to("cuda"))
115 + hidden = torch.nn.functional.silu(raw[:, :, 0::2].float())
116 + hidden = (hidden * raw[:, :, 1::2] * gammas[:, :, None]).to(torch.bfloat16)
117 + expected = torch.einsum("tef,edf->td", hidden, w2[:, :, 4:].to("cuda"))
118 + torch.testing.assert_close(output, expected, rtol=0, atol=0)
tests/models/inkling/test_qkvr_prep.py new
+258
@@ -0,0 +1,258 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import pytest
5 +import torch
6 +
7 +import vllm.models.inkling.nvidia.ops.sconv as sconv
8 +from vllm.models.inkling.nvidia.ops import qkvr_prep
9 +from vllm.platforms import current_platform
10 +
11 +_cap = current_platform.get_device_capability() if current_platform.is_cuda() else None
12 +
13 +
14 +def _make_inputs(*, is_local: bool, tokens: int = 33, tp_size: int = 4):
15 + torch.manual_seed(0)
16 + heads = 64 // tp_size
17 + kv_heads = (16 if is_local else 8) // tp_size
18 + head_dim = 128
19 + d_rel = 16
20 + rel_extent = 512 if is_local else 1024
21 + page_size = 16
22 + num_blocks = (tokens + page_size - 1) // page_size
23 + q_width = heads * head_dim
24 + kv_width = kv_heads * head_dim
25 + r_width = heads * d_rel
26 + device = "cuda"
27 +
28 + qkvr = torch.randn(
29 + tokens,
30 + q_width + 2 * kv_width + r_width,
31 + device=device,
32 + dtype=torch.bfloat16,
33 + )
34 + k_weight = torch.randn(kv_width, 4, device=device, dtype=torch.bfloat16)
35 + v_weight = torch.randn_like(k_weight)
36 + q_norm_weight = torch.randn(head_dim, device=device, dtype=torch.bfloat16)
37 + k_norm_weight = torch.randn_like(q_norm_weight)
38 + rel_proj = torch.randn(d_rel, rel_extent, device=device, dtype=torch.bfloat16)
39 + conv_cache = torch.zeros(
40 + num_blocks,
41 + kv_heads,
42 + page_size,
43 + 2 * head_dim,
44 + device=device,
45 + dtype=torch.bfloat16,
46 + )
47 + key_cache = torch.empty(
48 + num_blocks,
49 + page_size,
50 + kv_heads,
51 + head_dim,
52 + device=device,
53 + dtype=torch.bfloat16,
54 + )
55 + value_cache = torch.empty_like(key_cache)
56 + positions = torch.arange(tokens, device=device, dtype=torch.int64)
57 + block_table = torch.arange(num_blocks, device=device, dtype=torch.int32)[None]
58 + seq_idx = torch.zeros(tokens, device=device, dtype=torch.int32)
59 + slots = torch.arange(tokens, device=device, dtype=torch.int64)
60 + query_start = torch.zeros(tokens, device=device, dtype=torch.int32)
61 + log_scaling_n_floor = None if is_local else 128000
62 + log_scaling = None
63 + if log_scaling_n_floor is not None:
64 + log_scaling = torch.linspace(
65 + 1.0,
66 + 1.1,
67 + tokens,
68 + device=device,
69 + dtype=torch.float32,
70 + )
71 + return (
72 + qkvr,
73 + k_weight,
74 + v_weight,
75 + q_norm_weight,
76 + k_norm_weight,
77 + rel_proj,
78 + 1e-6,
79 + heads,
80 + kv_heads,
81 + head_dim,
82 + d_rel,
83 + conv_cache,
84 + key_cache,
85 + value_cache,
86 + positions,
87 + block_table,
88 + seq_idx,
89 + slots,
90 + query_start,
91 + slots,
92 + 0,
93 + head_dim,
94 + page_size,
95 + log_scaling,
96 + )
97 +
98 +
99 +def _reference(args):
100 + qkvr = args[0]
101 + heads, kv_heads, head_dim, d_rel = args[7:11]
102 + q_width = heads * head_dim
103 + kv_width = kv_heads * head_dim
104 + q, k, v, r = qkvr.split((q_width, kv_width, kv_width, heads * d_rel), dim=1)
105 + conv_cache = args[11].clone()
106 + k = sconv.fused_sconv(
107 + k.contiguous(),
108 + args[1],
109 + conv_cache,
110 + args[14],
111 + args[15],
112 + args[16],
113 + args[17],
114 + args[18],
115 + args[20],
116 + head_dim,
117 + args[22],
118 + )
119 + v = sconv.fused_sconv(
120 + v.contiguous(),
121 + args[2],
122 + conv_cache,
123 + args[14],
124 + args[15],
125 + args[16],
126 + args[17],
127 + args[18],
128 + args[21],
129 + head_dim,
130 + args[22],
131 + )
132 +
133 + def rms_norm(x, weight):
134 + x = x.reshape(-1, head_dim).float()
135 + rstd = torch.rsqrt(x.square().mean(1, keepdim=True) + args[6])
136 + return (x * rstd * weight.float()).to(qkvr.dtype)
137 +
138 + q = rms_norm(q, args[3]).view(qkvr.shape[0], heads, head_dim)
139 + k = rms_norm(k, args[4]).view(qkvr.shape[0], kv_heads, head_dim)
140 + v = v.view(qkvr.shape[0], kv_heads, head_dim)
141 + rel = torch.mm(r.reshape(-1, d_rel), args[5]).view(qkvr.shape[0], heads, -1)
142 + if args[23] is not None:
143 + q = (q.float() * args[23][:, None, None]).to(q.dtype)
144 + rel = (rel.float() * args[23][:, None, None]).to(rel.dtype)
145 +
146 + key_cache = args[12].clone()
147 + value_cache = args[13].clone()
148 + slots = args[19]
149 + valid = slots >= 0
150 + key_cache.view(-1, kv_heads, head_dim)[slots[valid]] = k[valid]
151 + value_cache.view(-1, kv_heads, head_dim)[slots[valid]] = v[valid]
152 + return q.flatten(1), rel, key_cache, value_cache
153 +
154 +
155 +@pytest.mark.skipif(
156 + _cap is None,
157 + reason="Inkling QKVR prep kernels require CUDA",
158 +)
159 +@pytest.mark.parametrize(
160 + ("is_local", "tokens", "tp_size"),
161 + [
162 + (True, 9, 4),
163 + (False, 33, 4),
164 + (True, 33, 8),
165 + (True, 128, 4),
166 + (False, 128, 8),
167 + (True, 512, 4),
168 + (False, 640, 8),
169 + ],
170 +)
171 +@torch.inference_mode()
172 +def test_qkvr_prep_matches_reference(is_local, tokens, tp_size):
173 + args = _make_inputs(is_local=is_local, tokens=tokens, tp_size=tp_size)
174 + ref_q, ref_rel, ref_key, ref_value = _reference(args)
175 +
176 + q, rel = qkvr_prep.fused_qkvr_prep(*args)
177 +
178 + torch.testing.assert_close(q, ref_q, rtol=0.01, atol=0.02)
179 + torch.testing.assert_close(rel, ref_rel, rtol=0.02, atol=0.125)
180 + torch.testing.assert_close(args[12], ref_key, rtol=0.01, atol=0.01)
181 + torch.testing.assert_close(args[13], ref_value, rtol=0, atol=0)
182 +
183 +
184 +@pytest.mark.skipif(
185 + _cap is None,
186 + reason="Inkling QKVR prep kernels require CUDA",
187 +)
188 +@pytest.mark.parametrize("tokens", [9, 128])
189 +@torch.inference_mode()
190 +def test_qkvr_log_scaling_preserves_bf16_norm_output(tokens):
191 + args = list(_make_inputs(is_local=False, tokens=tokens, tp_size=8))
192 + tau = args[23]
193 + assert tau is not None
194 +
195 + scaled_q, scaled_rel = qkvr_prep.fused_qkvr_prep(*args)
196 + args[23] = None
197 + unscaled_q, unscaled_rel = qkvr_prep.fused_qkvr_prep(*args)
198 +
199 + expected_q = (unscaled_q.float() * tau[:, None]).to(unscaled_q.dtype)
200 + expected_rel = (unscaled_rel.float() * tau[:, None, None]).to(unscaled_rel.dtype)
201 + torch.testing.assert_close(scaled_q, expected_q, rtol=0, atol=0)
202 + torch.testing.assert_close(scaled_rel, expected_rel, rtol=0, atol=0)
203 +
204 +
205 +@pytest.mark.skipif(
206 + _cap is None,
207 + reason="Inkling QKVR prep kernels require CUDA",
208 +)
209 +@pytest.mark.parametrize("tokens", [9, 128])
210 +@torch.inference_mode()
211 +def test_qkvr_prep_negative_slots_skip_cache_writes(tokens):
212 + args = list(_make_inputs(is_local=True, tokens=tokens))
213 + args[17].fill_(-1)
214 + args[19].fill_(-1)
215 + conv_cache = args[11].clone()
216 + key_cache = args[12].clone()
217 + value_cache = args[13].clone()
218 +
219 + qkvr_prep.fused_qkvr_prep(*args)
220 +
221 + torch.testing.assert_close(args[11], conv_cache, rtol=0, atol=0)
222 + torch.testing.assert_close(args[12], key_cache, rtol=0, atol=0)
223 + torch.testing.assert_close(args[13], value_cache, rtol=0, atol=0)
224 +
225 +
226 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
227 +@torch.inference_mode()
228 +def test_fused_sconv_negative_slots_skip_cache_writes():
229 + args = list(_make_inputs(is_local=True, tokens=9))
230 + args[17].fill_(-1)
231 + cache = args[11].clone()
232 +
233 + sconv.fused_sconv(
234 + args[0][:, 16 * 128 : 16 * 128 + 4 * 128].contiguous(),
235 + args[1],
236 + args[11],
237 + args[14],
238 + args[15],
239 + args[16],
240 + args[17],
241 + args[18],
242 + 0,
243 + 128,
244 + args[22],
245 + )
246 +
247 + torch.testing.assert_close(args[11], cache, rtol=0, atol=0)
248 +
249 +
250 +@pytest.mark.parametrize(
251 + ("rel_extent", "last_latency_rows", "first_throughput_rows"),
252 + [(512, 8191, 8192), (1024, 2047, 2048)],
253 +)
254 +def test_rel_projection_schedule_crossover(
255 + rel_extent, last_latency_rows, first_throughput_rows
256 +):
257 + assert not qkvr_prep.use_rel_proj_throughput(last_latency_rows, rel_extent)
258 + assert qkvr_prep.use_rel_proj_throughput(first_throughput_rows, rel_extent)
tests/models/inkling/test_sconv_metadata.py new
+119
@@ -0,0 +1,119 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Parity test for the fused sconv seq-metadata kernel.
4 +
5 +``sconv_seq_metadata`` fills the per-token ``seq_idx`` (owning request) and
6 +``query_start`` (first x-row of that request) buffers in a single launch. Actual
7 +tokens must match the searchsorted-based reference, while CUDA graph padding
8 +must be initialized to safe zero values.
9 +"""
10 +
11 +import pytest
12 +import torch
13 +
14 +from vllm.models.inkling.nvidia.ops.sconv import sconv_seq_metadata
15 +from vllm.models.inkling.nvidia.sconv_swa_attn import InklingSconvMetadataBuilder
16 +
17 +CASES = [
18 + # (query_lens, extra_pad_tokens)
19 + ([1], 0), # bsz1 decode
20 + ([1] * 8, 0), # uniform decode
21 + ([1] * 8, 3), # uniform decode, padded tokens past the last request
22 + ([2] * 4, 0), # uniform spec-decode
23 + ([2048], 0), # single prefill
24 + ([517, 1, 1, 33, 1, 128], 0), # mixed prefill/decode
25 + ([517, 1, 1, 33, 1, 128], 5), # mixed, padded
26 + ([1] * 500, 0), # many requests (deep binary search)
27 +]
28 +
29 +
30 +def _ref(query_start_loc: torch.Tensor, num_reqs: int, num_tokens: int):
31 + cu_seqlens = query_start_loc[: num_reqs + 1].to(torch.int64)
32 + token_idx = torch.arange(num_tokens, device=cu_seqlens.device, dtype=torch.int64)
33 + seq_idx = (torch.searchsorted(cu_seqlens, token_idx, right=True) - 1).clamp(
34 + max=num_reqs - 1
35 + )
36 + return seq_idx.to(torch.int32), cu_seqlens[seq_idx].to(torch.int32)
37 +
38 +
39 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
40 +@pytest.mark.parametrize("query_lens,extra_pad", CASES)
41 +def test_sconv_seq_metadata_matches_searchsorted(query_lens, extra_pad):
42 + device = "cuda"
43 + num_reqs = len(query_lens)
44 + query_start_loc = torch.tensor(
45 + [0] + list(torch.tensor(query_lens).cumsum(0)), dtype=torch.int32
46 + ).to(device)
47 + num_actual_tokens = int(query_start_loc[-1])
48 + num_padded_tokens = num_actual_tokens + extra_pad
49 +
50 + ref_seq, ref_qs = _ref(query_start_loc, num_reqs, num_actual_tokens)
51 +
52 + seq_idx = torch.full((num_padded_tokens,), -1, dtype=torch.int32, device=device)
53 + query_start = torch.full_like(seq_idx, -1)
54 + sconv_seq_metadata(
55 + query_start_loc,
56 + num_reqs,
57 + num_actual_tokens,
58 + seq_idx,
59 + query_start,
60 + num_padded_tokens,
61 + )
62 +
63 + torch.testing.assert_close(seq_idx[:num_actual_tokens], ref_seq, rtol=0, atol=0)
64 + torch.testing.assert_close(query_start[:num_actual_tokens], ref_qs, rtol=0, atol=0)
65 + assert torch.count_nonzero(seq_idx[num_actual_tokens:]) == 0
66 + assert torch.count_nonzero(query_start[num_actual_tokens:]) == 0
67 +
68 +
69 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
70 +def test_sconv_metadata_reuses_padded_static_buffers():
71 + device = torch.device("cuda")
72 + builder = object.__new__(InklingSconvMetadataBuilder)
73 + builder.seq_idx_buffer = torch.empty(8, dtype=torch.int32, device=device)
74 + builder.query_start_buffer = torch.empty(8, dtype=torch.int32, device=device)
75 +
76 + class CommonMetadata:
77 + num_reqs = 1
78 + num_actual_tokens = 8
79 + query_start_loc = torch.tensor([0, 3], dtype=torch.int32, device=device)
80 + query_start_loc_cpu = torch.tensor([0, 3], dtype=torch.int32)
81 + block_table_tensor = torch.zeros((1, 1), dtype=torch.int32, device=device)
82 + slot_mapping = torch.tensor(
83 + [0, 1, 2, -1, -1, -1, -1, -1],
84 + dtype=torch.int64,
85 + device=device,
86 + )
87 +
88 + common = CommonMetadata()
89 + first = builder.build(0, common)
90 + pointers = tuple(
91 + tensor.data_ptr()
92 + for tensor in (
93 + first.block_table,
94 + first.slot_mapping,
95 + first.seq_idx,
96 + first.query_start,
97 + )
98 + )
99 +
100 + common.query_start_loc[1] = 5
101 + common.query_start_loc_cpu[1] = 5
102 + common.slot_mapping[:5] = torch.arange(5, dtype=torch.int64, device=device)
103 + second = builder.build(0, common)
104 +
105 + assert second.slot_mapping.shape == (8,)
106 + assert second.seq_idx.shape == (8,)
107 + assert second.query_start.shape == (8,)
108 + assert pointers == tuple(
109 + tensor.data_ptr()
110 + for tensor in (
111 + second.block_table,
112 + second.slot_mapping,
113 + second.seq_idx,
114 + second.query_start,
115 + )
116 + )
117 + assert torch.count_nonzero(second.seq_idx[5:]) == 0
118 + assert torch.count_nonzero(second.query_start[5:]) == 0
119 + assert torch.all(second.slot_mapping[5:] == -1)
tests/models/registry.py
+12
@@ -330,6 +330,12 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
330 "naver-hyperclovax/HyperCLOVAX-SEED-Think-14B",
331 min_transformers_version="5.9.0",
332 ),
333 + "InklingForCausalLM": _HfExamplesInfo(
334 + "thinkingmachines/Inkling-NVFP4",
335 + tokenizer_mode="inkling",
336 + trust_remote_code=True,
337 + max_model_len=4096,
338 + ),
339 "InternLM2ForCausalLM": _HfExamplesInfo(
340 "internlm/internlm2-chat-7b", trust_remote_code=True
341 ),
@@ -956,6 +962,12 @@ _MULTIMODAL_EXAMPLE_MODELS = {
962 "HuggingFaceM4/Idefics3-8B-Llama3",
963 extras={"tiny": "HuggingFaceTB/SmolVLM-256M-Instruct"},
964 ),
965 + "InklingForConditionalGeneration": _HfExamplesInfo(
966 + "thinkingmachines/Inkling-NVFP4",
967 + tokenizer_mode="inkling",
968 + trust_remote_code=True,
969 + max_model_len=4096,
970 + ),
971 "IsaacForConditionalGeneration": _HfExamplesInfo(
972 "PerceptronAI/Isaac-0.1",
973 trust_remote_code=True,
tests/parser/engine/test_delegating_replay.py
+5
@@ -88,6 +88,11 @@ def _discover_pairings() -> list[_PairingInfo]:
88 if cfg.name not in _BUILDERS:
89 missing_builders.append(f"{engine_cls.__name__} (config.name={cfg.name!r})")
90 continue
91 + if cfg.name == "inkling":
92 + # Inkling uses typed structural blocks and opts out of token-id
93 + # terminal matching; combined-parser replay coverage lives in
94 + # test_inkling.py.
95 + continue
96
97 parser_cls = type(
98 f"_Delegating{engine_cls.__name__}",
tests/parser/engine/test_inkling.py new
+506
@@ -0,0 +1,506 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for the engine-based Inkling parser.
4 +
5 +Inkling output is a sequence of typed content blocks delimited by dedicated
6 +special tokens; the tool-call payload is ``{"name":...,"args":{...}}``
7 +between ``<|content_invoke_tool_json|>`` and ``<|end_message|>``. The
8 +cases mirror the Rust unified parser's tests
9 +(``rust/src/parser/src/unified/inkling.rs``) where applicable.
10 +"""
11 +
12 +import json
13 +
14 +import pytest
15 +
16 +from tests.parser.engine.conftest import make_mock_tokenizer
17 +from tests.parser.engine.streaming_helpers import (
18 + collect_content,
19 + collect_function_name,
20 + collect_tool_arguments,
21 +)
22 +from vllm.parser.engine.parser_engine_config import ParserState
23 +from vllm.parser.inkling import InklingParser, _inkling_arg_converter
24 +from vllm.parser.parser_manager import ParserManager
25 +
26 +MSG_MODEL = "<|message_model|>"
27 +TEXT_START = "<|content_text|>"
28 +THINK_START = "<|content_thinking|>"
29 +TOOL_JSON = "<|content_invoke_tool_json|>"
30 +TOOL_TEXT = "<|content_invoke_tool_text|>"
31 +TOOL_ERROR = "<|content_tool_error|>"
32 +END_MESSAGE = "<|end_message|>"
33 +END_SAMPLING = "<|content_model_end_sampling|>"
34 +
35 +_TML_VOCAB = {
36 + MSG_MODEL: 200001,
37 + TEXT_START: 200004,
38 + END_SAMPLING: 200006,
39 + THINK_START: 200008,
40 + END_MESSAGE: 200010,
41 + TOOL_ERROR: 200022,
42 + TOOL_JSON: 200049,
43 + TOOL_TEXT: 200057,
44 +}
45 +
46 +
47 +@pytest.fixture
48 +def mock_tokenizer():
49 + return make_mock_tokenizer(_TML_VOCAB)
50 +
51 +
52 +@pytest.fixture
53 +def parser(mock_tokenizer):
54 + return InklingParser(mock_tokenizer)
55 +
56 +
57 +def _tool_block(name: str, args: str) -> str:
58 + return f'{TOOL_JSON}{{"name":"{name}","args":{args}}}{END_MESSAGE}'
59 +
60 +
61 +_MARKERS = sorted(_TML_VOCAB, key=len, reverse=True)
62 +
63 +
64 +def _tokenize(text: str) -> list[tuple[int, str]]:
65 + """Tokenize like the real stream: markers are atomic special tokens,
66 + plain text becomes one token per character (matching the mock
67 + tokenizer's ``chr``-based decode)."""
68 + tokens: list[tuple[int, str]] = []
69 + i = 0
70 + while i < len(text):
71 + for marker in _MARKERS:
72 + if text.startswith(marker, i):
73 + tokens.append((_TML_VOCAB[marker], marker))
74 + i += len(marker)
75 + break
76 + else:
77 + tokens.append((ord(text[i]), text[i]))
78 + i += 1
79 + return tokens
80 +
81 +
82 +def _stream(parser, request, text: str, chunk_size: int):
83 + """Stream production-shaped deltas: ``chunk_size`` tokens per delta,
84 + with delta_token_ids covering every token (specials and text)."""
85 + tokens = _tokenize(text)
86 + results = []
87 + previous_text = ""
88 + previous_token_ids: list[int] = []
89 + for start in range(0, len(tokens), chunk_size):
90 + batch = tokens[start : start + chunk_size]
91 + delta_text = "".join(t for _, t in batch)
92 + delta_token_ids = [tid for tid, _ in batch]
93 + current_text = previous_text + delta_text
94 + current_token_ids = previous_token_ids + delta_token_ids
95 + delta = parser.extract_tool_calls_streaming(
96 + previous_text=previous_text,
97 + current_text=current_text,
98 + delta_text=delta_text,
99 + previous_token_ids=tuple(previous_token_ids),
100 + current_token_ids=tuple(current_token_ids),
101 + delta_token_ids=tuple(delta_token_ids),
102 + request=request,
103 + )
104 + results.append((delta, current_text))
105 + previous_text = current_text
106 + previous_token_ids = current_token_ids
107 + finish = parser.finish_streaming()
108 + if finish is not None:
109 + results.append((finish, text))
110 + return results
111 +
112 +
113 +def _stream_text_only(parser, request, text: str, chunk_size: int):
114 + """Stream text-only deltas (no token ids), chunked at arbitrary
115 + character boundaries — exercises the text-lexing fallback path,
116 + including markers split across chunks."""
117 + results = []
118 + previous_text = ""
119 + for start in range(0, len(text), chunk_size):
120 + delta_text = text[start : start + chunk_size]
121 + current_text = previous_text + delta_text
122 + delta = parser.extract_tool_calls_streaming(
123 + previous_text=previous_text,
124 + current_text=current_text,
125 + delta_text=delta_text,
126 + previous_token_ids=(),
127 + current_token_ids=(),
128 + delta_token_ids=(),
129 + request=request,
130 + )
131 + results.append((delta, current_text))
132 + previous_text = current_text
133 + finish = parser.finish_streaming()
134 + if finish is not None:
135 + results.append((finish, text))
136 + return results
137 +
138 +
139 +def _collect_reasoning(results) -> str:
140 + return "".join(d.reasoning for d, _ in results if d and d.reasoning)
141 +
142 +
143 +class TestArgConverter:
144 + def test_complete_wrapper(self):
145 + raw = '{"name":"get_weather","args":{"city":"SF"}}'
146 + assert _inkling_arg_converter(raw, False) == '{"city":"SF"}'
147 +
148 + def test_partial_before_args(self):
149 + assert _inkling_arg_converter('{"name":"get_w', True) == ""
150 +
151 + def test_partial_inside_args(self):
152 + raw = '{"name":"x","args":{"a":1'
153 + assert _inkling_arg_converter(raw, True) == '{"a":1'
154 +
155 + def test_prefix_stability(self):
156 + full = '{"name":"x","args":{"a":{"b":[1,2]},"c":"d"}}'
157 + prev = ""
158 + for end in range(len(full)):
159 + out = _inkling_arg_converter(full[:end], True)
160 + assert out.startswith(prev) or prev.startswith(out) or not prev
161 + if out.startswith(prev):
162 + prev = out
163 +
164 + def test_args_value_appearing_in_name(self):
165 + raw = '{"name":"args","args":{"k":1}}'
166 + assert _inkling_arg_converter(raw, False) == '{"k":1}'
167 +
168 + def test_whitespace_tolerated(self):
169 + raw = '{ "name" : "x" , "args" : {"a": 1} }'
170 + assert _inkling_arg_converter(raw, False) == '{"a": 1}'
171 +
172 + def test_missing_args_defaults_empty(self):
173 + assert _inkling_arg_converter('{"name":"x"}', False) == "{}"
174 +
175 + def test_non_object_args_rejected(self):
176 + with pytest.raises(ValueError, match="JSON object"):
177 + _inkling_arg_converter('{"name":"x","args":[1]}', False)
178 +
179 +
180 +class TestNonStreaming:
181 + def test_plain_text(self, parser, mock_request):
182 + reasoning, content, tools = parser.parse(
183 + f"{TEXT_START}hello world{END_MESSAGE}", mock_request
184 + )
185 + assert reasoning is None
186 + assert content == "hello world"
187 + assert tools is None
188 +
189 + def test_reasoning_text_tool(self, parser, mock_request):
190 + text = (
191 + f"{THINK_START}I should check the weather.{END_MESSAGE}"
192 + f"{MSG_MODEL}{TEXT_START}Let me check.{END_MESSAGE}"
193 + f"{MSG_MODEL}" + _tool_block("get_weather", '{"city":"SF"}')
194 + )
195 + reasoning, content, tools = parser.parse(text, mock_request)
196 + assert reasoning == "I should check the weather."
197 + assert content == "Let me check."
198 + assert [t.name for t in tools] == ["get_weather"]
199 + assert json.loads(tools[0].arguments) == {"city": "SF"}
200 +
201 + def test_tool_header_name_is_not_visible_content(self, parser, mock_request):
202 + text = "get_weather" + _tool_block("get_weather", '{"city":"SF"}')
203 + _, content, tools = parser.parse(text, mock_request)
204 + assert content is None
205 + assert [tool.name for tool in tools] == ["get_weather"]
206 +
207 + def test_parallel_tool_calls(self, parser, mock_request):
208 + text = _tool_block("a", "{}") + MSG_MODEL + _tool_block("b", '{"x":[1,2]}')
209 + _, _, tools = parser.parse(text, mock_request)
210 + assert [t.name for t in tools] == ["a", "b"]
211 + assert json.loads(tools[0].arguments) == {}
212 + assert json.loads(tools[1].arguments) == {"x": [1, 2]}
213 +
214 + def test_nested_args(self, parser, mock_request):
215 + args = '{"q":{"deep":{"list":[{"k":"v"}]}},"s":"a}b"}'
216 + _, _, tools = parser.parse(_tool_block("f", args), mock_request)
217 + assert json.loads(tools[0].arguments) == json.loads(args)
218 +
219 + def test_invoke_tool_text_is_visible_text(self, parser, mock_request):
220 + reasoning, content, tools = parser.parse(
221 + f"{TOOL_TEXT}do something{END_MESSAGE}", mock_request
222 + )
223 + assert content == "do something"
224 + assert tools is None
225 +
226 + def test_tool_error_is_visible_text(self, parser, mock_request):
227 + _, content, tools = parser.parse(f"{TOOL_ERROR}boom{END_MESSAGE}", mock_request)
228 + assert content == "boom"
229 + assert tools is None
230 +
231 + def test_end_sampling_closes_blocks(self, parser, mock_request):
232 + reasoning, content, _ = parser.parse(
233 + f"{THINK_START}hm{END_MESSAGE}{MSG_MODEL}{TEXT_START}hi{END_SAMPLING}",
234 + mock_request,
235 + )
236 + assert reasoning == "hm"
237 + assert content == "hi"
238 +
239 + def test_multiple_reasoning_blocks_concatenate(self, parser, mock_request):
240 + text = (
241 + f"{THINK_START}one{END_MESSAGE}"
242 + f"{MSG_MODEL}{TEXT_START}mid{END_MESSAGE}"
243 + f"{MSG_MODEL}{THINK_START}two{END_MESSAGE}"
244 + )
245 + reasoning, content, _ = parser.parse(text, mock_request)
246 + assert reasoning == "onetwo"
247 + assert content == "mid"
248 +
249 + def test_text_after_tool_call(self, parser, mock_request):
250 + text = _tool_block("f", "{}") + f"{MSG_MODEL}{TEXT_START}done{END_MESSAGE}"
251 + _, content, tools = parser.parse(text, mock_request)
252 + assert [t.name for t in tools] == ["f"]
253 + assert content == "done"
254 +
255 + def test_incomplete_tool_call_at_eos(self, parser, mock_request):
256 + # Engine convention: best-effort with what arrived. (The Rust
257 + # parser instead errors with "incomplete Inkling tool call".)
258 + _, _, tools = parser.parse(
259 + f'{TOOL_JSON}{{"name":"d","args":{{"k":"v"', mock_request
260 + )
261 + assert [t.name for t in tools] == ["d"]
262 +
263 + def test_prose_marker_without_token_ids_is_structural(self, parser, mock_request):
264 + # Inkling opts into text-lexer terminal recognition so held-back
265 + # structural marker text from the detokenizer is still parsed.
266 + _, content, _ = parser.parse(
267 + f"{TEXT_START}see {TEXT_START} token{END_MESSAGE}", mock_request
268 + )
269 + assert content == "see token"
270 +
271 +
272 +class TestStreaming:
273 + @pytest.mark.parametrize("chunk_size", [1, 3, 7, 64, 4096])
274 + def test_chunk_invariance_tool_call(self, mock_tokenizer, mock_request, chunk_size):
275 + parser = InklingParser(mock_tokenizer)
276 + text = f"{TEXT_START}Check this.{END_MESSAGE}{MSG_MODEL}" + _tool_block(
277 + "get_weather", '{"city":"San Francisco"}'
278 + )
279 + results = _stream(parser, mock_request, text, chunk_size)
280 + assert collect_content(results) == "Check this."
281 + assert collect_function_name(results) == "get_weather"
282 + assert json.loads(collect_tool_arguments(results)) == {"city": "San Francisco"}
283 +
284 + @pytest.mark.parametrize("chunk_size", [1, 3, 7, 64])
285 + def test_chunk_invariance_tool_call_text_only(
286 + self, mock_tokenizer, mock_request, chunk_size
287 + ):
288 + # Same case through the text-lexing fallback (no token ids),
289 + # with markers split at arbitrary character boundaries.
290 + parser = InklingParser(mock_tokenizer)
291 + text = f"{TEXT_START}Check this.{END_MESSAGE}{MSG_MODEL}" + _tool_block(
292 + "get_weather", '{"city":"San Francisco"}'
293 + )
294 + results = _stream_text_only(parser, mock_request, text, chunk_size)
295 + assert collect_content(results) == "Check this."
296 + assert collect_function_name(results) == "get_weather"
297 + assert json.loads(collect_tool_arguments(results)) == {"city": "San Francisco"}
298 +
299 + @pytest.mark.parametrize("chunk_size", [1, 5, 11])
300 + def test_chunk_invariance_reasoning(self, mock_tokenizer, mock_request, chunk_size):
301 + parser = InklingParser(mock_tokenizer)
302 + text = (
303 + f"{THINK_START}thinking...{END_MESSAGE}"
304 + f"{MSG_MODEL}{TEXT_START}answer{END_MESSAGE}"
305 + )
306 + results = _stream(parser, mock_request, text, chunk_size)
307 + assert _collect_reasoning(results) == "thinking..."
308 + assert collect_content(results) == "answer"
309 +
310 + def test_split_marker_held_across_chunks(self, parser, mock_request):
311 + # Mirrors Rust `inkling_streaming_holds_split_markers`.
312 + text = f"{TEXT_START}hello{END_MESSAGE}"
313 + results = _stream_text_only(parser, mock_request, text, 9)
314 + assert collect_content(results) == "hello"
315 +
316 + def test_name_streams_before_args_complete(self, parser, mock_request):
317 + # Feed only up to the name's closing quote — the name delta must
318 + # already be emitted before any args arrive.
319 + prefix = f'{TOOL_JSON}{{"name":"get_weather",'
320 + results = _stream(parser, mock_request, prefix, 4096)
321 + assert collect_function_name(results) == "get_weather"
322 +
323 + def test_combined_parser_reasoning_to_tool_handoff_uses_text_markers(
324 + self, mock_tokenizer, mock_request
325 + ):
326 + parser_cls = ParserManager.get_parser(
327 + tool_parser_name="inkling",
328 + reasoning_parser_name="inkling",
329 + enable_auto_tools=True,
330 + )
331 + parser = parser_cls(mock_tokenizer, [])
332 +
333 + first = parser.parse_delta(
334 + THINK_START,
335 + [_TML_VOCAB[THINK_START]],
336 + mock_request,
337 + prompt_token_ids=[_TML_VOCAB[MSG_MODEL]],
338 + finished=False,
339 + )
340 + assert first is None
341 +
342 + second = parser.parse_delta(
343 + "thinking",
344 + [ord(c) for c in "thinking"],
345 + mock_request,
346 + finished=False,
347 + )
348 + assert second is not None
349 + assert second.reasoning == "thinking"
350 +
351 + # Mirrors the DelegatingParser handoff after reasoning closes: the
352 + # tool pass receives reconstructed text that starts at the Inkling
353 + # tool marker, while the token-id slice has already moved past it.
354 + body = (
355 + "get_weather"
356 + f'{TOOL_JSON}{{"name":"get_weather","args":{{"city":"Seattle"}}}}'
357 + f"{END_MESSAGE}"
358 + )
359 + third = parser.parse_delta(
360 + body,
361 + [_TML_VOCAB[END_MESSAGE], _TML_VOCAB[END_SAMPLING]],
362 + mock_request,
363 + finished=True,
364 + )
365 + assert third is not None
366 + assert third.tool_calls
367 + assert third.tool_calls[0].function.name == "get_weather"
368 + assert third.tool_calls[0].function.arguments == '{"city":"Seattle"}'
369 + assert TOOL_JSON not in ((third.content or "") + (third.reasoning or ""))
370 +
371 + def test_streamed_args_are_object_only(self, parser, mock_request):
372 + # The streamed `arguments` must be the bare args object, never
373 + # the `{"name":...}` wrapper.
374 + text = _tool_block("f", '{"a":1}')
375 + results = _stream(parser, mock_request, text, 3)
376 + args = collect_tool_arguments(results)
377 + assert json.loads(args) == {"a": 1}
378 + assert "name" not in args
379 +
380 + @pytest.mark.parametrize("chunk_size", [1, 9])
381 + def test_parallel_calls_streaming(self, mock_tokenizer, mock_request, chunk_size):
382 + parser = InklingParser(mock_tokenizer)
383 + text = _tool_block("a", '{"i":1}') + MSG_MODEL + _tool_block("b", '{"i":2}')
384 + results = _stream(parser, mock_request, text, chunk_size)
385 + indexed: dict[int, dict[str, str]] = {}
386 + for delta, _ in results:
387 + if not (delta and delta.tool_calls):
388 + continue
389 + for tc in delta.tool_calls:
390 + slot = indexed.setdefault(tc.index, {"name": "", "args": ""})
391 + if tc.function and tc.function.name:
392 + slot["name"] = tc.function.name
393 + if tc.function and tc.function.arguments:
394 + slot["args"] += tc.function.arguments
395 + assert indexed[0]["name"] == "a"
396 + assert indexed[1]["name"] == "b"
397 + assert json.loads(indexed[0]["args"]) == {"i": 1}
398 + assert json.loads(indexed[1]["args"]) == {"i": 2}
399 +
400 +
401 +class TestPromptSeededState:
402 + def test_prompt_ending_in_thinking_starts_reasoning(self, parser, mock_request):
403 + parser.adjust_initial_state_from_prompt([200001, _TML_VOCAB[THINK_START]])
404 + assert parser._engine.state == ParserState.REASONING
405 +
406 + def test_prompt_ending_in_text_starts_content(self, parser):
407 + parser.adjust_initial_state_from_prompt([200001, _TML_VOCAB[TEXT_START]])
408 + assert parser._engine.state == ParserState.CONTENT
409 +
410 + def test_generation_prompt_tail_starts_message_header(self, parser):
411 + parser.adjust_initial_state_from_prompt(
412 + [_TML_VOCAB[END_MESSAGE], _TML_VOCAB[MSG_MODEL]]
413 + )
414 + assert parser._engine.state == ParserState.MESSAGE_HEADER
415 +
416 + def test_generation_prompt_header_hides_tool_name(self, parser, mock_request):
417 + text = "get_weather" + _tool_block("get_weather", '{"city":"SF"}')
418 + delta = parser.parse_delta(
419 + text,
420 + [token_id for token_id, _ in _tokenize(text)],
421 + mock_request,
422 + prompt_token_ids=[_TML_VOCAB[END_MESSAGE], _TML_VOCAB[MSG_MODEL]],
423 + finished=True,
424 + )
425 + assert delta is not None
426 + assert delta.content is None
427 + assert delta.tool_calls[0].function.name == "get_weather"
428 +
429 +
430 +class TestToolCallFiltering:
431 + """Inkling equivalents of the generic tool-call-filtering replay tests
432 + (Inkling is excluded from those in test_replay.py: its structural
433 + role/kind tokens and shared block-end token don't fit the generic
434 + reasoning/tool split model)."""
435 +
436 + def test_skip_tool_parsing_round_trip(self, mock_tokenizer, mock_request):
437 + # First pass (reasoning adapter, skip_tool_parsing): reasoning is
438 + # classified as reasoning while tool markup survives in content;
439 + # second pass (tool adapter) re-extracts the calls from it.
440 + text = (
441 + f"{THINK_START}plan{END_MESSAGE}{MSG_MODEL}"
442 + + _tool_block("f", '{"a":1}')
443 + + MSG_MODEL
444 + + _tool_block("g", '{"b":[2]}')
445 + )
446 + first = InklingParser(mock_tokenizer)
447 + first.skip_tool_parsing = True
448 + reasoning, content = first.extract_reasoning(text, mock_request)
449 + assert reasoning == "plan"
450 + assert content.count(TOOL_JSON) == 2
451 +
452 + second = InklingParser(mock_tokenizer)
453 + result = second.extract_tool_calls_from_content(content, mock_request)
454 + assert result.tools_called
455 + assert [tc.function.name for tc in result.tool_calls] == ["f", "g"]
456 + assert json.loads(result.tool_calls[0].function.arguments) == {"a": 1}
457 + assert json.loads(result.tool_calls[1].function.arguments) == {"b": [2]}
458 +
459 + @pytest.fixture
460 + def none_request(self, mock_request):
461 + mock_request.tools = [{"type": "function", "function": {"name": "f"}}]
462 + mock_request.tool_choice = "none"
463 + return mock_request
464 +
465 + def test_tool_choice_none_non_streaming(self, mock_tokenizer, none_request):
466 + parser = InklingParser(mock_tokenizer)
467 + text = (
468 + f"{THINK_START}plan{END_MESSAGE}"
469 + f"{MSG_MODEL}{TEXT_START}visible{END_MESSAGE}"
470 + f"{MSG_MODEL}" + _tool_block("f", '{"a":1}')
471 + )
472 + reasoning, content, tools = parser.parse(text, none_request)
473 + assert reasoning == "plan"
474 + assert content == "visible"
475 + assert not tools
476 +
477 + def test_tool_choice_none_streaming(self, mock_tokenizer, none_request):
478 + parser = InklingParser(mock_tokenizer)
479 + text = f"{TEXT_START}visible{END_MESSAGE}{MSG_MODEL}" + _tool_block(
480 + "f", '{"a":1}'
481 + )
482 + results = _stream(parser, none_request, text, 3)
483 + assert collect_content(results) == "visible"
484 + assert all(not (d and d.tool_calls) for d, _ in results)
485 +
486 +
487 +class TestRegisteredAdapters:
488 + def test_adapters_resolve(self):
489 + from vllm.reasoning import ReasoningParserManager
490 + from vllm.tool_parsers import ToolParserManager
491 +
492 + reasoning_cls = ReasoningParserManager.get_reasoning_parser("inkling")
493 + tool_cls = ToolParserManager.get_tool_parser("inkling")
494 + assert reasoning_cls._parser_engine_cls is InklingParser
495 + assert tool_cls._parser_engine_cls is InklingParser
496 + assert tool_cls.supports_required_and_named is False
497 +
498 + def test_adapter_round_trip(self, mock_tokenizer, mock_request):
499 + from vllm.tool_parsers import ToolParserManager
500 +
501 + tool_cls = ToolParserManager.get_tool_parser("inkling")
502 + adapter = tool_cls(mock_tokenizer)
503 + result = adapter.extract_tool_calls(_tool_block("f", '{"a":1}'), mock_request)
504 + assert result.tools_called
505 + assert result.tool_calls[0].function.name == "f"
506 + assert json.loads(result.tool_calls[0].function.arguments) == {"a": 1}
tests/parser/engine/test_replay.py
+10 -1
@@ -68,6 +68,10 @@ def _discover_parsers() -> list[_ParserInfo]:
68 if cfg.name not in _BUILDERS:
69 missing_builders.append(f"{obj.__name__} (config.name={cfg.name!r})")
70 continue
71 + if cfg.name == "inkling":
72 + # Inkling opts out of token-id terminal matching and has typed
73 + # structural blocks; its replay coverage lives in test_inkling.py.
74 + continue
75 tool_end = cfg.token_id_terminals.get("TOOL_END")
76 if not tool_end:
77 raise RuntimeError(
@@ -299,7 +303,12 @@ _TOOL_CALL_SAMPLES = [
303 (p.parser_cls, s, p.think_end, p.tool_start)
304 for p in _PARSERS
305 for s in p.samples
302 - if s.expected_tool_calls and s.expected_reasoning
306 + if s.expected_tool_calls
307 + and s.expected_reasoning
308 + # Inkling's typed-block format (structural role/kind tokens, shared
309 + # block-end token) doesn't fit the generic reasoning/tool split
310 + # below; its filtering modes are covered in test_inkling.py instead.
311 + and p.name != "inkling"
312 ]
313
314
tests/parser/engine/trace_builder.py
+73
@@ -33,6 +33,7 @@ from vllm.parser.engine.registered_adapters import (
33 DeepSeekV32Parser,
34 Gemma4Parser,
35 Glm47MoeParser,
36 + InklingParser,
37 KimiK2Parser,
38 MinimaxM2Parser,
39 NemotronV3Parser,
@@ -948,6 +949,77 @@ _KIMI_K2_SCENARIOS = [
949 ]
950
951
952 +# ── Inkling (typed content blocks, JSON tool payloads) ───────────────────
953 +
954 +_TML_VOCAB: dict[str, int] = {
955 + "<|message_model|>": 200001,
956 + "<|content_text|>": 200004,
957 + "<|content_model_end_sampling|>": 200006,
958 + "<|content_thinking|>": 200008,
959 + "<|end_message|>": 200010,
960 + "<|content_tool_error|>": 200022,
961 + "<|content_invoke_tool_json|>": 200049,
962 + "<|content_invoke_tool_text|>": 200057,
963 +}
964 +
965 +
966 +def _inkling_block(
967 + segs: list[tuple[str, bool]],
968 + kind_token: str,
969 + body: str,
970 +) -> None:
971 + """Append one Inkling content block; blocks after the first start with
972 + the ``<|message_model|>`` role token (the first block continues the
973 + generation prompt directly)."""
974 + if segs:
975 + segs.append(("<|message_model|>", True))
976 + segs.append((kind_token, True))
977 + if body:
978 + segs.append((body, False))
979 + segs.append(("<|end_message|>", True))
980 +
981 +
982 +def _inkling_segments(scenario: Scenario) -> list[tuple[str, bool]]:
983 + segs: list[tuple[str, bool]] = []
984 + if scenario.reasoning is not None:
985 + _inkling_block(segs, "<|content_thinking|>", scenario.reasoning)
986 + if scenario.tool_calls is not None and not scenario.tool_calls:
987 + _inkling_block(segs, "<|content_invoke_tool_json|>", "")
988 + if scenario.content is not None:
989 + _inkling_block(segs, "<|content_text|>", scenario.content)
990 + if scenario.tool_calls:
991 + for tc in scenario.tool_calls:
992 + args = json.dumps(tc.arguments, ensure_ascii=False, separators=(",", ":"))
993 + payload = f'{{"name":"{tc.name}","args":{args}}}'
994 + _inkling_block(segs, "<|content_invoke_tool_json|>", payload)
995 + return segs
996 +
997 +
998 +def _build_inkling(scenario: Scenario, validate: bool = True) -> Sample:
999 + prompt_token_ids = None
1000 + if scenario.after_tool_response:
1001 + # Prompt ends with a closed tool-response block and the
1002 + # generation-prompt role token.
1003 + prompt_token_ids = [
1004 + _TML_VOCAB["<|end_message|>"],
1005 + _TML_VOCAB["<|message_model|>"],
1006 + ]
1007 + sample = _make_sample(
1008 + sample_id=f"inkling-{scenario.id}",
1009 + description=scenario.description,
1010 + vocab=_TML_VOCAB,
1011 + segments=_inkling_segments(scenario),
1012 + expected_reasoning=scenario.reasoning,
1013 + expected_content=_qwen3_expected_content(scenario),
1014 + expected_tool_calls=_expected_tc(scenario),
1015 + tools=_expected_tools(scenario),
1016 + prompt_token_ids=prompt_token_ids,
1017 + )
1018 + if validate:
1019 + _validate_sample(sample, InklingParser)
1020 + return sample
1021 +
1022 +
1023 # ── Registry and public API ──────────────────────────────────────────
1024
1025 _BUILDERS: dict[str, Any] = {
@@ -960,6 +1032,7 @@ _BUILDERS: dict[str, Any] = {
1032 "glm47_moe": _build_glm47_moe,
1033 "kimi_k2": _build_kimi_k2,
1034 "qwen3": _build_qwen3,
1035 + "inkling": _build_inkling,
1036 }
1037
1038
tests/renderers/test_inkling.py new
+484
@@ -0,0 +1,484 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Golden tests for the native Inkling renderer encoding.
4 +
5 +The message fixtures mirror the Rust renderer's fixture tests
6 +(``rust/src/chat/src/renderer/inkling/tests.rs``) so the two frontends stay
7 +in token-level parity. Image blocks emit the bare ``<|content_image|>`` marker;
8 +``InklingMultiModalProcessor`` inserts the per-patch placeholder run.
9 +"""
10 +
11 +import pytest
12 +
13 +from vllm.renderers.inkling import (
14 + InklingRenderer,
15 + _HfBackedTmlTokenizer,
16 + _resolve_reasoning_effort,
17 +)
18 +from vllm.renderers.inkling_encoding import (
19 + SPECIAL_TOKEN_SPELLINGS,
20 + render_inkling_messages,
21 +)
22 +from vllm.renderers.params import ChatParams
23 +
24 +
25 +@pytest.fixture()
26 +def should_do_global_cleanup_after_test() -> bool:
27 + # These tests touch no distributed or device state; the global
28 + # cleanup fixture is unnecessary (and trips a torch MPS allocator
29 + # assert on macOS dev machines).
30 + return False
31 +
32 +
33 +# One id per special token, mirroring the real Inkling vocab layout; plain
34 +# text encodes one token per character so decoded output is exact.
35 +_SPECIAL_VOCAB = {
36 + "<|message_user|>": 200000,
37 + "<|message_model|>": 200001,
38 + "<|message_system|>": 200002,
39 + "<|message_tool|>": 200003,
40 + "<|content_text|>": 200004,
41 + "<|content_image|>": 200005,
42 + "<|content_model_end_sampling|>": 200006,
43 + "<|content_thinking|>": 200008,
44 + "<|end_message|>": 200010,
45 + "<|content_audio_input|>": 200020,
46 + # The HF vocab spells CONTENT_XML as an unused slot.
47 + "<|unused_200024|>": 200024,
48 + "<|audio_end|>": 200043,
49 + "<|content_invoke_tool_json|>": 200049,
50 +}
51 +
52 +_ID_TO_SPECIAL = {v: k for k, v in _SPECIAL_VOCAB.items()}
53 +
54 +
55 +class FakeHfTokenizer:
56 + def get_vocab(self):
57 + return dict(_SPECIAL_VOCAB)
58 +
59 + def encode(self, text, add_special_tokens=False):
60 + assert not add_special_tokens
61 + return [ord(ch) for ch in text]
62 +
63 +
64 +def decode(token_ids):
65 + return "".join(
66 + _ID_TO_SPECIAL.get(tid, chr(tid) if tid < 200000 else f"<{tid}>")
67 + for tid in token_ids
68 + )
69 +
70 +
71 +@pytest.fixture
72 +def inkling_tokenizer():
73 + return _HfBackedTmlTokenizer(FakeHfTokenizer())
74 +
75 +
76 +def render_text(inkling_tokenizer, messages, **kwargs):
77 + return decode(render_inkling_messages(messages, inkling_tokenizer, **kwargs))
78 +
79 +
80 +class TestRustFixtureParity:
81 + def test_tool_round_trip(self, inkling_tokenizer):
82 + messages = [
83 + {
84 + "role": "assistant",
85 + "reasoning_content": "think",
86 + "content": "answer",
87 + "tool_calls": [
88 + {
89 + "id": "call_1",
90 + "function": {
91 + "name": "get_weather",
92 + "arguments": '{"city":"SF"}',
93 + },
94 + }
95 + ],
96 + },
97 + {"role": "tool", "tool_call_id": "call_1", "content": "sunny"},
98 + ]
99 + assert render_text(
100 + inkling_tokenizer, messages, add_generation_prompt=False
101 + ) == (
102 + "<|message_model|><|content_thinking|>think<|end_message|>"
103 + "<|message_model|><|content_text|>answer<|end_message|>"
104 + "<|message_model|>get_weather<|content_invoke_tool_json|>"
105 + '{"name":"get_weather","args":{"city":"SF"}}<|end_message|>'
106 + "<|content_model_end_sampling|>"
107 + "<|message_tool|>get_weather<|content_text|>sunny<|end_message|>"
108 + )
109 +
110 + def test_tool_declare(self, inkling_tokenizer):
111 + tools = [
112 + {
113 + "type": "function",
114 + "function": {
115 + "name": "get_weather",
116 + "description": "Get weather information",
117 + "parameters": {
118 + "type": "object",
119 + "required": ["city"],
120 + "properties": {"city": {"type": "string"}},
121 + },
122 + },
123 + }
124 + ]
125 + messages = [
126 + {
127 + "role": "developer",
128 + "content": "rules",
129 + "tools": [
130 + {
131 + "type": "function",
132 + "function": {
133 + "name": "local_tool",
134 + "parameters": {"z": 1, "a": {"b": 2}},
135 + },
136 + }
137 + ],
138 + },
139 + {"role": "user", "content": "hi"},
140 + ]
141 + assert render_text(inkling_tokenizer, messages, tools=tools) == (
142 + "<|message_system|>tool_declare<|unused_200024|>"
143 + '[{"description":"Get weather information","name":"get_weather",'
144 + '"parameters":{"properties":{"city":{"type":"string"}},'
145 + '"required":["city"],"type":"object"},"type":"function"},'
146 + '{"description":"","name":"local_tool",'
147 + '"parameters":{"a":{"b":2},"z":1},"type":"function"}]'
148 + "<|end_message|>"
149 + "<|message_system|><|content_text|>rules<|end_message|>"
150 + "<|message_user|><|content_text|>hi<|end_message|>"
151 + "<|message_model|>"
152 + )
153 +
154 + def test_text_image(self, inkling_tokenizer):
155 + messages = [
156 + {
157 + "role": "user",
158 + "content": [
159 + {"type": "text", "text": "look"},
160 + {"type": "image_url", "image_url": "data:image/png;base64,"},
161 + ],
162 + }
163 + ]
164 + # Multimodal preprocessing expands this bare marker after rendering.
165 + assert render_text(inkling_tokenizer, messages) == (
166 + "<|message_user|><|content_text|>look<|end_message|>"
167 + "<|message_user|><|content_image|><|end_message|>"
168 + "<|message_model|>"
169 + )
170 +
171 +
172 +class TestRenderingSemantics:
173 + @pytest.mark.parametrize(
174 + ("value", "expected"),
175 + [
176 + ("none", 0.0),
177 + ("minimal", 0.1),
178 + ("low", 0.2),
179 + ("medium", 0.7),
180 + ("high", 0.9),
181 + ("xhigh", 0.99),
182 + ("max", 0.99),
183 + (None, 0.9),
184 + (0.8, 0.8),
185 + (True, None),
186 + ("invalid", None),
187 + ],
188 + )
189 + def test_resolve_reasoning_effort(self, value, expected):
190 + assert _resolve_reasoning_effort(value) == expected
191 +
192 + def test_generation_prompt_default(self, inkling_tokenizer):
193 + text = render_text(inkling_tokenizer, [{"role": "user", "content": "hi"}])
194 + assert text.endswith("<|end_message|><|message_model|>")
195 +
196 + def test_developer_folds_into_system(self, inkling_tokenizer):
197 + assert (
198 + render_text(
199 + inkling_tokenizer,
200 + [{"role": "developer", "content": "be nice"}],
201 + add_generation_prompt=False,
202 + )
203 + == "<|message_system|><|content_text|>be nice<|end_message|>"
204 + )
205 +
206 + def test_empty_string_content_skipped(self, inkling_tokenizer):
207 + assert (
208 + render_text(
209 + inkling_tokenizer,
210 + [{"role": "user", "content": ""}],
211 + add_generation_prompt=False,
212 + )
213 + == ""
214 + )
215 +
216 + def test_empty_reasoning_skipped(self, inkling_tokenizer):
217 + assert (
218 + render_text(
219 + inkling_tokenizer,
220 + [{"role": "assistant", "reasoning_content": "", "content": "hi"}],
221 + add_generation_prompt=False,
222 + )
223 + == "<|message_model|><|content_text|>hi<|end_message|>"
224 + "<|content_model_end_sampling|>"
225 + )
226 +
227 + def test_reasoning_field(self, inkling_tokenizer):
228 + messages = [
229 + {
230 + "role": "assistant",
231 + "reasoning": "think",
232 + "content": "answer",
233 + }
234 + ]
235 + assert render_text(
236 + inkling_tokenizer, messages, add_generation_prompt=False
237 + ) == (
238 + "<|message_model|><|content_thinking|>think<|end_message|>"
239 + "<|message_model|><|content_text|>answer<|end_message|>"
240 + "<|content_model_end_sampling|>"
241 + )
242 +
243 + def test_audio_part(self, inkling_tokenizer):
244 + messages = [
245 + {
246 + "role": "user",
247 + "content": [{"type": "input_audio", "input_audio": {}}],
248 + }
249 + ]
250 + assert render_text(
251 + inkling_tokenizer, messages, add_generation_prompt=False
252 + ) == ("<|message_user|><|content_audio_input|><|audio_end|><|end_message|>")
253 +
254 + def test_tool_response_name_from_message(self, inkling_tokenizer):
255 + assert (
256 + render_text(
257 + inkling_tokenizer,
258 + [{"role": "tool", "name": "my_tool", "content": "ok"}],
259 + add_generation_prompt=False,
260 + )
261 + == "<|message_tool|>my_tool<|content_text|>ok<|end_message|>"
262 + )
263 +
264 + def test_tool_call_args_object_form(self, inkling_tokenizer):
265 + messages = [
266 + {
267 + "role": "assistant",
268 + "tool_calls": [
269 + {
270 + "id": "x",
271 + "function": {
272 + "name": "f",
273 + # dict-form arguments, unsorted keys
274 + "arguments": {"z": 1, "a": 2},
275 + },
276 + }
277 + ],
278 + }
279 + ]
280 + assert render_text(
281 + inkling_tokenizer, messages, add_generation_prompt=False
282 + ) == (
283 + "<|message_model|>f<|content_invoke_tool_json|>"
284 + '{"name":"f","args":{"a":2,"z":1}}<|end_message|>'
285 + "<|content_model_end_sampling|>"
286 + )
287 +
288 + def test_tool_call_empty_args(self, inkling_tokenizer):
289 + messages = [
290 + {
291 + "role": "assistant",
292 + "tool_calls": [{"id": "x", "function": {"name": "f", "arguments": ""}}],
293 + }
294 + ]
295 + assert render_text(
296 + inkling_tokenizer, messages, add_generation_prompt=False
297 + ) == (
298 + "<|message_model|>f<|content_invoke_tool_json|>"
299 + '{"name":"f","args":{}}<|end_message|>'
300 + "<|content_model_end_sampling|>"
301 + )
302 +
303 + def test_tool_call_non_object_args_rejected(self, inkling_tokenizer):
304 + messages = [
305 + {
306 + "role": "assistant",
307 + "tool_calls": [
308 + {"id": "x", "function": {"name": "f", "arguments": "[1]"}}
309 + ],
310 + }
311 + ]
312 + with pytest.raises(TypeError, match="decode to an object"):
313 + render_inkling_messages(
314 + messages, inkling_tokenizer, add_generation_prompt=False
315 + )
316 +
317 + def test_unsupported_role_rejected(self, inkling_tokenizer):
318 + with pytest.raises(ValueError, match="unsupported Inkling message role"):
319 + render_inkling_messages(
320 + [{"role": "narrator", "content": "hi"}], inkling_tokenizer
321 + )
322 +
323 +
324 +class TestReasoningEffort:
325 + def test_frontend_defaults_to_high(self, inkling_tokenizer):
326 + renderer = InklingRenderer.__new__(InklingRenderer)
327 + renderer._inkling_tokenizer = inkling_tokenizer
328 +
329 + text = decode(
330 + renderer._render(
331 + [
332 + {"role": "system", "content": "rules"},
333 + {"role": "user", "content": "hi"},
334 + ],
335 + ChatParams(),
336 + )
337 + )
338 +
339 + assert text.startswith(
340 + "<|message_system|><|content_text|>rules<|end_message|>"
341 + "<|message_system|><|content_text|>Thinking effort level: 0.9"
342 + "<|end_message|>"
343 + )
344 +
345 + @pytest.mark.parametrize("value", ["none", 0, 0.0, -0.0])
346 + def test_zero_effort_has_one_canonical_spelling(self, inkling_tokenizer, value):
347 + renderer = InklingRenderer.__new__(InklingRenderer)
348 + renderer._inkling_tokenizer = inkling_tokenizer
349 +
350 + text = decode(
351 + renderer._render(
352 + [{"role": "user", "content": "hi"}],
353 + ChatParams(chat_template_kwargs={"reasoning_effort": value}),
354 + )
355 + )
356 +
357 + assert text.startswith(
358 + "<|message_system|><|content_text|>Thinking effort level: 0.0"
359 + "<|end_message|>"
360 + )
361 +
362 + def test_emits_one_effort_after_initial_prefix(self, inkling_tokenizer):
363 + effort_block = (
364 + "<|message_system|><|content_text|>Thinking effort level: 0.7"
365 + "<|end_message|>"
366 + )
367 + text = render_text(
368 + inkling_tokenizer,
369 + [
370 + {"role": "system", "content": "rules"},
371 + {"role": "developer", "content": "policy"},
372 + {"role": "user", "content": "user1"},
373 + {"role": "assistant", "content": "assistant1"},
374 + {"role": "user", "content": "user2"},
375 + ],
376 + tools=[{"type": "function", "function": {"name": "f"}}],
377 + reasoning_effort=0.7,
378 + )
379 +
380 + assert text.count(effort_block) == 1
381 + assert (
382 + text.index("tool_declare")
383 + < text.index("rules")
384 + < text.index("policy")
385 + < text.index(effort_block)
386 + < text.index("user1")
387 + < text.index("assistant1")
388 + < text.index("user2")
389 + )
390 +
391 + def test_renders_after_tool_declare(self, inkling_tokenizer):
392 + tools = [{"type": "function", "function": {"name": "f"}}]
393 + text = render_text(
394 + inkling_tokenizer,
395 + [{"role": "user", "content": "hi"}],
396 + tools=tools,
397 + reasoning_effort=0.8,
398 + )
399 + declare_end = text.index("<|end_message|>") + len("<|end_message|>")
400 + assert text[declare_end:].startswith(
401 + "<|message_system|><|content_text|>Thinking effort level: 0.8"
402 + "<|end_message|>"
403 + )
404 +
405 + @pytest.mark.parametrize(
406 + ("value", "expected"),
407 + [(0.2, "0.2"), (0.7, "0.7"), (0.9, "0.9"), (0.99, "0.99")],
408 + )
409 + def test_formats_at_most_two_decimals(self, inkling_tokenizer, value, expected):
410 + text = render_text(
411 + inkling_tokenizer,
412 + [{"role": "user", "content": "hi"}],
413 + reasoning_effort=value,
414 + )
415 + assert f"Thinking effort level: {expected}<|end_message|>" in text
416 +
417 + def test_absent_emits_no_block(self, inkling_tokenizer):
418 + text = render_text(inkling_tokenizer, [{"role": "user", "content": "hi"}])
419 + assert "Thinking effort" not in text
420 +
421 + @pytest.mark.parametrize("value", [0.9900001, 1, 1.5, -0.1])
422 + def test_out_of_range_rejected(self, inkling_tokenizer, value):
423 + with pytest.raises(ValueError, match="must be in"):
424 + render_inkling_messages(
425 + [{"role": "user", "content": "hi"}],
426 + inkling_tokenizer,
427 + reasoning_effort=value,
428 + )
429 +
430 +
431 +class TestSpecialTokenResolution:
432 + def test_missing_special_token_raises(self):
433 + class IncompleteTokenizer(FakeHfTokenizer):
434 + def get_vocab(self):
435 + vocab = dict(_SPECIAL_VOCAB)
436 + del vocab["<|content_invoke_tool_json|>"]
437 + return vocab
438 +
439 + with pytest.raises(ValueError, match="missing special tokens"):
440 + _HfBackedTmlTokenizer(IncompleteTokenizer())
441 +
442 + def test_semantic_spelling_preferred(self):
443 + class SemanticSpellingTokenizer(FakeHfTokenizer):
444 + def get_vocab(self):
445 + vocab = dict(_SPECIAL_VOCAB)
446 + del vocab["<|unused_200024|>"]
447 + vocab["<|content_xml|>"] = 200024
448 + return vocab
449 +
450 + inkling_tokenizer = _HfBackedTmlTokenizer(SemanticSpellingTokenizer())
451 + tools = [{"type": "function", "function": {"name": "f"}}]
452 + ids = render_inkling_messages(
453 + [{"role": "user", "content": "hi"}], inkling_tokenizer, tools=tools
454 + )
455 + assert 200024 in ids
456 +
457 + def test_all_spellings_covered(self):
458 + # Every semantic token must resolve from the reference vocab.
459 + for token, spellings in SPECIAL_TOKEN_SPELLINGS.items():
460 + assert any(s in _SPECIAL_VOCAB for s in spellings), token
461 +
462 +
463 +def test_render_inkling_messages_direct_protocol():
464 + """The encoding core only needs the structural tokenizer protocol."""
465 +
466 + class ProtocolTokenizer:
467 + def encode_text(self, text):
468 + return [ord(c) for c in text]
469 +
470 + def encode_special(self, token):
471 + return {
472 + "<|message_user|>": 200000,
473 + "<|message_model|>": 200001,
474 + "<|content_text|>": 200004,
475 + "<|content_model_end_sampling|>": 200006,
476 + "<|end_message|>": 200010,
477 + }[token]
478 +
479 + ids = render_inkling_messages(
480 + [{"role": "user", "content": "hi"}],
481 + ProtocolTokenizer(),
482 + add_generation_prompt=True,
483 + )
484 + assert ids == [200000, 200004, ord("h"), ord("i"), 200010, 200001]
tests/test_config.py
+20
@@ -211,6 +211,26 @@ def test_resolve_cudagraph_mode_adjusts_spec_decode_sizes_only_for_v1(
211 ),
212 True,
213 ),
214 + (
215 + SimpleNamespace(
216 + model="thinkingmachines/Inkling",
217 + architectures=["InklingForCausalLM"],
218 + runner_type="generate",
219 + is_moe=True,
220 + is_quantized=False,
221 + ),
222 + True,
223 + ),
224 + (
225 + SimpleNamespace(
226 + model="thinkingmachines/Inkling",
227 + architectures=["InklingForConditionalGeneration"],
228 + runner_type="generate",
229 + is_moe=True,
230 + is_quantized=False,
231 + ),
232 + True,
233 + ),
234 (
235 SimpleNamespace(
236 model="mistralai/Mixtral-8x7B-Instruct-v0.1",
vllm/config/model.py
+5 -1
@@ -83,7 +83,9 @@ _REGISTERED_MODEL_CLASS_OVERRIDES: set[tuple[str, str]] = set()
83 RunnerOption = Literal["auto", RunnerType]
84 ConvertType = Literal["none", "embed", "classify"]
85 ConvertOption = Literal["auto", ConvertType]
86 -TokenizerMode = Literal["auto", "hf", "slow", "mistral", "deepseek_v32", "deepseek_v4"]
86 +TokenizerMode = Literal[
87 + "auto", "hf", "slow", "mistral", "deepseek_v32", "deepseek_v4", "inkling"
88 +]
89 ModelDType = Literal["auto", "half", "float16", "bfloat16", "float", "float32"]
90 LogprobsMode = Literal[
91 "raw_logits", "raw_logprobs", "processed_logits", "processed_logprobs"
@@ -628,6 +630,8 @@ class ModelConfig:
630 self.tokenizer_mode = "deepseek_v32"
631 elif arch == "DeepseekV4ForCausalLM":
632 self.tokenizer_mode = "deepseek_v4"
633 + elif arch in ("InklingForCausalLM", "InklingForConditionalGeneration"):
634 + self.tokenizer_mode = "inkling"
635
636 if self.tokenizer_mode != "auto":
637 logger.info(
vllm/config/vllm.py
+3 -1
@@ -68,9 +68,11 @@ logger = init_logger(__name__)
68 DEFAULT_V2_MODEL_RUNNER_ARCHITECTURES = frozenset(
69 {
70 "DeepseekV2ForCausalLM",
71 - "Qwen2MoeForCausalLM",
71 "GraniteMoeForCausalLM",
72 + "InklingForCausalLM",
73 + "InklingForConditionalGeneration",
74 "LongcatFlashNgramForCausalLM",
75 + "Qwen2MoeForCausalLM",
76 }
77 )
78
vllm/model_executor/models/registry.py
+5
@@ -159,6 +159,11 @@ _TEXT_GENERATION_MODELS = {
159 "vllm.models.minimax_m3",
160 "MiniMaxM3SparseForCausalLM",
161 ),
162 + "InklingForCausalLM": ("vllm.models.inkling", "InklingForCausalLM"),
163 + "InklingForConditionalGeneration": (
164 + "vllm.models.inkling",
165 + "InklingForConditionalGeneration",
166 + ),
167 "Ministral3ForCausalLM": ("mistral", "MistralForCausalLM"),
168 "MistralForCausalLM": ("mistral", "MistralForCausalLM"),
169 "MistralLarge3ForCausalLM": ("mistral_large_3", "MistralLarge3ForCausalLM"),
vllm/model_executor/warmup/kernel_warmup.py
+1
@@ -43,6 +43,7 @@ if TYPE_CHECKING:
43 logger = init_logger(__name__)
44
45 _LL_BF16_WARMUP_MODEL_SHAPES: tuple[tuple[int, int], ...] = (
46 + (6144, 264), # Inkling
47 (7168, 256), # DSV3
48 (7168, 384), # DSV4-Pro
49 (14400, 256), # DSV4-Flash
vllm/models/inkling/__init__.py new
+22
@@ -0,0 +1,22 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from typing import TYPE_CHECKING
4 +
5 +if TYPE_CHECKING:
6 + from .nvidia.model import (
7 + InklingForCausalLM,
8 + InklingForConditionalGeneration,
9 + )
10 +
11 +__all__ = [
12 + "InklingForConditionalGeneration",
13 + "InklingForCausalLM",
14 +]
15 +
16 +
17 +def __getattr__(name: str):
18 + if name in __all__:
19 + from .nvidia import model
20 +
21 + return getattr(model, name)
22 + raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
vllm/models/inkling/common/__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/models/inkling/common/mm_preprocess.py new
+361
@@ -0,0 +1,361 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling multimodal preprocessing."""
4 +
5 +from __future__ import annotations
6 +
7 +from collections.abc import Iterable, Mapping, Sequence
8 +from typing import Any, cast
9 +
10 +import numpy as np
11 +import regex as re
12 +import torch
13 +from transformers.feature_extraction_utils import BatchFeature
14 +
15 +from vllm.config.multimodal import (
16 + AudioDummyOptions,
17 + BaseDummyOptions,
18 + ImageDummyOptions,
19 +)
20 +from vllm.inputs import MultiModalDataDict
21 +from vllm.multimodal.inputs import (
22 + MultiModalFieldConfig,
23 + MultiModalKwargsItems,
24 +)
25 +from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser
26 +from vllm.multimodal.processing import (
27 + BaseDummyInputsBuilder,
28 + BaseMultiModalProcessor,
29 + BaseProcessingInfo,
30 + PromptReplacement,
31 + PromptUpdate,
32 + PromptUpdateDetails,
33 +)
34 +from vllm.transformers_utils.processors.inkling import (
35 + AUDIO_MARKER_ID,
36 + AUDIO_TOKEN_ID,
37 + IMAGE_MARKER_ID,
38 + IMAGE_TOKEN_ID,
39 + InklingAudioFeatureExtractor,
40 + InklingImageProcessor,
41 + InklingProcessor,
42 +)
43 +
44 +from ..configs import InklingMMConfig
45 +
46 +# Maximum audio tokens accepted per clip. At the dMel rate of 20 tokens/s
47 +# (50 ms hop) this is ~10 minutes of audio. It bounds the persistent per-request
48 +# buffers and the encoder/memory budget; longer clips are rejected up front.
49 +MAX_AUDIO_TOKENS = 12_000
50 +
51 +
52 +class InklingMultiModalDataParser(MultiModalDataParser):
53 + def _parse_audio_data(self, data: Any) -> Any:
54 + if isinstance(data, (np.ndarray, torch.Tensor)) and data.ndim == 2:
55 + raise ValueError(
56 + "Inkling raw 2-D audio has an ambiguous channel layout. "
57 + "Provide encoded audio or a list of mono waveforms."
58 + )
59 + return super()._parse_audio_data(data)
60 +
61 +
62 +def inkling_vision_enabled(config: InklingMMConfig) -> bool:
63 + return getattr(config.vision_config, "decoder_dmodel", None) is not None
64 +
65 +
66 +def inkling_audio_enabled(config: InklingMMConfig) -> bool:
67 + return getattr(config.audio_config, "decoder_dmodel", None) is not None
68 +
69 +
70 +class InklingProcessingInfo(BaseProcessingInfo):
71 + def get_hf_config(self) -> InklingMMConfig:
72 + return self.ctx.get_hf_config(InklingMMConfig)
73 +
74 + def get_hf_processor(self, **kwargs: object) -> InklingProcessor:
75 + config = self.get_hf_config()
76 + vision_config = config.vision_config
77 + audio_config = config.audio_config
78 +
79 + image_processor = InklingImageProcessor(
80 + patch_size=getattr(vision_config, "patch_size", None) or 40,
81 + )
82 +
83 + if inkling_audio_enabled(config):
84 + audio_params = {
85 + "n_mels": audio_config.n_mel_bins,
86 + "num_dmel_bins": audio_config.mel_vocab_size,
87 + "dmel_min_value": audio_config.dmel_min_value,
88 + "dmel_max_value": audio_config.dmel_max_value,
89 + }
90 + else:
91 + audio_params = {}
92 + audio_extractor = InklingAudioFeatureExtractor(params=audio_params)
93 +
94 + return InklingProcessor(
95 + image_processor=image_processor,
96 + audio_feature_extractor=audio_extractor,
97 + tokenizer=self.get_tokenizer(),
98 + )
99 +
100 + def get_data_parser(self) -> MultiModalDataParser:
101 + # Audio inputs must be resampled to the dMel feature extractor's rate
102 + # before `process_audios` (see InklingAudioFeatureExtractor._decode_one).
103 + # Without a target_sr the default parser raises on any audio input.
104 + extractor = self.get_hf_processor().audio_feature_extractor
105 + return InklingMultiModalDataParser(
106 + target_sr=extractor.params.sample_rate,
107 + target_channels=1,
108 + expected_hidden_size=self._get_expected_hidden_size(),
109 + )
110 +
111 + def get_supported_mm_limits(self) -> Mapping[str, int | None]:
112 + config = self.get_hf_config()
113 + limits: dict[str, int | None] = {}
114 + if inkling_vision_enabled(config):
115 + limits["image"] = None
116 + if inkling_audio_enabled(config):
117 + limits["audio"] = None
118 + return limits
119 +
120 + def get_mm_max_tokens_per_item(
121 + self,
122 + seq_len: int,
123 + mm_counts: Mapping[str, int],
124 + ) -> Mapping[str, int] | None:
125 + # Let vLLM profile dummy inputs to determine the max token counts; the
126 + # image patch count is data-dependent, and the dummy audio is sized to
127 + # MAX_AUDIO_TOKENS so audio is profiled/budgeted at its allowed maximum.
128 + return None
129 +
130 +
131 +class InklingDummyInputsBuilder(BaseDummyInputsBuilder[InklingProcessingInfo]):
132 + def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
133 + # One placeholder per media item; the processor expands each into N
134 + # copies once feature row counts are known.
135 + num_images = mm_counts.get("image", 0)
136 + num_audios = mm_counts.get("audio", 0)
137 + # Use spellings the renderer would emit; tokenization is bypassed in
138 + # _call_hf_processor (we build input_ids directly), so the exact text
139 + # only needs to be a stable per-item marker.
140 + return ("<|content_image|>" * num_images) + (
141 + "<|content_audio_input|>" * num_audios
142 + )
143 +
144 + def get_dummy_mm_data(
145 + self,
146 + seq_len: int,
147 + mm_counts: Mapping[str, int],
148 + mm_options: Mapping[str, BaseDummyOptions],
149 + ) -> MultiModalDataDict:
150 + config = self.info.get_hf_config()
151 + num_images = mm_counts.get("image", 0)
152 + num_audios = mm_counts.get("audio", 0)
153 +
154 + mm_data: dict[str, Any] = {}
155 + if num_images:
156 + patch_size = getattr(config.vision_config, "patch_size", 40)
157 + # A square image ~4 patches wide so the dummy emits several patches.
158 + side = patch_size * 4
159 + image_overrides = mm_options.get("image")
160 + mm_data["image"] = self._get_dummy_images(
161 + width=side,
162 + height=side,
163 + num_images=num_images,
164 + overrides=cast(ImageDummyOptions | None, image_overrides),
165 + )
166 + if num_audios:
167 + # Size the dummy at the maximum allowed audio so memory/encoder
168 + # budgeting reflects the largest clip we accept (MAX_AUDIO_TOKENS).
169 + params = self.info.get_hf_processor().audio_feature_extractor.params
170 + hop = int(round(params.audio_token_duration_s * params.sample_rate))
171 + audio_len = MAX_AUDIO_TOKENS * hop
172 + audio_overrides = mm_options.get("audio")
173 + mm_data["audio"] = self._get_dummy_audios(
174 + length=audio_len,
175 + num_audios=num_audios,
176 + overrides=cast(AudioDummyOptions | None, audio_overrides),
177 + )
178 + return mm_data
179 +
180 +
181 +class InklingMultiModalProcessor(BaseMultiModalProcessor[InklingProcessingInfo]):
182 + def _hf_processor_applies_updates(
183 + self,
184 + prompt_text: str,
185 + mm_items: MultiModalDataItems,
186 + hf_processor_mm_kwargs: Mapping[str, object],
187 + tokenization_kwargs: Mapping[str, object],
188 + ) -> bool:
189 + return False
190 +
191 + def _call_hf_processor(
192 + self,
193 + prompt: str,
194 + mm_data: Mapping[str, object],
195 + mm_kwargs: Mapping[str, object],
196 + tok_kwargs: Mapping[str, object],
197 + ) -> BatchFeature:
198 + # Inkling is not a standard HF processor (no fused text+mm call), so we run
199 + # the vendored extractors ourselves and tokenize the text separately.
200 + # The MM placeholders in `prompt` are expanded later by the prompt
201 + # updates, so here we emit ONE placeholder id per media item.
202 + processor = self.info.get_hf_processor(**mm_kwargs)
203 + tokenizer = self.info.get_tokenizer()
204 +
205 + images = mm_data.get("images") or []
206 + audios = mm_data.get("audios") or []
207 + if not isinstance(images, list):
208 + images = list(cast(Iterable[Any], images))
209 + if not isinstance(audios, list):
210 + audios = list(cast(Iterable[Any], audios))
211 +
212 + prompt_ids = self._tokenize_with_placeholders(
213 + prompt, tokenizer, len(images), len(audios)
214 + )
215 +
216 + data: dict[str, Any] = {"input_ids": [prompt_ids]}
217 +
218 + if images:
219 + img_feat = processor.process_images(images)
220 + data["pixel_values"] = img_feat["vision_patches_bthwc"]
221 + data["num_patches"] = torch.tensor(
222 + img_feat["num_patches"], dtype=torch.int64
223 + )
224 +
225 + if audios:
226 + aud_feat = processor.process_audios(audios)
227 + per_clip = aud_feat["dmel_bins"]
228 + num_audio_tokens = aud_feat["num_audio_tokens"]
229 + for i, n in enumerate(num_audio_tokens):
230 + if int(n) > MAX_AUDIO_TOKENS:
231 + raise ValueError(
232 + f"Audio clip {i} produces {int(n)} tokens, exceeding the "
233 + f"maximum of {MAX_AUDIO_TOKENS} (~10 min at 20 tokens/s). "
234 + "Provide a shorter clip."
235 + )
236 + if per_clip:
237 + input_audio_features = torch.cat(
238 + [torch.as_tensor(c) for c in per_clip], dim=0
239 + )
240 + else:
241 + input_audio_features = torch.empty(0)
242 + data["input_audio_features"] = input_audio_features
243 + data["num_audio_tokens"] = torch.tensor(num_audio_tokens, dtype=torch.int64)
244 +
245 + return BatchFeature(data=data, tensor_type=None)
246 +
247 + def _tokenize_with_placeholders(
248 + self,
249 + prompt: str,
250 + tokenizer: Any,
251 + num_images: int,
252 + num_audios: int,
253 + ) -> list[int]:
254 + """Tokenize `prompt`, emitting the block-start marker id per media item.
255 +
256 + Each marker (kept verbatim) is later expanded by ``_get_prompt_updates``
257 + into ``<marker> + <placeholder> * N``.
258 + """
259 + image_marker = "<|content_image|>"
260 + audio_marker = "<|content_audio_input|>"
261 +
262 + pattern = f"({re.escape(image_marker)}|{re.escape(audio_marker)})"
263 + chunks = re.split(pattern, prompt)
264 +
265 + ids: list[int] = []
266 + seen_img = seen_aud = 0
267 + for chunk in chunks:
268 + if chunk == image_marker:
269 + ids.append(IMAGE_MARKER_ID)
270 + seen_img += 1
271 + elif chunk == audio_marker:
272 + ids.append(AUDIO_MARKER_ID)
273 + seen_aud += 1
274 + elif chunk:
275 + ids.extend(tokenizer.encode(chunk, add_special_tokens=False))
276 +
277 + # Reconcile against the declared media counts only when media is
278 + # present. With no media items -- e.g. the base text-only tokenization
279 + # probe (``_apply_hf_processor_text_only``), which calls this via
280 + # ``_call_hf_processor`` with empty ``mm_data`` -- emit the markers
281 + # verbatim; the marker<->item correspondence is enforced later by
282 + # ``_get_prompt_updates`` once the media features are available.
283 + if num_images or num_audios:
284 + # Fail clearly on a placeholder/media-count mismatch instead of
285 + # crashing with an IndexError deep in the per-item replacement logic.
286 + if num_images and seen_img != num_images:
287 + raise ValueError(
288 + f"Prompt contains {seen_img} image placeholder(s), but only "
289 + f"{num_images} image(s) were provided."
290 + )
291 + if num_audios and seen_aud != num_audios:
292 + raise ValueError(
293 + f"Prompt contains {seen_aud} audio placeholder(s), but only "
294 + f"{num_audios} audio input(s) were provided."
295 + )
296 + return ids
297 +
298 + def _get_mm_fields_config(
299 + self,
300 + hf_inputs: BatchFeature,
301 + hf_processor_mm_kwargs: Mapping[str, object],
302 + ) -> Mapping[str, MultiModalFieldConfig]:
303 + num_patches = hf_inputs.get("num_patches", torch.empty(0, dtype=torch.int64))
304 + num_audio_tokens = hf_inputs.get(
305 + "num_audio_tokens", torch.empty(0, dtype=torch.int64)
306 + )
307 + return dict(
308 + # Ragged per-image patches, grouped by num_patches.
309 + pixel_values=MultiModalFieldConfig.flat_from_sizes("image", num_patches),
310 + num_patches=MultiModalFieldConfig.batched("image"),
311 + # Ragged per-audio frames, grouped by num_audio_tokens.
312 + input_audio_features=MultiModalFieldConfig.flat_from_sizes(
313 + "audio", num_audio_tokens
314 + ),
315 + num_audio_tokens=MultiModalFieldConfig.batched("audio"),
316 + )
317 +
318 + def _get_prompt_updates(
319 + self,
320 + mm_items: Any,
321 + hf_processor_mm_kwargs: Mapping[str, Any],
322 + out_mm_kwargs: MultiModalKwargsItems,
323 + ) -> Sequence[PromptUpdate]:
324 + out_mm_data = out_mm_kwargs.get_data()
325 + num_patches: Any = out_mm_data.get("num_patches")
326 + num_audio_tokens: Any = out_mm_data.get("num_audio_tokens")
327 +
328 + # Keep the block-start marker and append N placeholder tokens after it;
329 + # only the placeholder positions are flagged as embeddings (is_embed), so
330 + # the marker stays a normal text token while the tower features scatter
331 + # into the placeholders.
332 + def image_replacement(item_idx: int) -> PromptUpdateDetails:
333 + n = int(num_patches[item_idx])
334 + return PromptUpdateDetails.select_token_id(
335 + [IMAGE_MARKER_ID] + [IMAGE_TOKEN_ID] * n, IMAGE_TOKEN_ID
336 + )
337 +
338 + def audio_replacement(item_idx: int) -> PromptUpdateDetails:
339 + n = int(num_audio_tokens[item_idx])
340 + return PromptUpdateDetails.select_token_id(
341 + [AUDIO_MARKER_ID] + [AUDIO_TOKEN_ID] * n, AUDIO_TOKEN_ID
342 + )
343 +
344 + updates: list[PromptUpdate] = []
345 + if num_patches is not None and len(num_patches) > 0:
346 + updates.append(
347 + PromptReplacement(
348 + modality="image",
349 + target=[IMAGE_MARKER_ID],
350 + replacement=image_replacement,
351 + )
352 + )
353 + if num_audio_tokens is not None and len(num_audio_tokens) > 0:
354 + updates.append(
355 + PromptReplacement(
356 + modality="audio",
357 + target=[AUDIO_MARKER_ID],
358 + replacement=audio_replacement,
359 + )
360 + )
361 + return updates
vllm/models/inkling/common/towers.py new
+321
@@ -0,0 +1,321 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling Titan vision + audio towers.
4 +
5 +The vision tower (``InklingVision`` / ``HMLPPatchEncoder``) emits one token per
6 +image patch; the audio tower (``InklingAudio``) emits one token per audio frame.
7 +Both use vLLM's standard ``RMSNorm`` (CPU-friendly, with a native fallback).
8 +"""
9 +
10 +from __future__ import annotations
11 +
12 +from typing import cast
13 +
14 +import numpy as np
15 +import torch
16 +import torch.nn.functional as F
17 +from torch import nn
18 +
19 +from vllm.model_executor.layers.layernorm import RMSNorm
20 +
21 +from ..configs import InklingAudioConfig, InklingVisionConfig
22 +
23 +# ===========================================================================
24 +# Vision tower (HMLPPatchEncoder / InklingVision)
25 +# ===========================================================================
26 +
27 +
28 +def _prime_factors(n: int) -> list[int]:
29 + """Return the prime factors of *n* in ascending order."""
30 + if n < 1:
31 + raise ValueError("n must be a positive integer")
32 +
33 + factors: list[int] = []
34 + while n % 2 == 0:
35 + factors.append(2)
36 + n //= 2
37 + p = 3
38 + while p * p <= n:
39 + while n % p == 0:
40 + factors.append(p)
41 + n //= p
42 + p += 2
43 + if n > 1:
44 + factors.append(n)
45 + return factors
46 +
47 +
48 +def plan_out_scales(
49 + temporal_patch_size: int, patch_size: int, n_layers: int, n_channels: int = 3
50 +) -> list[tuple[int, int, int, int]]:
51 + """Plan the (t, h, w, c) dimensions for each HMLP layer.
52 +
53 + Spatial dims expand first, then temporal; channel counts round up to
54 + multiples of 64.
55 + """
56 + if patch_size <= 1:
57 + raise ValueError(
58 + "patch_size must be greater than 1, otherwise this doesn't make sense"
59 + )
60 +
61 + def _round_up(x: int) -> int:
62 + return int(np.ceil(x / 64)) * 64
63 +
64 + last_h_scale = 1
65 + scales: list[tuple[int, int, int, int]] = [(1, 1, 1, n_channels)]
66 + for pscale in _prime_factors(patch_size)[::-1]:
67 + last_h_scale *= pscale
68 + scales.append(
69 + (
70 + 1,
71 + last_h_scale,
72 + last_h_scale,
73 + _round_up((last_h_scale**2) * n_channels),
74 + )
75 + )
76 + last_t_scale = 1
77 + for tscale in _prime_factors(temporal_patch_size)[::-1]:
78 + last_t_scale *= tscale
79 + scales.append(
80 + (
81 + last_t_scale,
82 + last_h_scale,
83 + last_h_scale,
84 + _round_up((last_h_scale**2) * n_channels * last_t_scale),
85 + )
86 + )
87 +
88 + size_reduction = np.prod(np.array(scales)[:, :-1], 1)
89 +
90 + log_ideal_scales = np.linspace(
91 + 0,
92 + np.log(patch_size * patch_size * temporal_patch_size * n_channels),
93 + n_layers + 1,
94 + )
95 + cost_matrix = np.abs(log_ideal_scales[:, None] - np.log(size_reduction)[None])
96 +
97 + if n_layers >= len(scales):
98 + idxs = np.argmin(cost_matrix, axis=1)
99 + else:
100 + from scipy.optimize import linear_sum_assignment
101 +
102 + idxs = linear_sum_assignment(cost_matrix)[1]
103 +
104 + assert len(idxs) >= 2
105 + idxs[0] = 0
106 + idxs[-1] = len(scales) - 1
107 +
108 + return [scales[i] for i in idxs]
109 +
110 +
111 +def fold_timespace_to_depth(
112 + vision_patches_bthwc: torch.Tensor, t_fold: int, hw_fold: int
113 +) -> torch.Tensor:
114 + """(B, T, H, W, C) -> (B, T//t, H//hw, W//hw, C*(t*hw**2))."""
115 + B, T, H, W, C = vision_patches_bthwc.shape
116 +
117 + assert T % t_fold == 0, f"Temporal dimension {T} must be divisible by {t_fold}"
118 + assert H % hw_fold == 0, f"Height dimension {H} must be divisible by {hw_fold}"
119 + assert W % hw_fold == 0, f"Width dimension {W} must be divisible by {hw_fold}"
120 +
121 + t_new = T // t_fold
122 + h_new = H // hw_fold
123 + w_new = W // hw_fold
124 +
125 + x = vision_patches_bthwc.reshape(
126 + B, t_new, t_fold, h_new, hw_fold, w_new, hw_fold, C
127 + )
128 + x = x.permute(0, 1, 3, 5, 2, 4, 6, 7)
129 + x = x.reshape(B, t_new, h_new, w_new, t_fold * hw_fold * hw_fold * C)
130 + return x
131 +
132 +
133 +class HMLPPatchEncoder(nn.Module):
134 + def __init__(self, config: InklingVisionConfig):
135 + super().__init__()
136 + self.decoder_dmodel = config.decoder_dmodel
137 + self.patch_size = config.patch_size
138 + self.temporal_patch_size = config.temporal_patch_size
139 + self.n_channels = config.n_channels
140 + self.n_layers = config.n_layers
141 + self.use_vision_norm = config.use_vision_norm
142 +
143 + self.scales: list[tuple[int, int, int, int]] = plan_out_scales(
144 + self.temporal_patch_size, self.patch_size, self.n_layers, self.n_channels
145 + )
146 + self.layers: nn.ModuleDict = nn.ModuleDict()
147 + for i, (start_scale, end_scale) in enumerate(
148 + zip(self.scales[:-1], self.scales[1:])
149 + ):
150 + shuffle_mult = (
151 + (end_scale[0] // start_scale[0])
152 + * (end_scale[1] // start_scale[1])
153 + * (end_scale[2] // start_scale[2])
154 + )
155 + if i == self.n_layers - 1:
156 + self.layers[f"linear_{i}"] = nn.Linear(
157 + start_scale[3] * shuffle_mult, self.decoder_dmodel, bias=False
158 + )
159 + else:
160 + self.layers[f"linear_{i}"] = nn.Linear(
161 + start_scale[3] * shuffle_mult, end_scale[3], bias=False
162 + )
163 + self.layers[f"norm_{i}"] = RMSNorm(end_scale[3])
164 +
165 + self.final_norm: RMSNorm | None = None
166 + if self.use_vision_norm:
167 + assert self.decoder_dmodel is not None
168 + self.final_norm = RMSNorm(self.decoder_dmodel)
169 +
170 + def forward(self, x: torch.Tensor) -> torch.Tensor:
171 + # Fused norm+gelu on CUDA (same fp32-accum/bf16-rounding structure as
172 + # the generic path below; differs only by reduction order).
173 + fused = None
174 + if x.is_cuda and x.dtype == torch.bfloat16:
175 + from vllm.models.inkling.nvidia.ops.mm_towers import rmsnorm_gelu
176 +
177 + fused = rmsnorm_gelu
178 +
179 + num_patches, T, H, W, C = x.shape
180 + prefolded = False
181 + for i, (start_scale, end_scale) in enumerate(
182 + zip(self.scales[:-1], self.scales[1:])
183 + ):
184 + t_fold = end_scale[0] // start_scale[0]
185 + hw_fold = end_scale[1] // start_scale[1]
186 + if (hw_fold > 1 or t_fold > 1) and not prefolded:
187 + x = fold_timespace_to_depth(x, t_fold, hw_fold)
188 + prefolded = False
189 + assert x.shape[1:-1] == (
190 + T // end_scale[0],
191 + H // end_scale[1],
192 + W // end_scale[2],
193 + )
194 + x = self.layers[f"linear_{i}"](x)
195 + if i < self.n_layers - 1:
196 + norm = cast(RMSNorm, self.layers[f"norm_{i}"])
197 + if fused is not None:
198 + # If the NEXT layer starts with a copying fold (spatial
199 + # dims still > 1 after folding), store this layer's
200 + # output directly in the folded layout instead.
201 + nxt = self.scales[i + 2]
202 + ntf = nxt[0] // end_scale[0]
203 + nhf = nxt[1] // end_scale[1]
204 + copy_fold = (ntf > 1 or nhf > 1) and (
205 + x.shape[2] // nhf > 1 or x.shape[3] // nhf > 1
206 + )
207 + x = fused(
208 + x,
209 + norm.weight,
210 + norm.variance_epsilon,
211 + gelu=True,
212 + fold=(ntf, nhf) if copy_fold else None,
213 + )
214 + prefolded = copy_fold
215 + else:
216 + # rms_norm kernel only supports rank 2-4; x is 5-D here.
217 + orig = x.shape
218 + x = norm(x.reshape(-1, x.shape[-1])).reshape(orig)
219 + x = F.gelu(x)
220 +
221 + if self.final_norm is not None:
222 + if fused is not None:
223 + x = fused(
224 + x,
225 + self.final_norm.weight,
226 + self.final_norm.variance_epsilon,
227 + gelu=False,
228 + )
229 + else:
230 + orig = x.shape
231 + x = self.final_norm(x.reshape(-1, x.shape[-1])).reshape(orig)
232 +
233 + x = x.reshape(num_patches, -1)
234 + return x
235 +
236 +
237 +class InklingVision(nn.Module):
238 + def __init__(self, config: InklingVisionConfig, prefix: str = ""):
239 + del prefix
240 + super().__init__()
241 + assert config.vision_encoder_type == "hmlp"
242 + self.vision_encoder = HMLPPatchEncoder(config)
243 +
244 + @property
245 + def dtype(self) -> torch.dtype:
246 + return next(self.parameters()).dtype
247 +
248 + @property
249 + def device(self) -> torch.device:
250 + return next(self.parameters()).device
251 +
252 + def forward(self, vision_features: torch.Tensor) -> torch.Tensor:
253 + return self.vision_encoder(vision_features)
254 +
255 +
256 +# ===========================================================================
257 +# Audio tower (InklingAudio)
258 +# ===========================================================================
259 +
260 +
261 +class InklingAudio(nn.Module):
262 + def __init__(self, config: InklingAudioConfig, prefix: str = ""):
263 + del prefix
264 + super().__init__()
265 + assert config.audio_mode == "dmel"
266 + self.n_mel_bins = config.n_mel_bins
267 + self.mel_vocab_size = config.mel_vocab_size
268 + self.use_audio_norm = config.use_audio_norm
269 + self.encoder = nn.Embedding(
270 + config.n_mel_bins * config.mel_vocab_size, config.decoder_dmodel
271 + )
272 + self.final_norm: RMSNorm | None = None
273 + if self.use_audio_norm:
274 + assert config.decoder_dmodel is not None
275 + self.final_norm = RMSNorm(config.decoder_dmodel, eps=1e-6)
276 +
277 + @property
278 + def dtype(self) -> torch.dtype:
279 + return self.encoder.weight.dtype
280 +
281 + @property
282 + def device(self) -> torch.device:
283 + return self.encoder.weight.device
284 +
285 + def forward(self, audio_features: torch.Tensor) -> torch.Tensor:
286 + assert audio_features.shape[1] == self.n_mel_bins
287 +
288 + # dMel bins are integer indices; cast once to int32 on the right device
289 + # (no float round-trip).
290 + audio_features = audio_features.to(
291 + device=self.encoder.weight.device, dtype=torch.int32
292 + )
293 +
294 + weight = self.encoder.weight
295 + if audio_features.is_cuda and weight.dtype == torch.bfloat16:
296 + # One kernel: per-bin offset + embedding gather + fp32 sum + norm.
297 + # Skips the [T, n_mel_bins, D] intermediate entirely (bit-exact).
298 + from vllm.models.inkling.nvidia.ops.mm_towers import dmel_embed_sum_norm
299 +
300 + return dmel_embed_sum_norm(
301 + audio_features.contiguous(),
302 + weight,
303 + self.final_norm.weight if self.final_norm is not None else None,
304 + self.final_norm.variance_epsilon if self.final_norm else 0.0,
305 + )
306 +
307 + embedding_indices = (
308 + torch.arange(self.n_mel_bins, device=audio_features.device)
309 + * self.mel_vocab_size
310 + ).unsqueeze(0) + audio_features
311 +
312 + hidden_states = (
313 + self.encoder(embedding_indices.reshape(-1))
314 + .reshape(audio_features.shape[0], audio_features.shape[1], -1)
315 + .sum(axis=1)
316 + )
317 +
318 + if self.final_norm is not None:
319 + hidden_states = self.final_norm(hidden_states)
320 +
321 + return hidden_states
vllm/models/inkling/configs.py new
+373
@@ -0,0 +1,373 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling model configs for the text backbone and audio/vision towers."""
4 +
5 +from __future__ import annotations
6 +
7 +from dataclasses import dataclass, field
8 +from typing import Any, ClassVar, Literal, cast
9 +
10 +import torch
11 +from transformers.configuration_utils import PretrainedConfig
12 +
13 +
14 +class InklingModelConfig(PretrainedConfig):
15 + model_type = "inkling_model"
16 + keys_to_ignore_at_inference = ["past_key_values"]
17 +
18 + def __init__(
19 + self,
20 + *,
21 + vocab_size: int = 201024,
22 + hidden_size: int = 1536,
23 + intermediate_size: int = 768,
24 + dense_intermediate_size: int | None = None,
25 + num_hidden_layers: int = 16,
26 + num_attention_heads: int = 12,
27 + num_key_value_heads: int = 4,
28 + head_dim: int | None = None,
29 + v_head_dim: int | None = None,
30 + d_rel: int = 16,
31 + rel_extent: int = 1024,
32 + local_layer_ids: list[int] | None = None,
33 + sliding_window_size: int = 512,
34 + swa_num_attention_heads: int | None = None,
35 + swa_num_key_value_heads: int | None = None,
36 + swa_head_dim: int | None = None,
37 + swa_v_head_dim: int | None = None,
38 + rms_norm_eps: float = 1e-6,
39 + hidden_act: str = "silu",
40 + q_bias: bool = False,
41 + o_bias: bool = False,
42 + use_embed_norm: bool = False,
43 + use_sconv: bool = False,
44 + sconv_kernel_size: int = 4,
45 + dense_mlp_idx: int = 0,
46 + n_routed_experts: int = 0,
47 + n_shared_experts: int = 0,
48 + num_experts_per_tok: int = 1,
49 + route_scale: float = 1.0,
50 + use_gate_bias: bool = False,
51 + use_global_scale: bool = False,
52 + norm_after_topk: bool = True,
53 + gate_activation: Literal["sigmoid", "softmax"] = "sigmoid",
54 + shared_expert_sink: bool = False,
55 + shared_experts_size: int = 1,
56 + inference_moe_w13_interleaved: bool = True,
57 + log_scaling_n_floor: int | None = None,
58 + log_scaling_alpha: float = 0.1,
59 + unpadded_vocab_size: int | None = None,
60 + padded_vocab_size: int | None = None,
61 + logits_mup_width_multiplier: float | None = None,
62 + final_logit_softcapping: float | None = None,
63 + tie_word_embeddings: bool = False,
64 + **kwargs: Any,
65 + ) -> None:
66 + if head_dim is None:
67 + head_dim = hidden_size // num_attention_heads
68 + if v_head_dim is None:
69 + v_head_dim = head_dim
70 + if swa_num_attention_heads is None:
71 + swa_num_attention_heads = num_attention_heads
72 + if swa_num_key_value_heads is None:
73 + swa_num_key_value_heads = num_key_value_heads
74 + if swa_head_dim is None:
75 + swa_head_dim = head_dim
76 + if swa_v_head_dim is None:
77 + swa_v_head_dim = swa_head_dim
78 + if dense_intermediate_size is None:
79 + dense_intermediate_size = intermediate_size
80 + if local_layer_ids is None:
81 + local_layer_ids = []
82 +
83 + if padded_vocab_size is None:
84 + padded_vocab_size = vocab_size
85 + vocab_size = (
86 + unpadded_vocab_size
87 + if (
88 + unpadded_vocab_size is not None
89 + and unpadded_vocab_size < padded_vocab_size
90 + )
91 + else vocab_size
92 + )
93 +
94 + self.vocab_size = vocab_size
95 + self.padded_vocab_size = padded_vocab_size
96 + self.hidden_size = hidden_size
97 + self.intermediate_size = intermediate_size
98 + self.dense_intermediate_size = dense_intermediate_size
99 + self.num_hidden_layers = num_hidden_layers
100 + self.num_attention_heads = num_attention_heads
101 + self.num_key_value_heads = num_key_value_heads
102 + self.head_dim = head_dim
103 + self.v_head_dim = v_head_dim
104 + self.d_rel = d_rel
105 + self.rel_extent = rel_extent
106 + self.local_layer_ids = local_layer_ids
107 + self.sliding_window_size = sliding_window_size
108 + self.swa_num_attention_heads = swa_num_attention_heads
109 + self.swa_num_key_value_heads = swa_num_key_value_heads
110 + self.swa_head_dim = swa_head_dim
111 + self.swa_v_head_dim = swa_v_head_dim
112 + self.rms_norm_eps = rms_norm_eps
113 + self.hidden_act = hidden_act
114 + self.q_bias = q_bias
115 + self.o_bias = o_bias
116 + self.use_embed_norm = use_embed_norm
117 + self.use_sconv = use_sconv
118 + self.sconv_kernel_size = sconv_kernel_size
119 + self.dense_mlp_idx = dense_mlp_idx
120 + self.n_routed_experts = n_routed_experts
121 + self.num_experts = n_routed_experts
122 + self.n_shared_experts = n_shared_experts
123 + self.num_shared_experts = n_shared_experts
124 + self.num_experts_per_tok = num_experts_per_tok
125 + self.route_scale = route_scale
126 + self.use_gate_bias = use_gate_bias
127 + self.use_global_scale = use_global_scale
128 + self.norm_after_topk = norm_after_topk
129 + self.gate_activation = gate_activation
130 + self.shared_expert_sink = shared_expert_sink
131 + self.shared_experts_size = shared_experts_size
132 + self.inference_moe_w13_interleaved = inference_moe_w13_interleaved
133 + self.log_scaling_n_floor = log_scaling_n_floor
134 + self.log_scaling_alpha = log_scaling_alpha
135 + self.unpadded_vocab_size = self.vocab_size
136 + self.logits_mup_width_multiplier = logits_mup_width_multiplier
137 + self.final_logit_softcapping = final_logit_softcapping
138 +
139 + super().__init__(tie_word_embeddings=tie_word_embeddings, **kwargs)
140 +
141 + @property
142 + def conv_layer_ids(self) -> list[int]:
143 + return list(range(self.num_hidden_layers))
144 +
145 + @property
146 + def linear_layer_ids(self) -> list[int]:
147 + return self.conv_layer_ids
148 +
149 + @property
150 + def full_attention_layer_ids(self) -> list[int]:
151 + return list(range(self.num_hidden_layers))
152 +
153 + @property
154 + def mamba_chunk_size(self) -> int:
155 + # Floor at 64: mamba_cache_chunk_size = max(mamba_chunk_size, page_size),
156 + # and a floor of 1 lets the radix tree adopt another request's KV at
157 + # tiny shared prefixes, whose different kernel-rounding perturbs decode
158 + # logits.
159 + return 64
160 +
161 + @property
162 + def mamba2_cache_params(self) -> TMLConvCacheParams | None:
163 + try:
164 + from vllm.distributed import get_tensor_model_parallel_world_size
165 +
166 + tp_size = get_tensor_model_parallel_world_size()
167 + except (AssertionError, RuntimeError):
168 + tp_size = 1
169 +
170 + def tp_local_kv_conv_dim(num_kv_heads: int, head_dim: int) -> int:
171 + return max(1, num_kv_heads // tp_size) * head_dim
172 +
173 + full_kv_conv_dim = tp_local_kv_conv_dim(self.num_key_value_heads, self.head_dim)
174 + local_kv_conv_dim = tp_local_kv_conv_dim(
175 + self.swa_num_key_value_heads, self.swa_head_dim
176 + )
177 + stream_dim = self.hidden_size
178 + conv_len = self.sconv_kernel_size - 1
179 + shape = TMLConvStateShape(
180 + conv=[
181 + (conv_len, full_kv_conv_dim),
182 + (conv_len, full_kv_conv_dim),
183 + (conv_len, local_kv_conv_dim),
184 + (conv_len, local_kv_conv_dim),
185 + (conv_len, stream_dim),
186 + (conv_len, stream_dim),
187 + ],
188 + temporal=(0, 0, 0),
189 + )
190 + dtype = TMLStateDType(conv=torch.bfloat16, temporal=torch.bfloat16)
191 + return TMLConvCacheParams(shape=shape, layers=self.conv_layer_ids, dtype=dtype)
192 +
193 +
194 +class InklingAudioConfig(PretrainedConfig):
195 + model_type = "inkling_audio_model"
196 +
197 + def __init__(
198 + self,
199 + *,
200 + decoder_dmodel: int | None = None,
201 + n_mel_bins: int | None = None,
202 + mel_vocab_size: int | None = None,
203 + dmel_min_value: float | None = None,
204 + dmel_max_value: float | None = None,
205 + use_audio_norm: bool | None = None,
206 + audio_mode: Literal["dmel", "flow"] | None = None,
207 + **kwargs: Any,
208 + ) -> None:
209 + values = {
210 + "n_mel_bins": n_mel_bins,
211 + "mel_vocab_size": mel_vocab_size,
212 + "dmel_min_value": dmel_min_value,
213 + "dmel_max_value": dmel_max_value,
214 + "use_audio_norm": use_audio_norm,
215 + "audio_mode": audio_mode,
216 + }
217 + if decoder_dmodel is not None and (
218 + missing := [name for name, value in values.items() if value is None]
219 + ):
220 + raise ValueError(
221 + "Enabled Inkling audio tower is missing config fields: "
222 + + ", ".join(missing)
223 + )
224 + self.decoder_dmodel = decoder_dmodel
225 + self.n_mel_bins = cast(int, n_mel_bins)
226 + self.mel_vocab_size = cast(int, mel_vocab_size)
227 + self.dmel_min_value = cast(float, dmel_min_value)
228 + self.dmel_max_value = cast(float, dmel_max_value)
229 + self.use_audio_norm = cast(bool, use_audio_norm)
230 + self.audio_mode = cast(Literal["dmel", "flow"], audio_mode)
231 + super().__init__(**kwargs)
232 +
233 +
234 +class InklingVisionConfig(PretrainedConfig):
235 + model_type = "inkling_vision_model"
236 +
237 + def __init__(
238 + self,
239 + *,
240 + vision_encoder_type: Literal["linear", "hmlp"] | None = None,
241 + decoder_dmodel: int | None = None,
242 + patch_size: int | None = None,
243 + temporal_patch_size: int | None = None,
244 + n_channels: int | None = None,
245 + n_layers: int | None = None,
246 + use_vision_norm: bool | None = None,
247 + **kwargs: Any,
248 + ) -> None:
249 + values = {
250 + "vision_encoder_type": vision_encoder_type,
251 + "patch_size": patch_size,
252 + "temporal_patch_size": temporal_patch_size,
253 + "n_channels": n_channels,
254 + "n_layers": n_layers,
255 + "use_vision_norm": use_vision_norm,
256 + }
257 + if decoder_dmodel is not None and (
258 + missing := [name for name, value in values.items() if value is None]
259 + ):
260 + raise ValueError(
261 + "Enabled Inkling vision tower is missing config fields: "
262 + + ", ".join(missing)
263 + )
264 + self.vision_encoder_type = cast(Literal["linear", "hmlp"], vision_encoder_type)
265 + self.decoder_dmodel = decoder_dmodel
266 + self.patch_size = cast(int, patch_size)
267 + self.temporal_patch_size = cast(int, temporal_patch_size)
268 + self.n_channels = cast(int, n_channels)
269 + self.n_layers = cast(int, n_layers)
270 + self.use_vision_norm = cast(bool, use_vision_norm)
271 + super().__init__(**kwargs)
272 +
273 +
274 +class InklingMMConfig(PretrainedConfig):
275 + model_type = "inkling_mm_model"
276 + keys_to_ignore_at_inference = ["past_key_values"]
277 + sub_configs: ClassVar[dict[str, type[PretrainedConfig]]] = {
278 + "text_config": InklingModelConfig,
279 + "audio_config": InklingAudioConfig,
280 + "vision_config": InklingVisionConfig,
281 + }
282 +
283 + def __init__(
284 + self,
285 + *,
286 + text_config: dict[str, Any] | InklingModelConfig | None = None,
287 + audio_config: dict[str, Any] | InklingAudioConfig | None = None,
288 + vision_config: dict[str, Any] | InklingVisionConfig | None = None,
289 + tie_word_embeddings: bool = False,
290 + **kwargs: Any,
291 + ) -> None:
292 + self.text_config = (
293 + text_config
294 + if isinstance(text_config, InklingModelConfig)
295 + else InklingModelConfig(**(text_config or {}))
296 + )
297 + self.audio_config = (
298 + audio_config
299 + if isinstance(audio_config, InklingAudioConfig)
300 + else InklingAudioConfig(**(audio_config or {}))
301 + )
302 + self.vision_config = (
303 + vision_config
304 + if isinstance(vision_config, InklingVisionConfig)
305 + else InklingVisionConfig(**(vision_config or {}))
306 + )
307 + super().__init__(tie_word_embeddings=tie_word_embeddings, **kwargs)
308 +
309 + def get_text_config(self, *args: Any, **kwargs: Any) -> InklingModelConfig:
310 + return self.text_config
311 +
312 + @property
313 + def vocab_size(self) -> int:
314 + return self.text_config.vocab_size
315 +
316 + @property
317 + def hidden_size(self) -> int:
318 + return self.text_config.hidden_size
319 +
320 + @property
321 + def num_hidden_layers(self) -> int:
322 + return self.text_config.num_hidden_layers
323 +
324 + @property
325 + def num_attention_heads(self) -> int:
326 + return self.text_config.num_attention_heads
327 +
328 + @property
329 + def num_key_value_heads(self) -> int:
330 + return self.text_config.num_key_value_heads
331 +
332 + @property
333 + def head_dim(self) -> int:
334 + return self.text_config.head_dim
335 +
336 + @property
337 + def full_attention_layer_ids(self) -> list[int]:
338 + return self.text_config.full_attention_layer_ids
339 +
340 + @property
341 + def linear_layer_ids(self) -> list[int]:
342 + return self.text_config.linear_layer_ids
343 +
344 + @property
345 + def conv_layer_ids(self) -> list[int]:
346 + return self.text_config.conv_layer_ids
347 +
348 + @property
349 + def mamba_chunk_size(self) -> int:
350 + return self.text_config.mamba_chunk_size
351 +
352 + @property
353 + def mamba2_cache_params(self) -> TMLConvCacheParams | None:
354 + return self.text_config.mamba2_cache_params
355 +
356 +
357 +@dataclass(kw_only=True, frozen=True)
358 +class TMLConvStateShape:
359 + conv: list[tuple[int, int]]
360 + temporal: tuple[int, int, int]
361 +
362 +
363 +@dataclass(kw_only=True, frozen=True)
364 +class TMLStateDType:
365 + conv: torch.dtype = torch.bfloat16
366 + temporal: torch.dtype = torch.bfloat16
367 +
368 +
369 +@dataclass(kw_only=True, frozen=True)
370 +class TMLConvCacheParams:
371 + shape: TMLConvStateShape
372 + layers: list[int]
373 + dtype: TMLStateDType = field(default_factory=TMLStateDType)
vllm/models/inkling/nvfp4.py new
+64
@@ -0,0 +1,64 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""NVFP4 (ModelOpt) support for the Inkling mixture-of-experts.
4 +
5 +Only the routed MoE experts are quantized in the Inkling checkpoint;
6 +attention, the dense MLP, and the shared "sink" experts stay bf16 (they are
7 +in the checkpoint ``exclude_modules``). The routed experts are served by
8 +vLLM's standard ModelOpt NVFP4 fused-MoE stack (see ``moe.py``); this module
9 +keeps the checkpoint detection.
10 +"""
11 +
12 +from __future__ import annotations
13 +
14 +FLOAT8_E4M3_MAX = 448.0
15 +FLOAT4_E2M1_MAX = 6.0
16 +
17 +
18 +class InklingNvfp4Config:
19 + """Lightweight NVFP4 descriptor parsed from the checkpoint quant config.
20 +
21 + Holds the (mapped) ``exclude_modules`` so the model can decide, per MoE
22 + layer and per expert group, whether the weights are NVFP4 or plain bf16.
23 + """
24 +
25 + def __init__(self, group_size: int, exclude_modules: list[str]) -> None:
26 + self.group_size = group_size
27 + self.exclude_modules = set(exclude_modules)
28 +
29 + @staticmethod
30 + def _is_nvfp4(quant_cfg: dict) -> bool:
31 + wq = quant_cfg["modelopt_quant_config"]["quant_cfg"]["*weight_quantizer"]
32 + return tuple(wq["num_bits"]) == (2, 1) and tuple(
33 + wq["block_sizes"].get("scale_bits", [])
34 + ) == (4, 3)
35 +
36 + @classmethod
37 + def from_hf_config(cls, hf_config) -> InklingNvfp4Config | None:
38 + quant_cfg = getattr(hf_config, "quantization_config", None)
39 + text_config = getattr(hf_config, "text_config", None)
40 + if quant_cfg is None and text_config is not None:
41 + quant_cfg = getattr(text_config, "quantization_config", None)
42 + if quant_cfg is None:
43 + return None
44 + # ModelOpt <=0.29 nests everything under "quantization".
45 + if "quantization" in quant_cfg:
46 + quant_cfg = quant_cfg["quantization"]
47 + if not cls._is_nvfp4(quant_cfg):
48 + return None
49 + group_size = quant_cfg.get("group_size", 16)
50 + if group_size != 16:
51 + raise ValueError("Inkling NVFP4 only supports group size 16")
52 + exclude = list(quant_cfg.get("exclude_modules", []) or [])
53 + return cls(group_size=group_size, exclude_modules=exclude)
54 +
55 + def experts_quantized(self, layer_id: int) -> bool:
56 + """Whether the routed experts of ``layer_id`` are NVFP4 (vs excluded)."""
57 + return f"model.llm.layers.{layer_id}.mlp.experts" not in self.exclude_modules
58 +
59 + def shared_experts_quantized(self, layer_id: int) -> bool:
60 + """Whether the shared sink experts of ``layer_id`` are NVFP4."""
61 + return (
62 + f"model.llm.layers.{layer_id}.mlp.shared_experts"
63 + not in self.exclude_modules
64 + )
vllm/models/inkling/nvidia/__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/models/inkling/nvidia/attention.py new
+325
@@ -0,0 +1,325 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from __future__ import annotations
4 +
5 +from typing import cast
6 +
7 +import torch
8 +from torch import nn
9 +
10 +from vllm.config import VllmConfig, get_current_vllm_config
11 +from vllm.distributed import get_tensor_model_parallel_world_size
12 +from vllm.forward_context import get_forward_context
13 +from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
14 +from vllm.model_executor.layers.linear import (
15 + MergedColumnParallelLinear,
16 + RowParallelLinear,
17 +)
18 +from vllm.model_executor.layers.quantization import QuantizationConfig
19 +from vllm.utils.torch_utils import (
20 + canonicalize_singleton_dim_strides,
21 + kv_cache_dtype_str_to_dtype,
22 +)
23 +from vllm.v1.attention.backend import AttentionBackend
24 +from vllm.v1.attention.backends.flash_attn import (
25 + FlashAttentionBackend,
26 + FlashAttentionMetadata,
27 +)
28 +from vllm.v1.kv_cache_interface import (
29 + FullAttentionSpec,
30 + KVCacheSpec,
31 + SlidingWindowSpec,
32 +)
33 +
34 +from ..configs import InklingModelConfig
35 +from .layernorm import InklingRMSNorm
36 +from .ops.fa4_rel_attention import (
37 + bucket_max_seqlen_q,
38 + inkling_fa4_num_splits,
39 + inkling_fa4_rel_attention,
40 +)
41 +from .ops.fa4_warmup import InklingFA4WarmupConfig, register_fa4_warmup
42 +from .ops.qkvr_prep import fused_qkvr_prep
43 +from .sconv_swa_attn import _K, _V, InklingConvState, InklingSconvMetadata
44 +from .short_conv import InklingShortConv
45 +
46 +
47 +def compute_log_scaling_tau(
48 + positions: torch.Tensor, n_floor: int, alpha: float
49 +) -> torch.Tensor:
50 + effective_n = (positions + 1).to(torch.float32)
51 + return 1.0 + alpha * torch.log(torch.clamp(effective_n / float(n_floor), min=1.0))
52 +
53 +
54 +class RelLogitsProj(nn.Module):
55 + """Project the per-head relative branch ``r`` to per-distance logits."""
56 +
57 + def __init__(self, d_rel: int, rel_extent: int) -> None:
58 + super().__init__()
59 + self.d_rel = d_rel
60 + self.rel_extent = rel_extent
61 + self.proj = nn.Parameter(torch.empty(d_rel, rel_extent), requires_grad=False)
62 +
63 + def forward(self, r_out: torch.Tensor) -> torch.Tensor:
64 + # r_out: (T, num_heads, d_rel) -> (T, num_heads, rel_extent)
65 + return torch.einsum("thd,de->the", r_out, self.proj)
66 +
67 +
68 +class InklingAttention(nn.Module, AttentionLayerBase):
69 + def __init__(
70 + self,
71 + config: InklingModelConfig,
72 + *,
73 + num_heads: int,
74 + num_kv_heads: int,
75 + head_dim: int,
76 + rel_extent: int,
77 + local_extent: int,
78 + is_local: bool,
79 + prefix: str,
80 + quant_config: QuantizationConfig | None = None,
81 + conv_owner: InklingConvState,
82 + ) -> None:
83 + super().__init__()
84 + self.prefix = prefix
85 + self.is_local = is_local
86 + self.hidden_size = config.hidden_size
87 + self.head_dim = head_dim
88 + self.d_rel = config.d_rel
89 + self.log_scaling_n_floor = config.log_scaling_n_floor
90 + self.log_scaling_alpha = config.log_scaling_alpha
91 + # q/k are per-head RMS-normed (unit norm), so Inkling scales by 1/head_dim.
92 + self.scaling = 1.0 / head_dim
93 +
94 + tp_size = get_tensor_model_parallel_world_size()
95 + self.num_total_heads = num_heads
96 + self.num_total_kv_heads = num_kv_heads
97 + assert self.num_total_heads % tp_size == 0
98 + self.num_heads = self.num_total_heads // tp_size
99 + if self.num_total_kv_heads >= tp_size:
100 + assert self.num_total_kv_heads % tp_size == 0
101 + else:
102 + assert tp_size % self.num_total_kv_heads == 0
103 + self.num_kv_heads = max(1, self.num_total_kv_heads // tp_size)
104 + # When tp_size > num_kv_heads the K/V projections are padded up to
105 + # tp_size heads so each rank gets at least one (GQA replication).
106 + kv_total_for_sizing = max(self.num_total_kv_heads, tp_size)
107 +
108 + self.qkvr = MergedColumnParallelLinear(
109 + input_size=config.hidden_size,
110 + output_sizes=[
111 + head_dim * self.num_total_heads,
112 + head_dim * kv_total_for_sizing,
113 + head_dim * kv_total_for_sizing,
114 + self.d_rel * self.num_total_heads,
115 + ],
116 + bias=config.q_bias,
117 + quant_config=quant_config,
118 + prefix=f"{prefix}.qkvr",
119 + )
120 + self.wo_ud = RowParallelLinear(
121 + input_size=head_dim * self.num_total_heads,
122 + output_size=config.hidden_size,
123 + bias=config.o_bias,
124 + quant_config=quant_config,
125 + # reduce_results=False: the partial output is all-reduced below
126 + # (one-shot custom AR) so the attention-output sconv can run on the
127 + # full hidden width fused with the residual add + rmsnorm.
128 + reduce_results=False,
129 + prefix=f"{prefix}.wo_ud",
130 + )
131 + self.rel_extent = local_extent if is_local else rel_extent
132 + self.local_extent = local_extent if is_local else None
133 + self.rel_logits_proj = RelLogitsProj(self.d_rel, self.rel_extent)
134 + self.q_norm = InklingRMSNorm(head_dim, eps=config.rms_norm_eps)
135 + self.k_norm = InklingRMSNorm(head_dim, eps=config.rms_norm_eps)
136 +
137 + # Short convolution on the K/V streams (per-head-width, TP sharded),
138 + # applied after the qkvr projection and before q/k norm.
139 + kv_conv_dim = self.num_kv_heads * head_dim
140 + self.conv_owner = conv_owner
141 + self.k_sconv = InklingShortConv(
142 + kv_conv_dim, config.sconv_kernel_size, owner=conv_owner, stream_idx=_K
143 + )
144 + self.v_sconv = InklingShortConv(
145 + kv_conv_dim, config.sconv_kernel_size, owner=conv_owner, stream_idx=_V
146 + )
147 +
148 + # FA4 left/right window; right=0 keeps it causal. local_extent-1 mirrors
149 + # the source (sliding_window_size - 1).
150 + self.window_size: tuple[int, int] = (
151 + (local_extent - 1, 0) if is_local else (-1, -1)
152 + )
153 + # Static per-layer-type KV length bound for the split heuristic: local
154 + # layers never see more than the sliding window.
155 + vllm_config = get_current_vllm_config()
156 + self._max_kv_len = (
157 + local_extent if is_local else vllm_config.model_config.max_model_len
158 + )
159 +
160 + # ---- KV-cache wiring (reuse FlashAttentionBackend for metadata) ----
161 + cache_config = vllm_config.cache_config
162 + self.kv_cache_dtype = (
163 + cache_config.cache_dtype if cache_config is not None else "auto"
164 + )
165 + self.kv_cache_torch_dtype = kv_cache_dtype_str_to_dtype(
166 + self.kv_cache_dtype, vllm_config.model_config
167 + )
168 + self.register_buffer("k_scale", torch.ones((), dtype=torch.float32))
169 + self.register_buffer("v_scale", torch.ones((), dtype=torch.float32))
170 +
171 + compilation_config = vllm_config.compilation_config
172 + if prefix in compilation_config.static_forward_context:
173 + raise ValueError(f"Duplicate layer name: {prefix}")
174 + compilation_config.static_forward_context[prefix] = self
175 + self.kv_cache = torch.tensor([]) # replaced by bind_kv_cache
176 +
177 + register_fa4_warmup(
178 + InklingFA4WarmupConfig(
179 + num_heads=self.num_heads,
180 + num_kv_heads=self.num_kv_heads,
181 + head_dim=self.head_dim,
182 + rel_extent=self.rel_extent,
183 + window_size=self.window_size,
184 + is_local=self.is_local,
185 + max_kv_len=self._max_kv_len,
186 + dtype=vllm_config.model_config.dtype,
187 + kv_dtype=self.kv_cache_torch_dtype,
188 + block_size=vllm_config.cache_config.block_size,
189 + max_num_reqs=vllm_config.scheduler_config.max_num_seqs,
190 + max_num_batched_tokens=(
191 + vllm_config.scheduler_config.max_num_batched_tokens
192 + ),
193 + )
194 + )
195 +
196 + def get_attn_backend(self) -> type[AttentionBackend]:
197 + return FlashAttentionBackend
198 +
199 + def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
200 + block_size = vllm_config.cache_config.block_size
201 + if self.is_local:
202 + assert self.local_extent is not None
203 + return SlidingWindowSpec(
204 + block_size=block_size,
205 + num_kv_heads=self.num_kv_heads,
206 + head_size=self.head_dim,
207 + dtype=self.kv_cache_torch_dtype,
208 + sliding_window=self.local_extent,
209 + )
210 + return FullAttentionSpec(
211 + block_size=block_size,
212 + num_kv_heads=self.num_kv_heads,
213 + head_size=self.head_dim,
214 + dtype=self.kv_cache_torch_dtype,
215 + )
216 +
217 + def _split_kv_cache(self) -> tuple[torch.Tensor, torch.Tensor]:
218 + key_cache, value_cache = self.kv_cache.transpose(1, 2).split(
219 + self.head_dim, dim=-1
220 + )
221 + return (
222 + canonicalize_singleton_dim_strides(key_cache),
223 + canonicalize_singleton_dim_strides(value_cache),
224 + )
225 +
226 + def forward(
227 + self,
228 + positions: torch.Tensor,
229 + hidden_states: torch.Tensor,
230 + log_scaling: torch.Tensor | None = None,
231 + ) -> torch.Tensor:
232 + num_tokens = hidden_states.shape[0]
233 + qkvr, _ = self.qkvr(hidden_states)
234 +
235 + attn_metadata = get_forward_context().attn_metadata
236 + attn_output = torch.empty(
237 + (num_tokens, self.num_heads, self.head_dim),
238 + dtype=qkvr.dtype,
239 + device=qkvr.device,
240 + )
241 + if not isinstance(attn_metadata, dict):
242 + attn_output.zero_()
243 + else:
244 + conv_meta = attn_metadata[self.conv_owner.prefix]
245 + md = attn_metadata[self.prefix]
246 + assert isinstance(conv_meta, InklingSconvMetadata)
247 + fa_md = cast(FlashAttentionMetadata, md)
248 + assert self.kv_cache.numel() > 0
249 + assert self.conv_owner.kv_cache.numel() > 0
250 + # One launch: K/V sconv (conv-cache insert + conv + residual),
251 + # Q/K per-head rmsnorm, and the attention KV-cache write. K/V are
252 + # consumed via the KV cache; only normed q is materialized.
253 + key_cache, value_cache = self._split_kv_cache()
254 + off_k, _ = self.conv_owner.stream_ranges[_K]
255 + off_v, _ = self.conv_owner.stream_ranges[_V]
256 + q, rel_logits = fused_qkvr_prep(
257 + qkvr,
258 + self.k_sconv.weight.squeeze(1),
259 + self.v_sconv.weight.squeeze(1),
260 + self.q_norm.weight,
261 + self.k_norm.weight,
262 + self.rel_logits_proj.proj,
263 + self.q_norm.variance_epsilon,
264 + self.num_heads,
265 + self.num_kv_heads,
266 + self.head_dim,
267 + self.d_rel,
268 + self.conv_owner.kv_cache,
269 + key_cache,
270 + value_cache,
271 + positions,
272 + conv_meta.block_table,
273 + conv_meta.seq_idx,
274 + conv_meta.slot_mapping,
275 + conv_meta.query_start,
276 + fa_md.slot_mapping,
277 + off_k,
278 + off_v,
279 + self.conv_owner.block_size,
280 + log_scaling if not self.is_local else None,
281 + )
282 + q = q.view(num_tokens, self.num_heads, self.head_dim)
283 + self._attention(q, rel_logits, attn_output)
284 +
285 + flat = attn_output.view(num_tokens, -1)
286 + output, _ = self.wo_ud(flat)
287 + return output
288 +
289 + def _attention(
290 + self,
291 + q: torch.Tensor,
292 + rel_logits: torch.Tensor,
293 + output: torch.Tensor,
294 + ) -> None:
295 + attn_metadata = get_forward_context().attn_metadata
296 + assert isinstance(attn_metadata, dict)
297 + md = cast(FlashAttentionMetadata, attn_metadata[self.prefix])
298 +
299 + nt = md.num_actual_tokens
300 + key_cache, value_cache = self._split_kv_cache()
301 + max_seqlen_q = bucket_max_seqlen_q(md.max_query_len)
302 + num_splits = inkling_fa4_num_splits(
303 + is_local=self.is_local,
304 + batch_size=md.seq_lens.shape[0],
305 + max_query_len=max_seqlen_q,
306 + num_heads=self.num_heads,
307 + num_kv_heads=self.num_kv_heads,
308 + max_kv_len=self._max_kv_len,
309 + )
310 + inkling_fa4_rel_attention(
311 + q[:nt],
312 + key_cache,
313 + value_cache,
314 + block_table=md.block_table,
315 + cache_seqlens=md.seq_lens,
316 + cu_seqlens_q=md.query_start_loc,
317 + max_seqlen_q=max_seqlen_q,
318 + softmax_scale=self.scaling,
319 + causal=True,
320 + window_size=self.window_size,
321 + rel_extent=self.rel_extent,
322 + rel_logits=rel_logits[:nt],
323 + num_splits=num_splits,
324 + out=output[:nt],
325 + )
vllm/models/inkling/nvidia/layernorm.py new
+26
@@ -0,0 +1,26 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling RMSNorm (no bias, weight-scaled), backed by the vendored Triton kernel."""
4 +
5 +from __future__ import annotations
6 +
7 +import torch
8 +from torch import nn
9 +
10 +from .ops import rmsnorm
11 +
12 +
13 +class InklingRMSNorm(nn.Module):
14 + def __init__(self, hidden_size: int, eps: float = 1e-6) -> None:
15 + super().__init__()
16 + self.weight = nn.Parameter(torch.ones(hidden_size))
17 + self.variance_epsilon = eps
18 + self.hidden_size = hidden_size
19 +
20 + def forward(self, x: torch.Tensor) -> torch.Tensor:
21 + if x.numel() == 0:
22 + return x
23 + original_shape = x.shape
24 + x_2d = x.contiguous().view(-1, self.hidden_size)
25 + y = rmsnorm(x_2d, self.weight, self.variance_epsilon)
26 + return y.view(original_shape)
vllm/models/inkling/nvidia/logits_processor.py new
+73
@@ -0,0 +1,73 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling logits processor with muP output scaling."""
4 +
5 +from __future__ import annotations
6 +
7 +import torch
8 +
9 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
10 +from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
11 +
12 +
13 +class InklingLogitsProcessor(LogitsProcessor):
14 + """``LogitsProcessor`` that applies Inkling's muP logits width multiplier.
15 +
16 + Args:
17 + vocab_size: Padded vocabulary size.
18 + org_vocab_size: Unpadded vocabulary size (defaults to ``vocab_size``).
19 + scale: Base logits scale (kept ``1.0`` for the served checkpoint).
20 + logits_as_input: Whether the input is already logits.
21 + soft_cap: Optional logit soft cap (``None`` for the served checkpoint).
22 + logits_mup_width_multiplier: muP width divisor for the final logits;
23 + ``None`` or ``0`` disables it.
24 + """
25 +
26 + def __init__(
27 + self,
28 + vocab_size: int,
29 + org_vocab_size: int | None = None,
30 + scale: float = 1.0,
31 + logits_as_input: bool = False,
32 + soft_cap: float | None = None,
33 + logits_mup_width_multiplier: float | None = None,
34 + ) -> None:
35 + super().__init__(
36 + vocab_size=vocab_size,
37 + org_vocab_size=org_vocab_size,
38 + scale=scale,
39 + logits_as_input=logits_as_input,
40 + soft_cap=soft_cap,
41 + )
42 + self.logits_mup_width_multiplier = logits_mup_width_multiplier
43 + self._logits_zero: torch.Tensor | None = None
44 +
45 + def forward(
46 + self,
47 + lm_head: VocabParallelEmbedding,
48 + hidden_states: torch.Tensor,
49 + embedding_bias: torch.Tensor | None = None,
50 + ) -> torch.Tensor | None:
51 + mup = self.logits_mup_width_multiplier
52 + if not mup:
53 + return super().forward(lm_head, hidden_states, embedding_bias)
54 + # Fold the muP width divisor into the lm_head GEMM alpha (fp32 epilogue):
55 + # no separate elementwise kernel, no bf16 rounding of scaled logits, and
56 + # no weight mutation. Overfit to the served checkpoint: bf16 lm_head, no
57 + # soft cap, unit logits scale.
58 + assert self.soft_cap is None
59 + assert self.scale == 1.0
60 + w = lm_head.weight
61 + if self._logits_zero is None:
62 + self._logits_zero = w.new_zeros(1)
63 + logits = torch.addmm(
64 + self._logits_zero,
65 + hidden_states,
66 + w.t(),
67 + beta=0.0,
68 + alpha=1.0 / mup,
69 + )
70 + logits = self._gather_logits(logits)
71 + if logits is not None:
72 + logits = logits[..., : self.org_vocab_size]
73 + return logits
vllm/models/inkling/nvidia/mlp.py new
+63
@@ -0,0 +1,63 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling dense SwiGLU MLP (also used as the MoE shared expert).
4 +
5 +The checkpoint stores the gate/up projection as a single fused, *interleaved*
6 +weight (``[gate0, up0, gate1, up1, ...]``), so we use a plain
7 +``ColumnParallelLinear`` whose contiguous row-sharding keeps each gate/up pair
8 +together, and an interleaved SwiGLU activation.
9 +"""
10 +
11 +from __future__ import annotations
12 +
13 +import torch
14 +from torch import nn
15 +
16 +from vllm.model_executor.layers.linear import (
17 + ColumnParallelLinear,
18 + RowParallelLinear,
19 +)
20 +from vllm.model_executor.layers.quantization import QuantizationConfig
21 +
22 +
23 +class InklingDenseMLP(nn.Module):
24 + def __init__(
25 + self,
26 + hidden_size: int,
27 + intermediate_size: int,
28 + *,
29 + use_global_scale: bool = False,
30 + quant_config: QuantizationConfig | None = None,
31 + prefix: str = "",
32 + ) -> None:
33 + super().__init__()
34 + self.gate_up_proj = ColumnParallelLinear(
35 + hidden_size,
36 + 2 * intermediate_size,
37 + bias=False,
38 + quant_config=quant_config,
39 + prefix=f"{prefix}.gate_up_proj",
40 + )
41 + self.down_proj = RowParallelLinear(
42 + intermediate_size,
43 + hidden_size,
44 + bias=False,
45 + quant_config=quant_config,
46 + reduce_results=False,
47 + prefix=f"{prefix}.down_proj",
48 + )
49 + if use_global_scale:
50 + self.global_scale = nn.Parameter(torch.empty(1), requires_grad=False)
51 + else:
52 + self.global_scale = None
53 +
54 + def forward(self, x: torch.Tensor) -> torch.Tensor:
55 + from .ops import silu_and_mul_triton
56 +
57 + gate_up, _ = self.gate_up_proj(x)
58 + x = silu_and_mul_triton(gate_up)
59 + x, _ = self.down_proj(x)
60 + if self.global_scale is not None:
61 + x = x * self.global_scale
62 + # TP-partial output: the layer's reduce-scatter fallback consumes it.
63 + return x
vllm/models/inkling/nvidia/model.py new
+678
@@ -0,0 +1,678 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling model implementation for NVIDIA GPUs."""
4 +
5 +from __future__ import annotations
6 +
7 +from collections.abc import Iterable
8 +from typing import Any
9 +
10 +import regex as re
11 +import torch
12 +from torch import nn
13 +
14 +from vllm.config import VllmConfig
15 +from vllm.distributed import (
16 + get_pp_group,
17 + get_tensor_model_parallel_rank,
18 + get_tensor_model_parallel_world_size,
19 + tensor_model_parallel_all_gather,
20 + tensor_model_parallel_reduce_scatter,
21 +)
22 +from vllm.forward_context import get_forward_context
23 +from vllm.model_executor.layers.quantization import QuantizationConfig
24 +from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead
25 +from vllm.model_executor.models.interfaces import (
26 + MultiModalEmbeddings,
27 + SupportsMultiModal,
28 + SupportsPP,
29 +)
30 +from vllm.model_executor.models.utils import (
31 + AutoWeightsLoader,
32 + WeightsMapper,
33 + make_empty_intermediate_tensors_factory,
34 + make_layers,
35 + maybe_prefix,
36 +)
37 +from vllm.models.inkling.common.mm_preprocess import (
38 + InklingDummyInputsBuilder,
39 + InklingMultiModalProcessor,
40 + InklingProcessingInfo,
41 + inkling_audio_enabled,
42 + inkling_vision_enabled,
43 +)
44 +from vllm.models.inkling.common.towers import InklingAudio, InklingVision
45 +from vllm.multimodal import MULTIMODAL_REGISTRY
46 +from vllm.sequence import IntermediateTensors
47 +
48 +from ..configs import InklingMMConfig, InklingModelConfig
49 +from ..nvfp4 import InklingNvfp4Config
50 +from .attention import InklingAttention, compute_log_scaling_tau
51 +from .layernorm import InklingRMSNorm
52 +from .logits_processor import InklingLogitsProcessor
53 +from .mlp import InklingDenseMLP
54 +from .moe import InklingMoE
55 +from .ops.lamport import get_lamport_rs_conv, initialize_lamport_rs_conv
56 +from .ops.norm import add_rmsnorm, embed_rmsnorm
57 +from .sconv_swa_attn import _ATTN, _MLP, InklingConvState, InklingSconvMetadata
58 +from .short_conv import InklingShortConv
59 +
60 +
61 +def _layer_id(name: str) -> int | None:
62 + m = re.search(r"\.layers\.(\d+)\.", name)
63 + return int(m.group(1)) if m else None
64 +
65 +
66 +def _sconv_add_norm(
67 + delta: torch.Tensor,
68 + hidden: torch.Tensor,
69 + sconv: InklingShortConv,
70 + norm: InklingRMSNorm | None,
71 + positions: torch.Tensor,
72 +) -> tuple[torch.Tensor | None, torch.Tensor]:
73 + """``h = hidden + sconv(TP-sum(delta)); y = rmsnorm(h)``.
74 +
75 + The Lamport path performs reduce-scatter + shard sconv + all-gather +
76 + residual add + norm. The NCCL path handles unsupported configurations."""
77 + attn_metadata = get_forward_context().attn_metadata
78 + m = (
79 + attn_metadata.get(sconv.owner.prefix)
80 + if isinstance(attn_metadata, dict)
81 + else None
82 + )
83 + cache = sconv.owner.kv_cache
84 + off_s, ws = sconv.owner.stream_ranges[sconv.stream_idx]
85 + norm_w = norm.weight if norm is not None else None
86 + eps = norm.variance_epsilon if norm is not None else 0.0
87 +
88 + mm = get_lamport_rs_conv(hidden.shape[-1], sconv.kernel_size)
89 + if mm is not None and mm.usable(delta.shape[0]) and m is not None:
90 + assert cache.numel() > 0
91 + assert isinstance(m, InklingSconvMetadata)
92 + return mm.rs_sconv_ag_add_norm(
93 + delta,
94 + hidden,
95 + sconv.weight.squeeze(1),
96 + norm_w,
97 + eps,
98 + cache,
99 + positions,
100 + m.block_table,
101 + m.seq_idx,
102 + m.slot_mapping,
103 + off_s,
104 + ws,
105 + sconv.owner.block_size,
106 + )
107 +
108 + # Fallback: NCCL RS -> shard sconv -> AG -> fused add(+rmsnorm).
109 + shard = tensor_model_parallel_reduce_scatter(delta, dim=-1)
110 + shard = sconv(shard.contiguous(), positions)
111 + full = tensor_model_parallel_all_gather(shard, dim=-1)
112 + if norm is None:
113 + return None, hidden + full
114 + return add_rmsnorm(hidden, full, norm_w, eps)
115 +
116 +
117 +class InklingDecoderLayer(nn.Module):
118 + def __init__(
119 + self,
120 + config: InklingModelConfig,
121 + layer_id: int,
122 + is_local: bool,
123 + quant_config: QuantizationConfig | None,
124 + prefix: str,
125 + nvfp4_config: InklingNvfp4Config | None = None,
126 + ) -> None:
127 + super().__init__()
128 + # Per-layer owner of the conv state as a paged SWA cache. The 4 sconv
129 + # streams (K/V/attn/mlp) are packed head-major into one block and share
130 + # it. Built first so the attention layer can wire its K/V sconv to it.
131 + self.conv_state = InklingConvState(
132 + num_kv_heads=(
133 + config.swa_num_key_value_heads
134 + if is_local
135 + else config.num_key_value_heads
136 + ),
137 + head_dim=config.swa_head_dim if is_local else config.head_dim,
138 + hidden_size=config.hidden_size,
139 + kernel_size=config.sconv_kernel_size,
140 + prefix=f"{prefix}.conv_state",
141 + )
142 + self.attn_norm = InklingRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
143 + self.attn = InklingAttention(
144 + config,
145 + num_heads=(
146 + config.swa_num_attention_heads
147 + if is_local
148 + else config.num_attention_heads
149 + ),
150 + num_kv_heads=(
151 + config.swa_num_key_value_heads
152 + if is_local
153 + else config.num_key_value_heads
154 + ),
155 + head_dim=config.swa_head_dim if is_local else config.head_dim,
156 + rel_extent=config.rel_extent,
157 + local_extent=config.sliding_window_size,
158 + is_local=is_local,
159 + prefix=f"{prefix}.attn",
160 + quant_config=quant_config,
161 + conv_owner=self.conv_state,
162 + )
163 + self.mlp_norm = InklingRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
164 + if layer_id < config.dense_mlp_idx:
165 + self.mlp: nn.Module = InklingDenseMLP(
166 + hidden_size=config.hidden_size,
167 + intermediate_size=config.dense_intermediate_size,
168 + use_global_scale=config.use_global_scale,
169 + quant_config=quant_config,
170 + prefix=f"{prefix}.mlp",
171 + )
172 + else:
173 + # InklingMoE decides per layer (from the checkpoint exclude list)
174 + # whether the routed experts are NVFP4 or bf16; the shared sink
175 + # experts are always bf16.
176 + self.mlp = InklingMoE(
177 + config,
178 + layer_id,
179 + prefix=f"{prefix}.mlp",
180 + nvfp4_config=nvfp4_config,
181 + )
182 +
183 + # Short convolution on the attention-output and MLP-output residual
184 + # streams, hidden-sharded: the sublayer outputs are reduce-scattered
185 + # to [T, H/tp], the sconv runs on the shard, and an all-gather
186 + # restores the full residual — all fused with the residual add + next
187 + # rmsnorm via the Lamport P2P kernels for decode-sized batches.
188 + tp_size = get_tensor_model_parallel_world_size()
189 + sconv_dim = config.hidden_size // tp_size
190 + self.attn_sconv = InklingShortConv(
191 + sconv_dim, config.sconv_kernel_size, owner=self.conv_state, stream_idx=_ATTN
192 + )
193 + self.mlp_sconv = InklingShortConv(
194 + sconv_dim, config.sconv_kernel_size, owner=self.conv_state, stream_idx=_MLP
195 + )
196 +
197 + def forward(
198 + self,
199 + positions: torch.Tensor,
200 + hidden_states: torch.Tensor,
201 + pending: tuple[torch.Tensor | None, InklingShortConv] | None = None,
202 + attn_in: torch.Tensor | None = None,
203 + log_scaling: torch.Tensor | None = None,
204 + ) -> tuple[torch.Tensor, tuple[torch.Tensor | None, InklingShortConv]]:
205 + # The previous sublayer's (pre-reduce, pre-sconv) delta is folded in
206 + # fused with its RS/sconv/AG and this layer's pre-attention rmsnorm.
207 + # A None delta means the partials sit in the NVLS symm buffer.
208 + if pending is None:
209 + if attn_in is None:
210 + # First layer; on the text path attn_norm comes fused with
211 + # the embedding gather (chain_weight in embed_rmsnorm).
212 + attn_in = self.attn_norm(hidden_states)
213 + else:
214 + attn_in, hidden_states = _sconv_add_norm(
215 + pending[0], hidden_states, pending[1], self.attn_norm, positions
216 + )
217 + attn_output = self.attn(positions, attn_in, log_scaling)
218 + mlp_in, hidden_states = _sconv_add_norm(
219 + attn_output, hidden_states, self.attn_sconv, self.mlp_norm, positions
220 + )
221 + mlp_output = self.mlp(mlp_in)
222 + # The caller folds mlp_output (pre-reduce, pre-sconv) into the next
223 + # fused sconv+add+rmsnorm.
224 + return hidden_states, (mlp_output, self.mlp_sconv)
225 +
226 +
227 +class InklingReplicatedEmbedding(nn.Module):
228 + """Full-vocab embedding table replicated on every TP rank.
229 +
230 + Trades the full table per rank (~2.3 GiB at V=201k / H=6144 bf16, vs a
231 + 1/tp shard) for no masked lookup or per-lookup TP all-reduce, and keeps the
232 + full table on-rank for the fused gather+norm kernel. Bit-exact vs
233 + vocab-parallel: the all-reduce there only ever summed one real row against
234 + exact zeros. The LM head stays vocab-sharded.
235 + """
236 +
237 + def __init__(self, num_embeddings: int, embedding_dim: int) -> None:
238 + super().__init__()
239 + self.weight = nn.Parameter(
240 + torch.empty(num_embeddings, embedding_dim, dtype=torch.get_default_dtype()),
241 + requires_grad=False,
242 + )
243 +
244 + def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
245 + return embed_rmsnorm(input_ids, self.weight, None, 0.0)
246 +
247 +
248 +class InklingModel(nn.Module):
249 + def __init__(
250 + self,
251 + *,
252 + config: InklingModelConfig,
253 + quant_config: QuantizationConfig | None,
254 + prefix: str,
255 + nvfp4_config: InklingNvfp4Config | None = None,
256 + ) -> None:
257 + super().__init__()
258 + self.config = config
259 + self.embed_tokens = InklingReplicatedEmbedding(
260 + config.padded_vocab_size, config.hidden_size
261 + )
262 + self.embed_norm = (
263 + InklingRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
264 + if config.use_embed_norm
265 + else None
266 + )
267 + local_ids = set(config.local_layer_ids)
268 +
269 + def get_layer(prefix: str) -> InklingDecoderLayer:
270 + idx = _layer_id(prefix + ".") or int(prefix.split(".")[-1])
271 + return InklingDecoderLayer(
272 + config, idx, idx in local_ids, quant_config, prefix, nvfp4_config
273 + )
274 +
275 + self.start_layer, self.end_layer, self.layers = make_layers(
276 + config.num_hidden_layers, get_layer, prefix=f"{prefix}.layers"
277 + )
278 + self.norm = InklingRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
279 + self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
280 + ["hidden_states"], config.hidden_size
281 + )
282 +
283 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
284 + # Row gather + embed_norm in one launch.
285 + norm = self.embed_norm
286 + return embed_rmsnorm(
287 + input_ids,
288 + self.embed_tokens.weight,
289 + norm.weight if norm is not None else None,
290 + norm.variance_epsilon if norm is not None else 0.0,
291 + )
292 +
293 + def forward(
294 + self,
295 + input_ids: torch.Tensor | None,
296 + positions: torch.Tensor,
297 + intermediate_tensors: IntermediateTensors | None,
298 + inputs_embeds: torch.Tensor | None = None,
299 + ) -> torch.Tensor | IntermediateTensors:
300 + attn_in0: torch.Tensor | None = None
301 + if get_pp_group().is_first_rank:
302 + if inputs_embeds is not None:
303 + # embed_norm was already applied when producing inputs_embeds.
304 + hidden_states = inputs_embeds
305 + else:
306 + # Gather + embed_norm + the first layer's attn_norm, one launch.
307 + norm = self.embed_norm
308 + hidden_states, attn_in0 = embed_rmsnorm(
309 + input_ids,
310 + self.embed_tokens.weight,
311 + norm.weight if norm is not None else None,
312 + self.config.rms_norm_eps,
313 + chain_weight=self.layers[self.start_layer].attn_norm.weight,
314 + )
315 + else:
316 + assert intermediate_tensors is not None
317 + hidden_states = intermediate_tensors["hidden_states"]
318 + hidden_states = hidden_states.view(-1, hidden_states.shape[-1])
319 + log_scaling = None
320 + if self.config.log_scaling_n_floor is not None:
321 + log_scaling = compute_log_scaling_tau(
322 + positions,
323 + self.config.log_scaling_n_floor,
324 + self.config.log_scaling_alpha,
325 + )
326 +
327 + pending: tuple[torch.Tensor | None, InklingShortConv] | None = None
328 + for layer in self.layers[self.start_layer : self.end_layer]:
329 + hidden_states, pending = layer(
330 + positions,
331 + hidden_states,
332 + pending=pending,
333 + attn_in=attn_in0,
334 + log_scaling=log_scaling,
335 + )
336 + attn_in0 = None
337 +
338 + if not get_pp_group().is_last_rank:
339 + if pending is not None:
340 + hidden_states = _sconv_add_norm(
341 + pending[0], hidden_states, pending[1], None, positions
342 + )[1]
343 + return IntermediateTensors({"hidden_states": hidden_states})
344 + if pending is not None:
345 + # Final RS/sconv/AG + residual add fused with the final rmsnorm.
346 + norm_out = _sconv_add_norm(
347 + pending[0], hidden_states, pending[1], self.norm, positions
348 + )[0]
349 + assert norm_out is not None
350 + return norm_out
351 + return self.norm(hidden_states)
352 +
353 +
354 +class _TmlForCausalLMBase(nn.Module, SupportsPP):
355 + """Shared text-backbone causal-LM scaffolding for both entry classes."""
356 +
357 + hf_to_vllm_mapper = WeightsMapper(
358 + orig_to_new_substr={
359 + ".w13_dn": ".gate_up_proj",
360 + ".w2_md": ".down_proj",
361 + },
362 + orig_to_new_stacked={
363 + ".attn.wq_du.": (".attn.qkvr.", 0),
364 + ".attn.wk_dv.": (".attn.qkvr.", 1),
365 + ".attn.wv_dv.": (".attn.qkvr.", 2),
366 + ".attn.wr_du.": (".attn.qkvr.", 3),
367 + },
368 + orig_to_new_prefix={
369 + "model.llm.layers.": "model.layers.",
370 + "model.llm.embed_norm": "model.embed_norm",
371 + "model.llm.embed": "model.embed_tokens",
372 + "model.llm.norm": "model.norm",
373 + "model.llm.unembed": "lm_head",
374 + },
375 + orig_to_new_suffix={
376 + # NVFP4 scale
377 + ".w13_weight.scale": ".w13_weight_scale",
378 + ".w13_weight.scale2": ".w13_weight_scale_2",
379 + ".w2_weight.scale": ".w2_weight_scale",
380 + ".w2_weight.scale2": ".w2_weight_scale_2",
381 + },
382 + )
383 +
384 + def _build(
385 + self,
386 + vllm_config: VllmConfig,
387 + text_config: InklingModelConfig,
388 + prefix: str,
389 + ) -> None:
390 + quant_config = vllm_config.quant_config
391 + self.config = text_config
392 + # NVFP4 experts are detected directly from the checkpoint quant config;
393 + # only the MoE experts are quantized (attention/dense MLP stay bf16).
394 + self.nvfp4_config = InklingNvfp4Config.from_hf_config(
395 + vllm_config.model_config.hf_config
396 + )
397 + # Read by the MRV2 runner to publish per-request short-conv metadata.
398 + # Short convolution is intrinsic to Inkling, so this is always set.
399 + self.uses_sconv = True
400 + self.model = InklingModel(
401 + config=text_config,
402 + quant_config=quant_config,
403 + prefix=maybe_prefix(prefix, "model"),
404 + nvfp4_config=self.nvfp4_config,
405 + )
406 + initialize_lamport_rs_conv(
407 + text_config.hidden_size,
408 + text_config.sconv_kernel_size,
409 + vllm_config.scheduler_config.max_num_batched_tokens,
410 + )
411 + self.lm_head = ParallelLMHead(
412 + text_config.padded_vocab_size,
413 + text_config.hidden_size,
414 + org_num_embeddings=text_config.padded_vocab_size,
415 + quant_config=quant_config,
416 + prefix=maybe_prefix(prefix, "lm_head"),
417 + )
418 + self.logits_processor = InklingLogitsProcessor(
419 + text_config.padded_vocab_size,
420 + org_vocab_size=text_config.vocab_size,
421 + soft_cap=text_config.final_logit_softcapping,
422 + logits_mup_width_multiplier=text_config.logits_mup_width_multiplier,
423 + )
424 + self.make_empty_intermediate_tensors = ( # type: ignore[method-assign]
425 + self.model.make_empty_intermediate_tensors
426 + )
427 +
428 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
429 + return self.model.embed_input_ids(input_ids)
430 +
431 + def forward(
432 + self,
433 + input_ids: torch.Tensor | None,
434 + positions: torch.Tensor,
435 + intermediate_tensors: IntermediateTensors | None = None,
436 + inputs_embeds: torch.Tensor | None = None,
437 + **kwargs: object,
438 + ) -> torch.Tensor | IntermediateTensors:
439 + return self.model(
440 + input_ids,
441 + positions,
442 + intermediate_tensors,
443 + inputs_embeds,
444 + )
445 +
446 + def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
447 + return self.logits_processor(self.lm_head, hidden_states)
448 +
449 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
450 + return _load_inkling_weights(self, weights, self.config)
451 +
452 +
453 +class InklingForCausalLM(_TmlForCausalLMBase):
454 + """Text-only entry point (``inkling_model`` checkpoints)."""
455 +
456 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
457 + super().__init__()
458 + self._build(vllm_config, vllm_config.model_config.hf_config, prefix)
459 +
460 +
461 +@MULTIMODAL_REGISTRY.register_processor(
462 + InklingMultiModalProcessor,
463 + info=InklingProcessingInfo,
464 + dummy_inputs=InklingDummyInputsBuilder,
465 +)
466 +class InklingForConditionalGeneration(_TmlForCausalLMBase, SupportsMultiModal):
467 + """Top-level (multimodal) entry point.
468 +
469 + Builds the vision + audio towers on top of the shared text backbone. Inkling has
470 + NO cross-modal fusion (the vision tower emits one token per patch, the audio
471 + tower one token per frame), so generation reuses the inherited backbone
472 + ``forward`` / ``compute_logits`` (the latter already applies muP) and this
473 + class only adds multimodal embedding + merge.
474 + """
475 +
476 + hf_to_vllm_mapper = _TmlForCausalLMBase.hf_to_vllm_mapper | WeightsMapper(
477 + orig_to_new_prefix={
478 + "model.audio.": "audio.",
479 + "model.visual.": "visual.vision_encoder.",
480 + },
481 + )
482 +
483 + @classmethod
484 + def get_placeholder_str(cls, modality: str, i: int) -> str | None:
485 + if modality.startswith("image"):
486 + return "<|content_image|>"
487 + if modality.startswith("audio"):
488 + return "<|content_audio_input|>"
489 + raise ValueError("Only image or audio modality is supported")
490 +
491 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
492 + super().__init__()
493 + config: InklingMMConfig = vllm_config.model_config.hf_config
494 +
495 + self.visual = (
496 + InklingVision(config.vision_config, prefix=maybe_prefix(prefix, "visual"))
497 + if inkling_vision_enabled(config)
498 + else None
499 + )
500 + self.audio = (
501 + InklingAudio(config.audio_config, prefix=maybe_prefix(prefix, "audio"))
502 + if inkling_audio_enabled(config)
503 + else None
504 + )
505 +
506 + self._build(vllm_config, config.text_config, prefix)
507 +
508 + # -- multimodal embedding -------------------------------------------
509 +
510 + def _process_image_input(
511 + self, pixel_values: Any, num_patches: Any
512 + ) -> tuple[torch.Tensor, ...]:
513 + assert self.visual is not None
514 + # pixel_values is a list (per item) of [P_i, 2, P, P, 3] tensors,
515 + # or a single concatenated tensor. Normalize to a flat batch, run the
516 + # tower once, then split back per item.
517 + if isinstance(pixel_values, (list, tuple)):
518 + if not pixel_values:
519 + return ()
520 + sizes = [int(p.shape[0]) for p in pixel_values]
521 + patches = torch.cat(list(pixel_values), dim=0)
522 + else:
523 + patches = pixel_values
524 + sizes = self._sizes_from(num_patches, patches.shape[0])
525 +
526 + patches = patches.to(device=self.visual.device, dtype=self.visual.dtype)
527 + embeds = self.visual(patches) # [total_patches, D]
528 + return tuple(embeds.split(sizes))
529 +
530 + def _process_audio_input(
531 + self, input_audio_features: Any, num_audio_tokens: Any
532 + ) -> tuple[torch.Tensor, ...]:
533 + assert self.audio is not None
534 + if isinstance(input_audio_features, (list, tuple)):
535 + if not input_audio_features:
536 + return ()
537 + sizes = [int(d.shape[0]) for d in input_audio_features]
538 + dmel = torch.cat(list(input_audio_features), dim=0)
539 + else:
540 + dmel = input_audio_features
541 + sizes = self._sizes_from(num_audio_tokens, dmel.shape[0])
542 +
543 + dmel = dmel.to(device=self.audio.device)
544 + embeds = self.audio(dmel) # [total_frames, D]
545 + return tuple(embeds.split(sizes))
546 +
547 + @staticmethod
548 + def _sizes_from(counts: Any, total: int) -> list[int]:
549 + if counts is None:
550 + return [total]
551 + if isinstance(counts, torch.Tensor):
552 + return [int(c) for c in counts.flatten().tolist()]
553 + if isinstance(counts, (list, tuple)):
554 + flat: list[int] = []
555 + for c in counts:
556 + flat.append(int(c.item()) if isinstance(c, torch.Tensor) else int(c))
557 + return flat
558 + return [int(counts)]
559 +
560 + def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
561 + # Iterate modalities in a stable order so the returned per-item tensors
562 + # line up with their appearance order; the positional merge in
563 + # embed_input_ids handles actual placement.
564 + pixel_values = kwargs.get("pixel_values")
565 + num_patches = kwargs.get("num_patches")
566 + input_audio_features = kwargs.get("input_audio_features")
567 + num_audio_tokens = kwargs.get("num_audio_tokens")
568 +
569 + embeddings: tuple[torch.Tensor, ...] = ()
570 + if pixel_values is not None and self.visual is not None:
571 + embeddings += self._process_image_input(pixel_values, num_patches)
572 + if input_audio_features is not None and self.audio is not None:
573 + embeddings += self._process_audio_input(
574 + input_audio_features, num_audio_tokens
575 + )
576 + return embeddings
577 +
578 + def embed_input_ids(
579 + self,
580 + input_ids: torch.Tensor,
581 + multimodal_embeddings: MultiModalEmbeddings | None = None,
582 + *,
583 + is_multimodal: torch.Tensor | None = None,
584 + ) -> torch.Tensor:
585 + # Override the base's 1-arg embed_input_ids: the runner calls this 3-arg
586 + # signature for multimodal models. Text embeddings come from the shared
587 + # backbone (which applies embed_norm); MM embeddings are scattered in.
588 + from vllm.model_executor.models.utils import _merge_multimodal_embeddings
589 +
590 + # Placeholder ids use unused vocabulary slots and these positions are
591 + # overwritten by MM embeds below.
592 + inputs_embeds = self.model.embed_input_ids(input_ids)
593 + if multimodal_embeddings is None or len(multimodal_embeddings) == 0:
594 + return inputs_embeds
595 + assert is_multimodal is not None
596 + return _merge_multimodal_embeddings(
597 + inputs_embeds=inputs_embeds,
598 + multimodal_embeddings=multimodal_embeddings,
599 + is_multimodal=is_multimodal,
600 + )
601 +
602 + def get_language_model(self) -> nn.Module:
603 + # This class IS the causal LM (the towers are side branches), so the
604 + # language model is self — callers expect a module exposing ``.model``
605 + # and ``.lm_head``.
606 + return self
607 +
608 +
609 +# ===========================================================================
610 +# Weight loading
611 +# ===========================================================================
612 +
613 +
614 +_MOE_EXPERT_WEIGHT_RE = re.compile(
615 + r"^(?P<mlp>.*\.mlp)\.(?P<rest>(?:shared_)?experts\..+)$"
616 +)
617 +
618 +
619 +def _load_inkling_weights(
620 + module: nn.Module,
621 + weights: Iterable[tuple[str, torch.Tensor]],
622 + config: InklingModelConfig,
623 +) -> set[str]:
624 + moe_modules = {
625 + name: mod for name, mod in module.named_modules() if isinstance(mod, InklingMoE)
626 + }
627 + loaded: set[str] = set()
628 + tp_size = get_tensor_model_parallel_world_size()
629 + tp_rank = get_tensor_model_parallel_rank()
630 + local_ids = set(config.local_layer_ids)
631 +
632 + def _iter_loadable_weights() -> Iterable[tuple[str, torch.Tensor]]:
633 + for name, weight in module.hf_to_vllm_mapper.apply(weights):
634 + shard_id = getattr(weight, "shard_id", None)
635 + # Replicate K/V conv-free GQA heads when tp_size > num_kv_heads.
636 + if (
637 + shard_id in (1, 2)
638 + and name.endswith(".attn.qkvr.weight")
639 + and weight.shape[0] > 0
640 + ):
641 + lid = _layer_id(name)
642 + if lid is not None:
643 + is_local = lid in local_ids
644 + n_kv = (
645 + config.swa_num_key_value_heads
646 + if is_local
647 + else config.num_key_value_heads
648 + )
649 + head_dim = config.swa_head_dim if is_local else config.head_dim
650 + if tp_size > n_kv and weight.shape[0] == n_kv * head_dim:
651 + kv_idx = (tp_rank * n_kv) // tp_size
652 + weight = weight.narrow(0, kv_idx * head_dim, head_dim)
653 + weight.shard_id = shard_id
654 +
655 + # MoE expert tensors (fused stacked, routed + shared sink): translate
656 + # the checkpoint layout to per-expert FusedMoE loads.
657 + moe_match = _MOE_EXPERT_WEIGHT_RE.match(name)
658 + if moe_match is not None and moe_match.group("mlp") in moe_modules:
659 + moe = moe_modules[moe_match.group("mlp")]
660 + for rel in moe.load_expert_weight(moe_match.group("rest"), weight):
661 + loaded.add(f"{moe_match.group('mlp')}.{rel}")
662 + continue
663 +
664 + yield name, weight
665 +
666 + # The release checkpoint also carries auxiliary prediction-head weights;
667 + # they are not part of the causal LM served by this implementation.
668 + loader = AutoWeightsLoader(module, skip_prefixes=["model.mtp."])
669 + loaded |= loader.load_weights(_iter_loadable_weights())
670 +
671 + # Post-load MoE fixups (default input scales, zeroed EP-padding experts).
672 + for moe_name, moe in moe_modules.items():
673 + for rel in moe.finalize_load():
674 + loaded.add(f"{moe_name}.{rel}")
675 + return loaded
676 +
677 +
678 +EntryClass = [InklingForCausalLM, InklingForConditionalGeneration]
vllm/models/inkling/nvidia/moe.py new
+578
@@ -0,0 +1,578 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling mixture-of-experts on vLLM's FusedMoE abstraction.
4 +
5 +Overfit to the served checkpoint: sigmoid gate (+ selection bias) top-k over
6 +the routed experts, log-sigmoid renormalization over the k routed + S shared
7 +"sink" logits, scaled by route_scale * global_scale. The routed top-k goes
8 +through vLLM's FusedMoE (which handles TP/EP); the sink experts run in
9 +:class:`InklingSinkExperts` -- replicated across EP ranks (every token
10 +activates every sink) and always bf16 (the checkpoint excludes every
11 +``shared_experts`` from quantization).
12 +
13 +NVFP4 routed experts reuse vLLM's ModelOpt NVFP4 fused-MoE method; excluded
14 +(bf16) layers fall back to the unquantized method. The checkpoint's fused
15 +stacked tensors (interleaved gate/up rows, ``.scale`` / ``.scale2`` /
16 +``.input_amax`` aux tensors) are translated to the standard per-expert loads
17 +in :meth:`InklingMoE.load_expert_weight`.
18 +"""
19 +
20 +from __future__ import annotations
21 +
22 +import math
23 +from typing import TYPE_CHECKING
24 +
25 +import torch
26 +from torch import nn
27 +from torch.nn.parameter import Parameter
28 +
29 +import vllm.envs as envs
30 +from vllm.config import get_current_vllm_config
31 +from vllm.distributed import (
32 + get_dp_group,
33 + get_pcp_group,
34 + get_tensor_model_parallel_rank,
35 + get_tensor_model_parallel_world_size,
36 +)
37 +from vllm.model_executor.kernels.linear.cute_dsl import ll_bf16
38 +from vllm.model_executor.layers.fused_moe import FusedMoE
39 +from vllm.model_executor.utils import set_weight_attrs
40 +from vllm.platforms import current_platform
41 +from vllm.triton_utils import tl, tldevice, triton
42 +from vllm.utils.multi_stream_utils import maybe_execute_in_parallel
43 +from vllm.utils.torch_utils import aux_stream
44 +
45 +from ..configs import InklingModelConfig
46 +from ..nvfp4 import FLOAT4_E2M1_MAX, FLOAT8_E4M3_MAX
47 +
48 +if TYPE_CHECKING:
49 + from vllm.model_executor.layers.fused_moe.routed_experts import (
50 + RoutedExperts,
51 + )
52 +
53 + from ..nvfp4 import InklingNvfp4Config
54 +
55 +# ---------------------------------------------------------------------------
56 +# Gate / expert selection
57 +# ---------------------------------------------------------------------------
58 +
59 +_INKLING_LL_BF16_MAX_TOKENS = 64
60 +
61 +
62 +def _linear_with_fp32_out(x: torch.Tensor, weight: torch.Tensor) -> torch.Tensor:
63 + leading = list(x.shape[:-1])
64 + flat = x.flatten(0, -2)
65 + if (
66 + flat.shape[0] <= _INKLING_LL_BF16_MAX_TOKENS
67 + and flat.dtype == torch.bfloat16
68 + and weight.dtype == torch.bfloat16
69 + and flat.is_cuda
70 + and flat.is_contiguous()
71 + and weight.is_contiguous()
72 + and flat.shape[1] % 8 == 0
73 + and current_platform.has_device_capability(90)
74 + and ll_bf16.is_available()
75 + ):
76 + out = ll_bf16.ll_bf16_gemm(flat, weight)
77 + else:
78 + out = torch.mm(flat, weight.T, out_dtype=torch.float32)
79 + return out.view(*leading, weight.shape[0])
80 +
81 +
82 +@triton.jit(do_not_specialize=["T", "route_scale"])
83 +def _inkling_gate_select_kernel(
84 + logits_ptr, # [T, G] fp32 gate logits (stride_logits_0 may include pad)
85 + bias_ptr, # [R] fp32 selection bias (or 0 ptr if HAS_BIAS=False)
86 + global_scale_ptr, # [1] fp32 (or unused if HAS_GSCALE=False)
87 + ids_ptr, # [T, K + S] int32 out: selected expert ids
88 + weights_ptr, # [T, K + S] fp32 out: renormalized weights
89 + route_scale,
90 + T,
91 + G: tl.constexpr, # total gate experts (routed + shared)
92 + stride_logits_0,
93 + R: tl.constexpr, # routed experts
94 + K: tl.constexpr, # top-k routed
95 + S: tl.constexpr, # shared (sink) experts
96 + HAS_BIAS: tl.constexpr,
97 + HAS_GSCALE: tl.constexpr,
98 + BLOCK_G: tl.constexpr,
99 +):
100 + pid = tl.program_id(0).to(tl.int64)
101 + if pid >= T:
102 + return
103 + offs = tl.arange(0, BLOCK_G)
104 + mask_r = offs < R
105 + logits = tl.load(
106 + logits_ptr + pid * stride_logits_0 + offs,
107 + mask=offs < G,
108 + other=float("-inf"),
109 + ).to(tl.float32)
110 +
111 + # Selection scores: sigmoid(routed logits) (+ bias), non-routed lanes -inf.
112 + sel = tl.where(mask_r, tl.sigmoid(logits), float("-inf"))
113 + if HAS_BIAS:
114 + bias = tl.load(bias_ptr + offs, mask=mask_r, other=0.0).to(tl.float32)
115 + sel = tl.where(mask_r, sel + bias, float("-inf"))
116 +
117 + scale = route_scale
118 + if HAS_GSCALE:
119 + scale = scale * tl.load(global_scale_ptr).to(tl.float32)
120 +
121 + # Iterative top-K (K is small); argmax tie-breaks to the lowest index
122 + # (stable ordering).
123 + A: tl.constexpr = K + S
124 + offs_a = tl.arange(0, A)
125 + top_ids = tl.zeros([A], dtype=tl.int32)
126 + active = tl.zeros([A], dtype=tl.float32)
127 + for kk in tl.static_range(K):
128 + idx = tl.argmax(sel, axis=0).to(tl.int32)
129 + raw = tl.max(tl.where(offs == idx, logits, float("-inf")), axis=0)
130 + top_ids = tl.where(offs_a == kk, idx, top_ids)
131 + active = tl.where(offs_a == kk, raw, active)
132 + sel = tl.where(offs == idx, float("-inf"), sel)
133 + if S > 0:
134 + # Shared sink logits sit at the tail of the gate output; their expert
135 + # ids continue after the routed range (R + j).
136 + for jj in tl.static_range(S):
137 + raw = tl.max(tl.where(offs == R + jj, logits, float("-inf")), axis=0)
138 + top_ids = tl.where(offs_a == K + jj, tl.full([], R + jj, tl.int32), top_ids)
139 + active = tl.where(offs_a == K + jj, raw, active)
140 +
141 + # Log-sigmoid renormalization over the K + S active logits.
142 + abs_l = tl.abs(active)
143 + min_l = tl.minimum(active, 0.0)
144 + log_probs = min_l - tldevice.log1p(tldevice.exp(-abs_l))
145 + max_lp = tl.max(log_probs, axis=0)
146 + exp_shifted = tldevice.exp(log_probs - max_lp)
147 + sum_exp = tl.sum(exp_shifted, axis=0)
148 + weights = exp_shifted / sum_exp * scale
149 +
150 + tl.store(ids_ptr + pid * A + offs_a, top_ids)
151 + tl.store(weights_ptr + pid * A + offs_a, weights)
152 +
153 +
154 +def inkling_gate_select(
155 + logits: torch.Tensor, # [T, >=G] fp32 (rows may carry GEMM padding)
156 + n_gate_experts: int,
157 + n_routed_experts: int,
158 + topk: int,
159 + n_shared_experts: int,
160 + bias: torch.Tensor | None,
161 + route_scale: float,
162 + global_scale: torch.Tensor | None,
163 +) -> tuple[torch.Tensor, torch.Tensor]:
164 + """Sigmoid + bias + top-k + log-sigmoid renorm; returns (weights, ids)."""
165 + assert logits.dtype == torch.float32
166 + tokens = logits.shape[0]
167 + active = topk + n_shared_experts
168 + topk_ids = torch.empty((tokens, active), dtype=torch.int32, device=logits.device)
169 + topk_weights = torch.empty(
170 + (tokens, active), dtype=torch.float32, device=logits.device
171 + )
172 + if tokens == 0:
173 + return topk_weights, topk_ids
174 + _inkling_gate_select_kernel[(tokens,)](
175 + logits,
176 + bias if bias is not None else logits,
177 + global_scale if global_scale is not None else logits,
178 + topk_ids,
179 + topk_weights,
180 + route_scale,
181 + tokens,
182 + n_gate_experts,
183 + logits.stride(0),
184 + n_routed_experts,
185 + topk,
186 + n_shared_experts,
187 + HAS_BIAS=bias is not None,
188 + HAS_GSCALE=global_scale is not None,
189 + BLOCK_G=triton.next_power_of_2(n_gate_experts),
190 + )
191 + return topk_weights, topk_ids
192 +
193 +
194 +class InklingGate(nn.Module):
195 + """Sigmoid gate with selection bias, log-sigmoid renorm after top-k, and
196 + global scale (the served checkpoint's only configuration)."""
197 +
198 + def __init__(
199 + self,
200 + d_model: int,
201 + n_routed_experts: int,
202 + n_shared_experts: int,
203 + experts_per_token: int,
204 + route_scale: float,
205 + *,
206 + use_global_scale: bool = False,
207 + use_gate_bias: bool = False,
208 + ) -> None:
209 + super().__init__()
210 + self.n_routed_experts = n_routed_experts
211 + self.n_shared_experts = n_shared_experts
212 + self.n_total_experts = n_routed_experts + n_shared_experts
213 + self.topk = experts_per_token
214 + self.route_scale = route_scale
215 +
216 + padded_experts = self.n_total_experts + (-self.n_total_experts) % 8
217 + self.weight = Parameter(
218 + torch.empty(padded_experts, d_model), requires_grad=False
219 + )
220 + set_weight_attrs(self.weight, {"weight_loader": self._load_weight})
221 + if use_global_scale:
222 + self.global_scale = Parameter(
223 + torch.empty(1, dtype=torch.float32), requires_grad=False
224 + )
225 + else:
226 + self.global_scale = None
227 + if use_gate_bias:
228 + self.bias = Parameter(
229 + torch.empty(n_routed_experts, dtype=torch.float32),
230 + requires_grad=False,
231 + )
232 + else:
233 + self.bias = None
234 +
235 + @staticmethod
236 + def _load_weight(param: Parameter, loaded_weight: torch.Tensor) -> None:
237 + param.data.zero_()
238 + param.data[: loaded_weight.shape[0]].copy_(loaded_weight)
239 +
240 + def compute_logits(self, x: torch.Tensor) -> torch.Tensor:
241 + """fp32 gate logits [T, n_total_experts + pad] (pad columns are junk)."""
242 + return _linear_with_fp32_out(x, self.weight)
243 +
244 + def select_experts(
245 + self, gating_output: torch.Tensor
246 + ) -> tuple[torch.Tensor, torch.Tensor]:
247 + """Full selection: (weights, ids) of [T, K + S]. The first K entries
248 + are the routed top-k; the S trailing entries are the sink gammas."""
249 + return inkling_gate_select(
250 + gating_output,
251 + self.n_total_experts,
252 + self.n_routed_experts,
253 + self.topk,
254 + self.n_shared_experts,
255 + self.bias,
256 + self.route_scale,
257 + self.global_scale,
258 + )
259 +
260 +
261 +# ---------------------------------------------------------------------------
262 +# MoE layer
263 +# ---------------------------------------------------------------------------
264 +
265 +
266 +def _inkling_moe_ep_size() -> int:
267 + """EP size the FusedMoE layer will run with (mirrors
268 + FusedMoEParallelConfig.make: experts shard over tp * dp * pcp when
269 + expert parallelism is enabled)."""
270 + parallel_config = get_current_vllm_config().parallel_config
271 + if not parallel_config.enable_expert_parallel:
272 + return 1
273 + world = (
274 + get_tensor_model_parallel_world_size()
275 + * get_dp_group().world_size
276 + * get_pcp_group().world_size
277 + )
278 + return world if world > 1 else 1
279 +
280 +
281 +class InklingSinkExperts(nn.Module):
282 + """Shared "sink" experts with per-token gammas, in bf16.
283 +
284 + Replicated across EP ranks (every token activates every sink, so
285 + EP-sharding them would hotspot the owning rank) and TP-sharded on the
286 + intermediate dim so the output remains a TP-partial sum like the routed
287 + output. The sinks are always bf16 (the checkpoint excludes every
288 + ``shared_experts`` from quantization): the experts concatenate into two
289 + plain dense GEMMs with the fused sink epilogue between them.
290 + """
291 +
292 + def __init__(
293 + self, n_experts: int, d_model: int, d_mlp: int, *, prefix: str = ""
294 + ) -> None:
295 + super().__init__()
296 + self.n_experts = n_experts
297 + tp_size = get_tensor_model_parallel_world_size()
298 + self.tp_rank = get_tensor_model_parallel_rank()
299 + intermediate_pp = d_mlp // tp_size
300 + self.w13_weight = Parameter(
301 + torch.empty(n_experts, 2 * intermediate_pp, d_model),
302 + requires_grad=False,
303 + )
304 + self.w2_weight = Parameter(
305 + torch.empty(d_model, n_experts * intermediate_pp),
306 + requires_grad=False,
307 + )
308 + self._unit: torch.Tensor | None = None
309 +
310 + def load_weight(self, key: str, weight: torch.Tensor) -> list[str]:
311 + """Load one checkpoint sink tensor (stacked over the S experts)."""
312 + if key == "w13_weight":
313 + if weight.shape != self.w13_weight.shape:
314 + shard = self.w13_weight.shape[1]
315 + weight = weight.narrow(1, self.tp_rank * shard, shard)
316 + self.w13_weight.data.copy_(weight)
317 + return [key]
318 +
319 + assert key == "w2_weight"
320 + shard = self.w2_weight.shape[1] // self.n_experts
321 + shard_start = 0 if weight.shape[2] == shard else self.tp_rank * shard
322 + for expert_idx, expert_weight in enumerate(weight):
323 + local_weight = expert_weight.narrow(1, shard_start, shard)
324 + start = expert_idx * shard
325 + self.w2_weight.data[:, start : start + shard].copy_(local_weight)
326 + return [key]
327 +
328 + def forward(self, x: torch.Tensor, gammas: torch.Tensor) -> torch.Tensor:
329 + """``sum_e gammas[:, e] * MLP_e(x)`` (TP-partial along d_mlp)."""
330 + from .ops import sink_silu_mul_epilogue
331 +
332 + # One GEMM over the experts' stacked w13 (a view), fused epilogue,
333 + # then one GEMM whose K-reduction over the K-concatenated w2 performs
334 + # the expert sum.
335 + if self._unit is None or self._unit.device != x.device:
336 + self._unit = torch.ones(
337 + self.n_experts, dtype=torch.float32, device=x.device
338 + )
339 + raw = x @ self.w13_weight.view(-1, x.shape[-1]).T # (T, S*2F)
340 + h = sink_silu_mul_epilogue(
341 + raw, self._unit, gammas, self._unit, self.n_experts, x.dtype
342 + )
343 + return h @ self.w2_weight.T # (T, D)
344 +
345 +
346 +class InklingMoE(nn.Module):
347 + def __init__(
348 + self,
349 + config: InklingModelConfig,
350 + layer_id: int,
351 + *,
352 + prefix: str = "",
353 + nvfp4_config: InklingNvfp4Config | None = None,
354 + ) -> None:
355 + super().__init__()
356 + # Overfit to the served checkpoint: sigmoid gate renormalized after
357 + # top-k, shared sink experts, interleaved gate/up checkpoint rows.
358 + assert config.gate_activation == "sigmoid" and config.norm_after_topk
359 + assert config.n_shared_experts > 0 and config.shared_expert_sink
360 + assert config.inference_moe_w13_interleaved
361 + n_routed = config.n_routed_experts
362 + n_shared = config.n_shared_experts
363 + self.n_routed_experts = n_routed
364 + self.gate = InklingGate(
365 + d_model=config.hidden_size,
366 + n_routed_experts=n_routed,
367 + n_shared_experts=n_shared,
368 + experts_per_token=config.num_experts_per_tok,
369 + route_scale=config.route_scale,
370 + use_global_scale=config.use_global_scale,
371 + use_gate_bias=config.use_gate_bias,
372 + )
373 +
374 + moe_quant_config = None
375 + if nvfp4_config is not None and nvfp4_config.experts_quantized(layer_id):
376 + from vllm.model_executor.layers.quantization.modelopt import (
377 + ModelOptNvFp4Config,
378 + )
379 +
380 + # The Inkling checkpoint is ModelOpt NVFP4; exclusion is decided per
381 + # layer right here, so no exclude list is needed.
382 + moe_quant_config = ModelOptNvFp4Config(
383 + quant_method="NVFP4",
384 + is_checkpoint_nvfp4_serialized=True,
385 + kv_cache_quant_algo=None,
386 + exclude_modules=[],
387 + group_size=nvfp4_config.group_size,
388 + )
389 +
390 + # TRTLLM MoE kernels assume equal, contiguous per-rank expert slabs
391 + # (local_expert_offset = ep_rank * local_num_experts), so pad the
392 + # expert count to a multiple of the EP size. A no-op for the usual
393 + # power-of-two EP sizes (n_routed is a power of two).
394 + num_experts = n_routed + (-n_routed) % _inkling_moe_ep_size()
395 +
396 + self.experts = FusedMoE(
397 + num_experts=num_experts,
398 + top_k=config.num_experts_per_tok,
399 + hidden_size=config.hidden_size,
400 + intermediate_size=config.intermediate_size,
401 + renormalize=False,
402 + quant_config=moe_quant_config,
403 + prefix=f"{prefix}.experts",
404 + custom_routing_function=self._select_routed,
405 + router_logits_dtype=torch.float32,
406 + activation="silu",
407 + )
408 + # The decoder layer reduce-scatters the MoE delta into the sconv
409 + # stream itself (RS -> shard sconv -> AG); the runner must return the
410 + # per-rank partial sum instead of all-reducing.
411 + self.experts.moe_config.skip_final_all_reduce = True
412 +
413 + self._routed_sel: tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None = None
414 + # The sinks are always bf16; fail loudly on a checkpoint that
415 + # quantizes them instead of silently misloading.
416 + assert nvfp4_config is None or not nvfp4_config.shared_experts_quantized(
417 + layer_id
418 + ), f"layer {layer_id}: NVFP4 shared experts are not supported"
419 +
420 + self.sink_experts = InklingSinkExperts(
421 + n_experts=n_shared,
422 + d_model=config.hidden_size,
423 + d_mlp=config.intermediate_size,
424 + prefix=f"{prefix}.shared_experts",
425 + )
426 +
427 + # Sink chain overlaps the routed MoE call on the aux stream for
428 + # decode-sized batches (same pattern as the runner's SharedExperts
429 + # multi-stream overlap). The routed GEMM runs on the default stream and
430 + # the sink chain on the aux stream, joined via these two events by
431 + # ``maybe_execute_in_parallel``.
432 + self._sink_stream: torch.cuda.Stream | None = aux_stream()
433 + self._sink_events = (torch.cuda.Event(), torch.cuda.Event())
434 +
435 + def _select_routed(
436 + self,
437 + hidden_states: torch.Tensor,
438 + gating_output: torch.Tensor,
439 + topk: int,
440 + renormalize: bool,
441 + ) -> tuple[torch.Tensor, torch.Tensor]:
442 + """FusedMoE ``custom_routing_function``: the routed top-k slice of the
443 + full (routed + sink) selection.
444 +
445 + forward() stashes its selection (keyed by logits identity) so the
446 + gate select runs once per layer; the fallback covers paths where the
447 + runner re-derives the logits (e.g. naive DP dispatch).
448 + """
449 + del hidden_states, renormalize
450 + assert topk == self.gate.topk
451 + cached = self._routed_sel
452 + self._routed_sel = None
453 + if cached is not None and cached[0] is gating_output:
454 + return cached[1], cached[2]
455 + weights, ids = self.gate.select_experts(gating_output)
456 + return weights[:, :topk].contiguous(), ids[:, :topk].contiguous()
457 +
458 + def forward(self, x: torch.Tensor) -> torch.Tensor | None:
459 + router_logits = self.gate.compute_logits(x)
460 + num_tokens = x.shape[0]
461 + # One gate select per layer: the routed slice is stashed for the
462 + # routing function inside the FusedMoE op; the sink gammas are the
463 + # trailing columns.
464 + k = self.gate.topk
465 + weights, ids = self.gate.select_experts(router_logits)
466 + self._routed_sel = (
467 + router_logits,
468 + weights[:, :k].contiguous(),
469 + ids[:, :k].contiguous(),
470 + )
471 + gammas = weights[:, k:]
472 +
473 + out, sink_out = maybe_execute_in_parallel(
474 + lambda: self.experts(hidden_states=x, router_logits=router_logits),
475 + lambda: self.sink_experts(x, gammas),
476 + self._sink_events[0],
477 + self._sink_events[1],
478 + self._sink_stream
479 + if num_tokens <= envs.VLLM_SHARED_EXPERTS_STREAM_TOKEN_THRESHOLD
480 + else None,
481 + )
482 + self._routed_sel = None
483 +
484 + return out + sink_out
485 +
486 + # -- weight loading ----------------------------------------------------
487 +
488 + def _local_expert_slots(self) -> dict[int, int]:
489 + """Global expert id -> local slot for this rank's expert partition."""
490 + manager = self.experts.routed_experts.expert_map_manager
491 + if manager.expert_map is None:
492 + return {g: g for g in range(manager.global_num_experts)}
493 + emap = manager.expert_map.tolist()
494 + return {g: slot for g, slot in enumerate(emap) if slot >= 0}
495 +
496 + def load_expert_weight(self, name: str, weight: torch.Tensor) -> list[str]:
497 + """Load one checkpoint expert tensor.
498 +
499 + ``name`` is relative to the mlp module: ``experts.<t>`` (routed
500 + stack) or ``shared_experts.shared_<t>`` (sink experts). Returns the
501 + loaded param names (relative to this module).
502 + """
503 + if name.startswith("shared_experts."):
504 + key = name.split(".", 1)[1].replace("shared_", "", 1)
505 + return [
506 + f"sink_experts.{p}" for p in self.sink_experts.load_weight(key, weight)
507 + ]
508 +
509 + experts: RoutedExperts = self.experts.routed_experts
510 + key = name.split(".", 1)[1]
511 +
512 + # original_shape is unused by the vLLM serving layout.
513 + if key.endswith(".original_shape"):
514 + return []
515 + if key.endswith(".input_amax"):
516 + projection = "w13" if key.startswith("w13") else "w2"
517 + amax = float(weight.max())
518 + assert math.isfinite(amax) and amax > 0, (
519 + f"bad {projection} input_amax: {amax}"
520 + )
521 + input_scale = getattr(experts, f"{projection}_input_scale")
522 + input_scale.data.fill_(amax / (FLOAT8_E4M3_MAX * FLOAT4_E2M1_MAX))
523 + return [f"experts.routed_experts.{projection}_input_scale"]
524 +
525 + param = getattr(experts, key)
526 + slots = self._local_expert_slots()
527 + gids = sorted(slots)
528 + lids = [slots[g] for g in gids]
529 + tp_rank = experts.moe_config.moe_parallel_config.tp_rank
530 +
531 + if key.endswith("_scale_2"):
532 + # Per-expert scalars, vectorized over the local experts. The
533 + # fused w13 param carries one slot per gate/up half.
534 + vals = weight[gids].float().to(param.device)
535 + param.data[lids] = vals[:, None] if param.data.ndim == 2 else vals
536 + elif key.startswith("w13"):
537 + # Checkpoint w13 rows are interleaved [g0, u0, g1, u1, ...]; the
538 + # fused param layout is [w1(gate); w3(up)]. The TP-local rows form
539 + # one contiguous slab of the interleaved tensor, so upload just
540 + # that slab (a single bounded synchronous H2D; pre-uploading whole
541 + # untrimmed tensors pins the mmap pages of the entire checkpoint
542 + # and OOMs the host) and de-interleave on device.
543 + half = param.shape[1] // 2
544 + for gid, lid in slots.items():
545 + slab = weight[gid].narrow(0, tp_rank * 2 * half, 2 * half)
546 + slab = slab.to(param.device)
547 + param.data[lid, :half].copy_(slab[0::2])
548 + param.data[lid, half:].copy_(slab[1::2])
549 + else:
550 + # w2: shard the packed intermediate (last) dim.
551 + shard = param.shape[2]
552 + for gid, lid in slots.items():
553 + param.data[lid].copy_(weight[gid].narrow(1, tp_rank * shard, shard))
554 + return [f"experts.routed_experts.{key}"]
555 +
556 + def finalize_load(self) -> list[str]:
557 + """Post-load fixups for zeroed padding experts."""
558 + experts = self.experts.routed_experts
559 + out: list[str] = []
560 + # Zero the EP-alignment padding experts (if any) so their
561 + # (never-routed) slots hold defined values.
562 + slots = self._local_expert_slots()
563 + for gid in range(self.n_routed_experts, experts.global_num_experts):
564 + lid = slots.get(gid)
565 + if lid is None:
566 + continue
567 + for pname in (
568 + "w13_weight",
569 + "w2_weight",
570 + "w13_weight_scale",
571 + "w2_weight_scale",
572 + "w13_weight_scale_2",
573 + "w2_weight_scale_2",
574 + ):
575 + p = getattr(experts, pname, None)
576 + if p is not None:
577 + p.data[lid].zero_()
578 + return out
vllm/models/inkling/nvidia/ops/__init__.py new
+43
@@ -0,0 +1,43 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling kernels (NVIDIA).
4 +
5 +``rmsnorm`` / ``sconv`` import eagerly. The SwiGLU kernels and the FA4
6 +relative-attention wrapper are exposed lazily to keep this package's
7 +import path lightweight.
8 +"""
9 +
10 +from typing import TYPE_CHECKING
11 +
12 +from .norm import add_rmsnorm, rmsnorm
13 +from .sconv import fused_sconv
14 +
15 +_LAZY_EXPORTS = {
16 + "silu_and_mul_triton": "silu_and_mul",
17 + "sink_silu_mul_epilogue": "silu_and_mul",
18 + "inkling_fa4_rel_attention": "fa4_rel_attention",
19 +}
20 +
21 +if TYPE_CHECKING:
22 + from .fa4_rel_attention import inkling_fa4_rel_attention # noqa: F401
23 + from .silu_and_mul import ( # noqa: F401
24 + silu_and_mul_triton,
25 + sink_silu_mul_epilogue,
26 + )
27 +
28 +__all__ = [
29 + "add_rmsnorm",
30 + "rmsnorm",
31 + "fused_sconv",
32 + *sorted(_LAZY_EXPORTS),
33 +]
34 +
35 +
36 +def __getattr__(name: str):
37 + module = _LAZY_EXPORTS.get(name)
38 + if module is not None:
39 + import importlib
40 +
41 + mod = importlib.import_module(f".{module}", __name__)
42 + return getattr(mod, name)
43 + raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
vllm/models/inkling/nvidia/ops/fa4_rel_attention.py new
+106
@@ -0,0 +1,106 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from __future__ import annotations
4 +
5 +import torch
6 +
7 +from vllm.platforms import current_platform
8 +
9 +
10 +def bucket_max_seqlen_q(max_seqlen_q: int) -> int:
11 + """Round the FA4 scheduling bound up to a power of two."""
12 + return 1 << max(0, max_seqlen_q - 1).bit_length()
13 +
14 +
15 +def inkling_fa4_num_splits(
16 + *,
17 + is_local: bool,
18 + batch_size: int,
19 + max_query_len: int,
20 + num_heads: int,
21 + num_kv_heads: int,
22 + max_kv_len: int,
23 +) -> int:
24 + """Return the split-KV cap for FA4 with sheared relative bias."""
25 + capability = current_platform.get_device_capability()
26 + if capability is not None and capability.major == 9:
27 + return 1
28 + if is_local:
29 + return 1
30 +
31 + q_rows = max_query_len * (num_heads // num_kv_heads)
32 + q_tiles = (q_rows + 255) // 256
33 + base_ctas = batch_size * num_kv_heads * q_tiles
34 + # Shearing makes split/combine overhead more visible. Multi-tile causal
35 + # prefill saturates around 64 CTAs. Batch-1 decode at very long context is
36 + # memory-bound and uses a TP-specific cap measured through 1M KV tokens.
37 + target_ctas = (
38 + 256 if q_tiles == 1 and batch_size == 1 else (128 if q_tiles == 1 else 64)
39 + )
40 + max_splits = 128
41 + if q_tiles == 1 and batch_size == 1:
42 + if num_kv_heads == 8:
43 + max_splits = 16
44 + elif num_kv_heads == 4 or max_kv_len <= 8192:
45 + max_splits = 32
46 + elif max_kv_len <= 65536:
47 + max_splits = 64
48 + else:
49 + max_splits = 128
50 + return max(
51 + 1,
52 + min(target_ctas // base_ctas, max_splits, (max_kv_len + 127) // 128),
53 + )
54 +
55 +
56 +def inkling_fa4_rel_attention(
57 + q: torch.Tensor,
58 + key_cache: torch.Tensor,
59 + value_cache: torch.Tensor,
60 + *,
61 + block_table: torch.Tensor,
62 + cache_seqlens: torch.Tensor,
63 + cu_seqlens_q: torch.Tensor,
64 + max_seqlen_q: int,
65 + softmax_scale: float,
66 + causal: bool,
67 + window_size: tuple[int, int],
68 + rel_extent: int,
69 + rel_logits: torch.Tensor,
70 + num_splits: int = 32,
71 + out: torch.Tensor | None = None,
72 +) -> torch.Tensor:
73 + """Paged varlen FA4 over the bound K/V cache with the Inkling relative bias.
74 +
75 + ``q`` is ``(num_tokens, num_heads, head_dim)``; ``key_cache`` / ``value_cache``
76 + are the paged caches ``(num_blocks, block_size, num_kv_heads, head_dim)``;
77 + ``block_table`` is the per-request page table and ``cache_seqlens`` the
78 + per-request KV lengths (``seqused_k``). ``rel_logits`` is
79 + ``(num_tokens, num_heads, rel_extent)``.
80 +
81 + The bias uses tml-fa4's sheared relative-bias layout.
82 + """
83 + from vllm.third_party.tml_fa4 import flash_attn_varlen_func
84 +
85 + # cute uses (None, None) to mean "no window".
86 + cute_window = (None, None) if window_size == (-1, -1) else window_size
87 +
88 + ret = flash_attn_varlen_func(
89 + q=q,
90 + k=key_cache,
91 + v=value_cache,
92 + cu_seqlens_q=cu_seqlens_q,
93 + seqused_k=cache_seqlens,
94 + max_seqlen_q=max_seqlen_q,
95 + page_table=block_table,
96 + softmax_scale=softmax_scale,
97 + causal=causal,
98 + window_size=cute_window,
99 + num_splits=num_splits,
100 + return_lse=False,
101 + out=out,
102 + rel_bias=rel_logits.contiguous(),
103 + )
104 + if isinstance(ret, tuple):
105 + return ret[0]
106 + return ret
vllm/models/inkling/nvidia/ops/fa4_warmup.py new
+154
@@ -0,0 +1,154 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Startup compilation of the FA4 kernels used by Inkling."""
4 +
5 +from __future__ import annotations
6 +
7 +from collections.abc import Iterator
8 +from dataclasses import dataclass
9 +from functools import partial
10 +
11 +import torch
12 +
13 +from vllm.model_executor.warmup.cutedsl_warmup import (
14 + CuTeDSLCompileUnit,
15 + register_cutedsl_warmup_provider,
16 +)
17 +
18 +from .fa4_rel_attention import (
19 + bucket_max_seqlen_q,
20 + inkling_fa4_num_splits,
21 + inkling_fa4_rel_attention,
22 +)
23 +
24 +
25 +@dataclass(frozen=True)
26 +class InklingFA4WarmupConfig:
27 + num_heads: int
28 + num_kv_heads: int
29 + head_dim: int
30 + rel_extent: int
31 + window_size: tuple[int, int]
32 + is_local: bool
33 + max_kv_len: int
34 + dtype: torch.dtype
35 + kv_dtype: torch.dtype
36 + block_size: int
37 + max_num_reqs: int
38 + max_num_batched_tokens: int
39 +
40 +
41 +def _num_warps_bucket(num_reqs: int) -> int:
42 + num_warps = min((num_reqs + 30) // 31, 32)
43 + return 1 << (num_warps - 1).bit_length()
44 +
45 +
46 +def _compile(config: InklingFA4WarmupConfig, max_seqlen_q: int, num_reqs: int) -> None:
47 + from torch._subclasses.fake_tensor import FakeTensorMode
48 +
49 + num_splits = inkling_fa4_num_splits(
50 + is_local=config.is_local,
51 + batch_size=num_reqs,
52 + max_query_len=max_seqlen_q,
53 + num_heads=config.num_heads,
54 + num_kv_heads=config.num_kv_heads,
55 + max_kv_len=config.max_kv_len,
56 + )
57 + with FakeTensorMode():
58 + device = torch.accelerator.current_accelerator()
59 + total_q = max_seqlen_q + num_reqs - 1
60 + q = torch.empty(
61 + total_q,
62 + config.num_heads,
63 + config.head_dim,
64 + dtype=config.dtype,
65 + device=device,
66 + )
67 + kv = torch.empty(
68 + 1,
69 + 2,
70 + config.block_size,
71 + config.num_kv_heads,
72 + config.head_dim,
73 + dtype=config.kv_dtype,
74 + device=device,
75 + )
76 + key_cache, value_cache = kv.unbind(1)
77 + inkling_fa4_rel_attention(
78 + q,
79 + key_cache,
80 + value_cache,
81 + block_table=torch.empty(num_reqs, 1, dtype=torch.int32, device=device),
82 + cache_seqlens=torch.empty(num_reqs, dtype=torch.int32, device=device),
83 + cu_seqlens_q=torch.empty(num_reqs + 1, dtype=torch.int32, device=device),
84 + max_seqlen_q=max_seqlen_q,
85 + softmax_scale=1.0 / config.head_dim,
86 + causal=True,
87 + window_size=config.window_size,
88 + rel_extent=config.rel_extent,
89 + rel_logits=torch.empty(
90 + total_q,
91 + config.num_heads,
92 + config.rel_extent,
93 + dtype=config.dtype,
94 + device=device,
95 + ),
96 + num_splits=num_splits,
97 + out=torch.empty_like(q),
98 + )
99 +
100 +
101 +def _iter_compile_units(
102 + config: InklingFA4WarmupConfig,
103 +) -> Iterator[CuTeDSLCompileUnit]:
104 + max_bucket = bucket_max_seqlen_q(config.max_num_batched_tokens)
105 + max_seqlen_q = 1
106 + while max_seqlen_q <= max_bucket:
107 + min_query_len = max_seqlen_q // 2 + 1
108 + max_num_reqs = min(
109 + config.max_num_reqs,
110 + config.max_num_batched_tokens - min_query_len + 1,
111 + )
112 + seen: set[tuple[int, int, int | None, bool]] = set()
113 + for num_reqs in range(1, max_num_reqs + 1):
114 + num_splits = inkling_fa4_num_splits(
115 + is_local=config.is_local,
116 + batch_size=num_reqs,
117 + max_query_len=max_seqlen_q,
118 + num_heads=config.num_heads,
119 + num_kv_heads=config.num_kv_heads,
120 + max_kv_len=config.max_kv_len,
121 + )
122 + key = (
123 + max_seqlen_q,
124 + num_splits,
125 + _num_warps_bucket(num_reqs) if num_splits > 1 else None,
126 + num_reqs > 1024,
127 + )
128 + if key in seen:
129 + continue
130 + seen.add(key)
131 + yield CuTeDSLCompileUnit(
132 + name="inkling_fa4",
133 + key=("inkling_fa4", config, key),
134 + compile=partial(_compile, config, max_seqlen_q, num_reqs),
135 + )
136 + max_seqlen_q *= 2
137 +
138 +
139 +class _WarmupProvider:
140 + def __init__(self) -> None:
141 + self.configs: set[InklingFA4WarmupConfig] = set()
142 +
143 + def get_cutedsl_warmup_compile_units(self) -> tuple[CuTeDSLCompileUnit, ...]:
144 + return tuple(
145 + unit for config in self.configs for unit in _iter_compile_units(config)
146 + )
147 +
148 +
149 +_PROVIDER = _WarmupProvider()
150 +
151 +
152 +def register_fa4_warmup(config: InklingFA4WarmupConfig) -> None:
153 + _PROVIDER.configs.add(config)
154 + register_cutedsl_warmup_provider(_PROVIDER)
vllm/models/inkling/nvidia/ops/lamport.py new
+766
@@ -0,0 +1,766 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Deadlock-free fused RS + short-conv + AG + residual + RMSNorm.
4 +
5 +The public integration surface is ``LamportRSConv.rs_sconv_ag_add_norm``.
6 +
7 +Liveness
8 +--------
9 +Large grids are deliberately split at the two communication dependencies:
10 +
11 + 1. ``_publish_input_kernel`` only publishes rank partials.
12 + 2. ``_reduce_insert_kernel`` waits, reduces, and inserts the local shard.
13 + 3. ``_sconv_publish_kernel`` only computes and publishes the local result.
14 + 4. ``_gather_norm_kernel`` waits, gathers, and normalizes.
15 +
16 +Without PDL, CUDA stream order completes a producer before its consumer. With
17 +PDL, every producer CTA posts all peer stores before triggering its dependent,
18 +and the consumer executes ``gdc_wait`` before polling. A consumer therefore
19 +waits only for stores from a producer that is already running (or complete) on
20 +another GPU. It never waits for another block in its own grid. Consequently
21 +spinning consumers cannot occupy resources needed by any producer, and the
22 +wait-for graph has no cycle. The proof is independent of grid size, block
23 +dispatch order, and occupancy.
24 +
25 +For one token, the first three phases use eight independent channel slices to
26 +expose enough CTA parallelism for decode latency. Each slice has exclusive
27 +ownership of its cache and Lamport columns; the gather/RMSNorm phase retains
28 +one CTA per token so no cross-CTA reduction or completion counter is needed.
29 +
30 +Immediate buffer reuse (including replay of a captured CUDA graph) is also
31 +safe. Rank R cannot republish input for call n+1 until its gather for n has
32 +finished; that gather waited for owner O's output, which O publishes only
33 +after consuming R's input for n. Likewise, R cannot republish output for n+1
34 +until its reduction for n+1 has observed destination D's input; D publishes
35 +that input only after its gather consumed R's output for n. Thus every prior
36 +read happens-before a same-slot rewrite. Three generations reduce incidental
37 +coupling for ordinary launches, but correctness does not depend on rotation.
38 +
39 +The payload itself is the Lamport flag. A 32-bit store publishes two bf16s
40 +atomically; 0x80008000 (two negative zeroes) denotes an empty pair. Real
41 +negative zeroes are changed to positive zero before publication. Consumers
42 +use volatile 32-bit loads and restore the sentinel after consuming a slot.
43 +"""
44 +
45 +from __future__ import annotations
46 +
47 +import os
48 +
49 +import torch
50 +
51 +from vllm.distributed import get_tp_group
52 +from vllm.distributed.parallel_state import in_the_same_node_as
53 +from vllm.logger import init_logger
54 +from vllm.triton_utils import HAS_TRITON, tl, triton
55 +
56 +logger = init_logger(__name__)
57 +
58 +_MAX_TOKENS = 16384
59 +_EMPTY_PAIR = tl.constexpr(0x80008000) if HAS_TRITON else 0x80008000
60 +
61 +
62 +@triton.jit
63 +def _pack_bf16_pairs(values):
64 + """Pack bf16 values into atomic u32 pairs and reserve negative zero."""
65 + lo, hi = tl.split(values.reshape([values.shape[0] // 2, 2]))
66 + lo = lo.to(tl.uint16, bitcast=True)
67 + hi = hi.to(tl.uint16, bitcast=True)
68 + lo = tl.where(lo == 0x8000, 0, lo).to(tl.uint32)
69 + hi = tl.where(hi == 0x8000, 0, hi).to(tl.uint32)
70 + return lo | (hi << 16)
71 +
72 +
73 +@triton.jit
74 +def _unpack_bf16_pairs(values):
75 + lo = (values & 0xFFFF).to(tl.uint16).to(tl.bfloat16, bitcast=True)
76 + hi = (values >> 16).to(tl.uint16).to(tl.bfloat16, bitcast=True)
77 + return tl.interleave(lo, hi)
78 +
79 +
80 +@triton.jit
81 +def _wait_pairs(ptr, offsets, mask):
82 + values = tl.load(ptr + offsets, mask=mask, other=0, volatile=True)
83 + while tl.max(tl.where(mask & (values == _EMPTY_PAIR), 1, 0)) != 0:
84 + values = tl.load(ptr + offsets, mask=mask, other=0, volatile=True)
85 + return values
86 +
87 +
88 +@triton.jit
89 +def _publish_input_kernel(
90 + stage_ptr,
91 + peer_ptrs,
92 + peer_offset_u32,
93 + stride_stage_t,
94 + C: tl.constexpr,
95 + CS: tl.constexpr,
96 + CS_P2: tl.constexpr,
97 + SPLITS: tl.constexpr,
98 + RANK: tl.constexpr,
99 + WORLD: tl.constexpr,
100 + USE_PDL: tl.constexpr,
101 + launch_pdl: tl.constexpr,
102 +):
103 + """Publish this rank's full partial row into every shard owner's slots."""
104 + token = tl.program_id(0).to(tl.int64)
105 + split = tl.program_id(1).to(tl.int64)
106 + CSS: tl.constexpr = CS // SPLITS
107 + pair = tl.arange(0, CS_P2 // 2)
108 + pair_mask = pair < CSS // 2
109 + elem = tl.arange(0, CS_P2)
110 + elem_mask = elem < CSS
111 + ptrs = peer_ptrs.to(tl.pointer_type(tl.uint64))
112 + # Address generation is independent of the preceding stream kernel. The
113 + # acquire remains before stage/peer loads, which may consume its writes.
114 + if USE_PDL:
115 + tl.extra.cuda.gdc_wait()
116 +
117 + for owner in tl.static_range(WORLD):
118 + values = tl.load(
119 + stage_ptr + token * stride_stage_t + owner * CS + split * CSS + elem,
120 + mask=elem_mask,
121 + other=0.0,
122 + )
123 + packed = _pack_bf16_pairs(values)
124 + base = tl.load(ptrs + owner).to(tl.pointer_type(tl.uint32))
125 + dst = (token * WORLD + RANK) * (CS // 2) + split * (CSS // 2) + pair
126 + tl.store(base + peer_offset_u32 + dst, packed, mask=pair_mask)
127 + if USE_PDL:
128 + tl.extra.cuda.gdc_launch_dependents()
129 +
130 +
131 +@triton.jit
132 +def _reduce_insert_kernel(
133 + input_peer_ptrs,
134 + input_peer_offset_u32,
135 + cache_ptr,
136 + slot_ptr,
137 + stride_cache_block,
138 + stride_cache_head,
139 + stride_cache_token,
140 + stride_cache_dim,
141 + block_size,
142 + C: tl.constexpr,
143 + CS: tl.constexpr,
144 + CS_P2: tl.constexpr,
145 + SPLITS: tl.constexpr,
146 + HEAD_SIZE: tl.constexpr,
147 + CACHE_OFFSET: tl.constexpr,
148 + RANK: tl.constexpr,
149 + WORLD: tl.constexpr,
150 + USE_PDL: tl.constexpr,
151 + launch_pdl: tl.constexpr,
152 +):
153 + """Consume all partials for this rank and insert the reduced cache row."""
154 + token = tl.program_id(0).to(tl.int64)
155 + split = tl.program_id(1).to(tl.int64)
156 + CSS: tl.constexpr = CS // SPLITS
157 + source = tl.arange(0, WORLD)
158 + pair = tl.arange(0, CS_P2 // 2)
159 + pair_mask = pair < CSS // 2
160 + offsets = (
161 + (token * WORLD + source)[:, None] * (CS // 2)
162 + + split * (CSS // 2)
163 + + pair[None, :]
164 + )
165 + mask = tl.full([WORLD], True, tl.int1)[:, None] & pair_mask[None, :]
166 + input_ptrs = input_peer_ptrs.to(tl.pointer_type(tl.uint64))
167 + input_u32 = tl.load(input_ptrs + RANK).to(tl.pointer_type(tl.uint32))
168 + input_u32 += input_peer_offset_u32
169 + # Slot metadata and the cache destination do not depend on input publish.
170 + slot = tl.load(slot_ptr + token)
171 + valid = slot >= 0
172 + channel = tl.arange(0, CS_P2)
173 + channel_mask = channel < CSS
174 + global_channel = split * CSS + channel
175 + head = tl.minimum(global_channel // HEAD_SIZE, CS // HEAD_SIZE - 1)
176 + dim = CACHE_OFFSET + global_channel % HEAD_SIZE
177 + safe_slot = tl.maximum(slot, 0).to(tl.int64)
178 + dst = (
179 + cache_ptr
180 + + (safe_slot // block_size) * stride_cache_block
181 + + head * stride_cache_head
182 + + (safe_slot % block_size) * stride_cache_token
183 + + dim * stride_cache_dim
184 + )
185 + if USE_PDL:
186 + tl.extra.cuda.gdc_wait()
187 + packed = _wait_pairs(input_u32, offsets, mask)
188 +
189 + lo = (packed & 0xFFFF).to(tl.uint16).to(tl.bfloat16, bitcast=True)
190 + hi = (packed >> 16).to(tl.uint16).to(tl.bfloat16, bitcast=True)
191 + reduced = tl.interleave(
192 + tl.sum(lo.to(tl.float32), axis=0).to(tl.bfloat16),
193 + tl.sum(hi.to(tl.float32), axis=0).to(tl.bfloat16),
194 + )
195 + tl.store(
196 + input_u32 + offsets,
197 + tl.full([WORLD, CS_P2 // 2], _EMPTY_PAIR, tl.uint32),
198 + mask=mask,
199 + )
200 +
201 + tl.store(dst, reduced, mask=valid & channel_mask)
202 + if USE_PDL:
203 + tl.extra.cuda.gdc_launch_dependents()
204 +
205 +
206 +@triton.jit
207 +def _sconv_publish_kernel(
208 + peer_ptrs,
209 + peer_offset_u32,
210 + residual_ptr,
211 + weight_ptr,
212 + cache_ptr,
213 + position_ptr,
214 + sequence_ptr,
215 + slot_ptr,
216 + block_table_ptr,
217 + stride_residual_t,
218 + stride_cache_block,
219 + stride_cache_head,
220 + stride_cache_token,
221 + stride_cache_dim,
222 + stride_block_table_r,
223 + max_blocks,
224 + block_size,
225 + C: tl.constexpr,
226 + CS: tl.constexpr,
227 + CS_P2: tl.constexpr,
228 + SPLITS: tl.constexpr,
229 + HEAD_SIZE: tl.constexpr,
230 + CACHE_OFFSET: tl.constexpr,
231 + RANK: tl.constexpr,
232 + WORLD: tl.constexpr,
233 + WINDOW: tl.constexpr,
234 + USE_PDL: tl.constexpr,
235 + launch_pdl: tl.constexpr,
236 +):
237 + """Compute this rank's shard, then publish it to every rank."""
238 + tl.static_assert(WINDOW == 4)
239 + token = tl.program_id(0).to(tl.int64)
240 + split = tl.program_id(1).to(tl.int64)
241 + CSS: tl.constexpr = CS // SPLITS
242 + channel = tl.arange(0, CS_P2)
243 + channel_mask = channel < CSS
244 + global_channel = split * CSS + channel
245 + head = tl.minimum(global_channel // HEAD_SIZE, CS // HEAD_SIZE - 1)
246 + dim = CACHE_OFFSET + global_channel % HEAD_SIZE
247 + slot = tl.load(slot_ptr + token)
248 + valid = slot >= 0
249 + position = tl.load(position_ptr + token)
250 + sequence = tl.load(sequence_ptr + token)
251 + safe_slot = tl.maximum(slot, 0).to(tl.int64)
252 + own_ptr = (
253 + cache_ptr
254 + + (safe_slot // block_size) * stride_cache_block
255 + + head * stride_cache_head
256 + + (safe_slot % block_size) * stride_cache_token
257 + + dim * stride_cache_dim
258 + )
259 + # These loads were made visible before reduce/insert could signal us and
260 + # are independent of its cache store. Hoisting them gives PDL useful work
261 + # to overlap while retaining the acquire before every cache read.
262 + residual = tl.load(
263 + residual_ptr + token * stride_residual_t + RANK * CS + global_channel,
264 + mask=channel_mask,
265 + other=0.0,
266 + )
267 + weight0 = tl.load(
268 + weight_ptr + global_channel * WINDOW,
269 + mask=channel_mask,
270 + other=0.0,
271 + ).to(tl.float32)
272 + weight1 = tl.load(
273 + weight_ptr + global_channel * WINDOW + 1,
274 + mask=channel_mask,
275 + other=0.0,
276 + ).to(tl.float32)
277 + weight2 = tl.load(
278 + weight_ptr + global_channel * WINDOW + 2,
279 + mask=channel_mask,
280 + other=0.0,
281 + ).to(tl.float32)
282 + weight3 = tl.load(
283 + weight_ptr + global_channel * WINDOW + 3,
284 + mask=channel_mask,
285 + other=0.0,
286 + ).to(tl.float32)
287 + ptrs = peer_ptrs.to(tl.pointer_type(tl.uint64))
288 + if USE_PDL:
289 + tl.extra.cuda.gdc_wait()
290 + current = tl.load(own_ptr, mask=valid & channel_mask, other=0.0)
291 +
292 + conv = tl.zeros([CS_P2], tl.float32)
293 + for tap_idx in tl.static_range(WINDOW):
294 + source_position = position - (WINDOW - 1) + tap_idx
295 + take = valid & (source_position >= 0)
296 + if tap_idx == WINDOW - 1:
297 + value = tl.where(take, current.to(tl.float32), 0.0)
298 + else:
299 + safe_position = tl.maximum(source_position, 0)
300 + logical_block = tl.minimum(safe_position // block_size, max_blocks - 1)
301 + physical_block = tl.load(
302 + block_table_ptr + sequence * stride_block_table_r + logical_block,
303 + mask=take,
304 + other=0,
305 + ).to(tl.int64)
306 + source_ptr = (
307 + cache_ptr
308 + + physical_block * stride_cache_block
309 + + head * stride_cache_head
310 + + (safe_position % block_size) * stride_cache_token
311 + + dim * stride_cache_dim
312 + )
313 + cached = tl.load(source_ptr, mask=take & channel_mask, other=0.0)
314 + value = tl.where(take, cached.to(tl.float32), 0.0)
315 + if tap_idx == 0:
316 + weight = weight0
317 + elif tap_idx == 1:
318 + weight = weight1
319 + elif tap_idx == 2:
320 + weight = weight2
321 + else:
322 + weight = weight3
323 + conv += value * weight
324 +
325 + # Preserve both bf16 rounding points of the original sublayer.
326 + short_conv_with_skip = (conv + current.to(tl.float32)).to(tl.bfloat16)
327 + output = (residual.to(tl.float32) + short_conv_with_skip.to(tl.float32)).to(
328 + tl.bfloat16
329 + )
330 +
331 + pair = tl.arange(0, CS_P2 // 2)
332 + pair_mask = pair < CSS // 2
333 + packed = _pack_bf16_pairs(output)
334 + row_offset = token * (C // 2) + RANK * (CS // 2) + split * (CSS // 2) + pair
335 +
336 + for destination in tl.static_range(WORLD):
337 + base = tl.load(ptrs + destination).to(tl.pointer_type(tl.uint32))
338 + tl.store(base + peer_offset_u32 + row_offset, packed, mask=pair_mask)
339 + if USE_PDL:
340 + tl.extra.cuda.gdc_launch_dependents()
341 +
342 +
343 +@triton.jit
344 +def _gather_norm_kernel(
345 + output_peer_ptrs,
346 + output_peer_offset_u32,
347 + norm_weight_ptr,
348 + normed_ptr,
349 + residual_out_ptr,
350 + eps,
351 + stride_output_t,
352 + C: tl.constexpr,
353 + C_P2: tl.constexpr,
354 + RANK: tl.constexpr,
355 + HAS_NORM: tl.constexpr,
356 + USE_PDL: tl.constexpr,
357 + launch_pdl: tl.constexpr,
358 +):
359 + """Consume a complete row; one CTA owns all outputs for one token."""
360 + token = tl.program_id(0).to(tl.int64)
361 + pair = tl.arange(0, C_P2 // 2)
362 + pair_mask = pair < C // 2
363 + output_ptrs = output_peer_ptrs.to(tl.pointer_type(tl.uint64))
364 + output_u32 = tl.load(output_ptrs + RANK).to(tl.pointer_type(tl.uint32))
365 + output_u32 += output_peer_offset_u32
366 + offsets = token * (C // 2) + pair
367 + channel = tl.arange(0, C_P2)
368 + channel_mask = channel < C
369 + # Gamma is independent of the preceding sconv publication. Keep the
370 + # acquire immediately before polling the Lamport output slots.
371 + if HAS_NORM:
372 + weight = tl.load(norm_weight_ptr + channel, mask=channel_mask, other=0.0)
373 + if USE_PDL:
374 + tl.extra.cuda.gdc_wait()
375 + packed = _wait_pairs(output_u32, offsets, pair_mask)
376 + row = _unpack_bf16_pairs(packed)
377 + tl.store(
378 + residual_out_ptr + token * stride_output_t + channel, row, mask=channel_mask
379 + )
380 + if HAS_NORM:
381 + row_f32 = tl.where(channel_mask, row.to(tl.float32), 0.0)
382 + inv_rms = tl.rsqrt(tl.sum(row_f32 * row_f32, axis=0) / C + eps)
383 + tl.store(
384 + normed_ptr + token * stride_output_t + channel,
385 + (row_f32 * inv_rms * weight.to(tl.float32)).to(tl.bfloat16),
386 + mask=channel_mask,
387 + )
388 + tl.store(
389 + output_u32 + offsets,
390 + tl.full([C_P2 // 2], _EMPTY_PAIR, tl.uint32),
391 + mask=pair_mask,
392 + )
393 + if USE_PDL:
394 + tl.extra.cuda.gdc_launch_dependents()
395 +
396 +
397 +@triton.jit
398 +def _validate_lamport_init_kernel(
399 + input_peer_ptrs,
400 + output_peer_ptrs,
401 + bad_ptr,
402 + num_pairs,
403 + RANK: tl.constexpr,
404 + BLOCK: tl.constexpr,
405 +):
406 + """Validate both complete local allocations through their fabric pointers."""
407 + offsets = tl.program_id(0).to(tl.int64) * BLOCK + tl.arange(0, BLOCK)
408 + mask = offsets < num_pairs
409 + ptrs_in = input_peer_ptrs.to(tl.pointer_type(tl.uint64))
410 + ptrs_out = output_peer_ptrs.to(tl.pointer_type(tl.uint64))
411 + local_in = tl.load(ptrs_in + RANK).to(tl.pointer_type(tl.uint32))
412 + local_out = tl.load(ptrs_out + RANK).to(tl.pointer_type(tl.uint32))
413 + value_in = tl.load(local_in + offsets, mask=mask, other=_EMPTY_PAIR)
414 + value_out = tl.load(local_out + offsets, mask=mask, other=_EMPTY_PAIR)
415 + bad = tl.max(
416 + tl.where(mask & ((value_in != _EMPTY_PAIR) | (value_out != _EMPTY_PAIR)), 1, 0)
417 + )
418 + if bad != 0:
419 + tl.atomic_max(bad_ptr, 1)
420 +
421 +
422 +class LamportRSConv:
423 + """Persistent symmetric buffers for one TP group."""
424 +
425 + def _initialize_lamport_buffers(self) -> None:
426 + """Arm every slot and collectively verify the exact sentinel bits.
427 +
428 + Do not replace this with a zero-fill: Lamport readiness distinguishes
429 + the bf16 bit pattern for -0.0 (0x8000) from every published payload.
430 + The validation is deliberately collective so one rank cannot enter the
431 + first polling kernel while another rank still has an unarmed buffer.
432 + """
433 + if not self.buf_in.is_contiguous() or not self.buf_out.is_contiguous():
434 + raise RuntimeError("Lamport symmetric buffers must be contiguous")
435 + if self.buf_in.numel() % 2 or self.buf_out.numel() % 2:
436 + raise RuntimeError("Lamport buffers must contain whole uint32 pairs")
437 +
438 + # Fill through int16 so each bf16 lane receives the exact -0.0 bits.
439 + # This covers all three generations and all max-token slots, including
440 + # slots that a smaller first invocation does not touch.
441 + self.buf_in.view(torch.int16).fill_(-0x8000)
442 + self.buf_out.view(torch.int16).fill_(-0x8000)
443 + torch.accelerator.synchronize(self.device)
444 + self.tp.barrier()
445 +
446 + # Scan the complete local allocations. Since every rank performs the
447 + # scan and participates in MAX, success proves every symmetric backing
448 + # allocation was armed before any rank is allowed to use generation 0.
449 + bad = torch.logical_or(
450 + self.buf_in.view(torch.int16).ne(-0x8000).any(),
451 + self.buf_out.view(torch.int16).ne(-0x8000).any(),
452 + ).to(dtype=torch.float32)
453 + bad = self.tp.all_reduce(bad)
454 + if int(bad.item()) != 0:
455 + raise RuntimeError("Lamport sentinel initialization failed on a TP rank")
456 + self.tp.barrier()
457 +
458 + def _initialize_mnnvl_buffers(self) -> None:
459 + """Initialize and validate FlashInfer fabric-mapped Lamport storage."""
460 + self._mnnvl_input_handle.lamport_initialize(self.rank, torch.bfloat16)
461 + self._mnnvl_output_handle.lamport_initialize(self.rank, torch.bfloat16)
462 + torch.accelerator.synchronize(self.device)
463 + self.tp.barrier()
464 +
465 + bad = torch.zeros((), dtype=torch.int32, device=self.device)
466 + num_pairs = self.num_buffers * self.max_tokens * self.hidden_size // 2
467 + _validate_lamport_init_kernel[(triton.cdiv(num_pairs, 256),)](
468 + self.input_peer_ptrs,
469 + self.output_peer_ptrs,
470 + bad,
471 + num_pairs,
472 + RANK=self.rank,
473 + BLOCK=256,
474 + num_warps=4,
475 + )
476 + bad = self.tp.all_reduce(bad.to(torch.float32))
477 + if int(bad.item()) != 0:
478 + raise RuntimeError("MNNVL Lamport sentinel initialization failed")
479 + self.tp.barrier()
480 +
481 + def __init__(
482 + self, hidden_size: int, window_size: int, max_tokens: int = _MAX_TOKENS
483 + ) -> None:
484 + import torch.distributed._symmetric_memory as symm_mem
485 +
486 + tp = get_tp_group()
487 + self.tp = tp
488 + self.group = tp.device_group
489 + self.world_size = tp.world_size
490 + self.rank = tp.rank_in_group
491 + self.device = torch.device(tp.device)
492 + if self.world_size not in (2, 4, 8):
493 + raise ValueError(f"TP world size must be 2, 4, or 8, got {self.world_size}")
494 + if hidden_size % (2 * self.world_size) != 0:
495 + raise ValueError("hidden size must produce an even shard on every rank")
496 + if window_size != 4:
497 + raise ValueError(f"short-conv window size must be 4, got {window_size}")
498 + if max_tokens < 1 or max_tokens > _MAX_TOKENS:
499 + raise ValueError(f"max_tokens must be in [1, {_MAX_TOKENS}]")
500 +
501 + is_cross_node = not all(in_the_same_node_as(tp.cpu_group))
502 + self.hidden_size = hidden_size
503 + self.window_size = window_size
504 + self.max_tokens = max_tokens
505 + self.shard_size = hidden_size // self.world_size
506 + # Three generations follow FlashInfer's Lamport layout. A generation
507 + # is reused only after two intervening collective calls.
508 + self.num_buffers = 3
509 + self.input_generation_bytes = max_tokens * hidden_size * 2
510 + self.output_generation_bytes = max_tokens * hidden_size * 2
511 + self.use_pdl = torch.cuda.get_device_capability(self.device)[0] >= 9
512 + if is_cross_node:
513 + try:
514 + from flashinfer.comm.mnnvl import (
515 + McastGPUBuffer,
516 + TorchDistBackend,
517 + is_mnnvl_fabric_supported,
518 + )
519 + except ImportError as error:
520 + raise RuntimeError(
521 + "cross-node TP requires FlashInfer MNNVL support"
522 + ) from error
523 +
524 + local_supported = int(
525 + is_mnnvl_fabric_supported(torch.accelerator.current_device_index())
526 + )
527 + unsupported = torch.tensor(
528 + 1 - local_supported, dtype=torch.float32, device=self.device
529 + )
530 + unsupported = tp.all_reduce(unsupported)
531 + if int(unsupported.item()) != 0:
532 + raise RuntimeError("cross-node TP is supported only on MNNVL fabric")
533 +
534 + comm_backend = TorchDistBackend(self.group)
535 + allocation_bytes = self.num_buffers * max_tokens * hidden_size * 2
536 + self._mnnvl_input_handle = McastGPUBuffer(
537 + allocation_bytes,
538 + self.world_size,
539 + self.rank,
540 + self.device,
541 + comm_backend,
542 + )
543 + self._mnnvl_output_handle = McastGPUBuffer(
544 + allocation_bytes,
545 + self.world_size,
546 + self.rank,
547 + self.device,
548 + comm_backend,
549 + )
550 + self.input_peer_ptrs = self._mnnvl_input_handle.get_buffer_ptrs_dev()
551 + self.output_peer_ptrs = self._mnnvl_output_handle.get_buffer_ptrs_dev()
552 + self._initialize_mnnvl_buffers()
553 + logger.info("using FlashInfer fabric-mapped MNNVL Lamport buffers")
554 + else:
555 + self.buf_in = symm_mem.empty(
556 + self.num_buffers,
557 + max_tokens,
558 + self.world_size,
559 + self.shard_size,
560 + dtype=torch.bfloat16,
561 + device=self.device,
562 + )
563 + self.buf_out = symm_mem.empty(
564 + self.num_buffers,
565 + max_tokens,
566 + hidden_size,
567 + dtype=torch.bfloat16,
568 + device=self.device,
569 + )
570 + group_name = self.group.group_name
571 + input_handle = symm_mem.rendezvous(self.buf_in, group_name)
572 + output_handle = symm_mem.rendezvous(self.buf_out, group_name)
573 + self.input_peer_ptrs = input_handle.buffer_ptrs_dev
574 + self.output_peer_ptrs = output_handle.buffer_ptrs_dev
575 + self._input_handle = input_handle
576 + self._output_handle = output_handle
577 + self._initialize_lamport_buffers()
578 + self.generation = 0
579 +
580 + def usable(self, num_tokens: int) -> bool:
581 + return 0 < num_tokens <= self.max_tokens
582 +
583 + def rs_sconv_ag_add_norm(
584 + self,
585 + input_tensor: torch.Tensor,
586 + residual: torch.Tensor,
587 + conv_weight: torch.Tensor,
588 + norm_weight: torch.Tensor | None,
589 + eps: float,
590 + cache: torch.Tensor,
591 + positions: torch.Tensor,
592 + block_table: torch.Tensor,
593 + seq_idx: torch.Tensor,
594 + slot_mapping: torch.Tensor,
595 + off_s: int,
596 + ws: int,
597 + block_size: int,
598 + ) -> tuple[torch.Tensor | None, torch.Tensor]:
599 + """Return ``(normed | None, new_residual)``, both shaped ``[T, 6144]``."""
600 + tokens, hidden_size = residual.shape
601 + if not self.usable(tokens):
602 + raise ValueError(f"num_tokens must be in [1, {self.max_tokens}]")
603 + if hidden_size != self.hidden_size or residual.dtype != torch.bfloat16:
604 + raise ValueError("residual must be bf16 [T, 6144]")
605 + if (
606 + input_tensor.shape != residual.shape
607 + or input_tensor.dtype != torch.bfloat16
608 + or input_tensor.stride(1) != 1
609 + ):
610 + raise ValueError("input_tensor must be channel-contiguous bf16 [T, 6144]")
611 + shard_size = hidden_size // self.world_size
612 + if conv_weight.shape != (shard_size, self.window_size):
613 + raise ValueError(
614 + f"conv_weight must have shape [{shard_size}, {self.window_size}]"
615 + )
616 + if (
617 + conv_weight.dtype != torch.bfloat16
618 + or conv_weight.stride(0) != self.window_size
619 + ):
620 + raise ValueError("conv_weight must be contiguous bf16")
621 + if norm_weight is not None and (
622 + norm_weight.shape != (hidden_size,) or norm_weight.dtype != torch.bfloat16
623 + ):
624 + raise ValueError("norm_weight must be bf16 [6144] or None")
625 + if cache.dtype != torch.bfloat16 or cache.ndim != 4:
626 + raise ValueError("cache must be a 4-D bf16 tensor")
627 + if shard_size % ws != 0 or cache.shape[1] != shard_size // ws:
628 + raise ValueError("cache head layout is inconsistent with ws")
629 + if off_s < 0 or off_s + ws > cache.shape[3]:
630 + raise ValueError("cache channel offset is out of bounds")
631 +
632 + index = self.generation
633 + input_offset = index * self.input_generation_bytes // 4
634 + output_offset = index * self.output_generation_bytes // 4
635 + normed = torch.empty_like(residual) if norm_weight is not None else None
636 + residual_out = torch.empty_like(residual)
637 + phase_splits = 8 if tokens == 1 and shard_size % 8 == 0 else 1
638 + phase_tile_p2 = triton.next_power_of_2(shard_size // phase_splits)
639 + phase_grid = (tokens, phase_splits)
640 + # Wide CTAs win before the grid saturates; smaller CTAs reduce register
641 + # pressure once high-throughput batches provide enough parallelism.
642 + if 128 <= tokens <= 2048:
643 + phase_warps = 16
644 + elif tokens > 2048:
645 + phase_warps = 8
646 + else:
647 + phase_warps = 4
648 + gather_warps = 4 if tokens >= 256 else 8
649 + _publish_input_kernel[phase_grid](
650 + input_tensor,
651 + self.input_peer_ptrs,
652 + input_offset,
653 + input_tensor.stride(0),
654 + C=hidden_size,
655 + CS=shard_size,
656 + CS_P2=phase_tile_p2,
657 + SPLITS=phase_splits,
658 + RANK=self.rank,
659 + WORLD=self.world_size,
660 + USE_PDL=self.use_pdl,
661 + launch_pdl=self.use_pdl,
662 + num_warps=phase_warps,
663 + )
664 + _reduce_insert_kernel[phase_grid](
665 + self.input_peer_ptrs,
666 + input_offset,
667 + cache,
668 + slot_mapping,
669 + cache.stride(0),
670 + cache.stride(1),
671 + cache.stride(2),
672 + cache.stride(3),
673 + block_size,
674 + C=hidden_size,
675 + CS=shard_size,
676 + CS_P2=phase_tile_p2,
677 + SPLITS=phase_splits,
678 + HEAD_SIZE=ws,
679 + CACHE_OFFSET=off_s,
680 + RANK=self.rank,
681 + WORLD=self.world_size,
682 + USE_PDL=self.use_pdl,
683 + launch_pdl=self.use_pdl,
684 + num_warps=phase_warps,
685 + )
686 + _sconv_publish_kernel[phase_grid](
687 + self.output_peer_ptrs,
688 + output_offset,
689 + residual,
690 + conv_weight,
691 + cache,
692 + positions,
693 + seq_idx,
694 + slot_mapping,
695 + block_table,
696 + residual.stride(0),
697 + cache.stride(0),
698 + cache.stride(1),
699 + cache.stride(2),
700 + cache.stride(3),
701 + block_table.stride(0),
702 + block_table.shape[1],
703 + block_size,
704 + C=hidden_size,
705 + CS=shard_size,
706 + CS_P2=phase_tile_p2,
707 + SPLITS=phase_splits,
708 + HEAD_SIZE=ws,
709 + CACHE_OFFSET=off_s,
710 + RANK=self.rank,
711 + WORLD=self.world_size,
712 + WINDOW=self.window_size,
713 + USE_PDL=self.use_pdl,
714 + launch_pdl=self.use_pdl,
715 + num_warps=phase_warps,
716 + )
717 + _gather_norm_kernel[(tokens,)](
718 + self.output_peer_ptrs,
719 + output_offset,
720 + norm_weight if norm_weight is not None else residual,
721 + normed if normed is not None else residual_out,
722 + residual_out,
723 + eps,
724 + residual.stride(0),
725 + C=hidden_size,
726 + C_P2=triton.next_power_of_2(hidden_size),
727 + RANK=self.rank,
728 + HAS_NORM=norm_weight is not None,
729 + USE_PDL=self.use_pdl,
730 + launch_pdl=self.use_pdl,
731 + num_warps=gather_warps,
732 + )
733 + self.generation = (index + 1) % self.num_buffers
734 + return normed, residual_out
735 +
736 +
737 +_STATE: LamportRSConv | None = None
738 +_STATE_FAILED = False
739 +
740 +
741 +def initialize_lamport_rs_conv(
742 + hidden_size: int, window_size: int, max_num_batched_tokens: int
743 +) -> None:
744 + """Collectively initialize the TP-group state during model construction."""
745 + global _STATE, _STATE_FAILED
746 + if _STATE is not None:
747 + if _STATE.hidden_size != hidden_size or _STATE.window_size != window_size:
748 + raise RuntimeError("all Lamport users must share hidden and window sizes")
749 + return
750 + if _STATE_FAILED or os.environ.get("LAMPORT_RS_SCONV", "1") == "0":
751 + return
752 + try:
753 + max_tokens = min(_MAX_TOKENS, max_num_batched_tokens)
754 + _STATE = LamportRSConv(hidden_size, window_size, max_tokens=max_tokens)
755 + except Exception:
756 + _STATE_FAILED = True
757 + logger.exception("fused collective unavailable; use the NCCL fallback")
758 +
759 +
760 +def get_lamport_rs_conv(hidden_size: int, window_size: int) -> LamportRSConv | None:
761 + """Return the state initialized with the model, or ``None`` for fallback."""
762 + if _STATE is not None and (
763 + _STATE.hidden_size != hidden_size or _STATE.window_size != window_size
764 + ):
765 + raise RuntimeError("all Lamport users must share hidden and window sizes")
766 + return _STATE
vllm/models/inkling/nvidia/ops/mm_towers.py new
+190
@@ -0,0 +1,190 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Fused CUDA kernels for the Inkling vision/audio towers.
4 +
5 +Both kernels keep the reference paths' fp32 accumulation and per-op bf16
6 +rounding points (native ``rms_norm`` / ``F.gelu``); outputs are frequently
7 +bit-identical and otherwise differ by 1-2 bf16 ulps from reduction-order
8 +(real-checkpoint-weight cosine vs reference > 0.9999998).
9 +"""
10 +
11 +from __future__ import annotations
12 +
13 +import torch
14 +
15 +from vllm.triton_utils import tl, tldevice, triton
16 +
17 +from .norm import _get_num_warps_from_block_size
18 +
19 +
20 +@triton.jit
21 +def _dmel_embed_sum_norm_kernel(
22 + idx_ptr, # [T, NB] int32 dMel bin indices (values in [0, VOCAB))
23 + w_ptr, # [NB * VOCAB, D] bf16 embedding table
24 + norm_w_ptr, # [D] (unused if HAS_NORM=False)
25 + out_ptr, # [T, D] bf16
26 + eps,
27 + D,
28 + stride_idx_t,
29 + NB: tl.constexpr,
30 + VOCAB: tl.constexpr,
31 + D_P2: tl.constexpr,
32 + HAS_NORM: tl.constexpr,
33 +):
34 + t = tl.program_id(0).to(tl.int64)
35 + offs = tl.arange(0, D_P2)
36 + mask = offs < D
37 + # One embedding row per mel bin (bin b uses table rows [b*VOCAB, (b+1)*VOCAB)),
38 + # summed in fp32 (matches torch's fp32-accumulated bf16 .sum()).
39 + acc = tl.zeros([D_P2], dtype=tl.float32)
40 + for b in tl.static_range(NB):
41 + v = tl.load(idx_ptr + t * stride_idx_t + b)
42 + row = (b * VOCAB + v).to(tl.int64)
43 + acc += tl.load(w_ptr + row * D + offs, mask=mask, other=0.0).to(tl.float32)
44 + h = acc.to(tl.bfloat16)
45 + if HAS_NORM:
46 + # Match ir.ops.rms_norm: fp32 variance/normalize, then a single-rounded
47 + # bf16 multiply with the bf16 weight.
48 + x32 = h.to(tl.float32)
49 + var = tl.sum(tl.where(mask, x32 * x32, 0.0), axis=0) / D
50 + xn = (x32 * tl.math.rsqrt(var + eps)).to(tl.bfloat16)
51 + w = tl.load(norm_w_ptr + offs, mask=mask, other=0.0)
52 + h = xn * w
53 + tl.store(out_ptr + t * D + offs, h, mask=mask)
54 +
55 +
56 +def dmel_embed_sum_norm(
57 + dmel_idx: torch.Tensor, # [T, NB] int32
58 + weight: torch.Tensor, # [NB * VOCAB, D] bf16
59 + norm_weight: torch.Tensor | None,
60 + eps: float,
61 +) -> torch.Tensor:
62 + """``rmsnorm(sum_b weight[b * VOCAB + idx[:, b]])`` in one launch (no
63 + [T, NB, D] intermediate)."""
64 + T, nb = dmel_idx.shape
65 + D = weight.shape[1]
66 + assert weight.shape[0] % nb == 0
67 + vocab = weight.shape[0] // nb
68 + out = torch.empty((T, D), dtype=weight.dtype, device=weight.device)
69 + if T == 0:
70 + return out
71 + d_p2 = triton.next_power_of_2(D)
72 + _dmel_embed_sum_norm_kernel[(T,)](
73 + dmel_idx,
74 + weight,
75 + norm_weight if norm_weight is not None else weight,
76 + out,
77 + eps,
78 + D,
79 + dmel_idx.stride(0),
80 + NB=nb,
81 + VOCAB=vocab,
82 + D_P2=d_p2,
83 + HAS_NORM=norm_weight is not None,
84 + # Swept on GB200: 8 warps beats the block-size heuristic's 16 by ~4%
85 + # at large T (the 80-row gather chain is latency- not lane-bound).
86 + num_warps=8,
87 + )
88 + return out
89 +
90 +
91 +@triton.jit
92 +def _rmsnorm_gelu_kernel(
93 + x_ptr, # [R, D] bf16
94 + w_ptr, # [D]
95 + out_ptr, # [R, D] bf16 (or the folded layout when FOLD)
96 + eps,
97 + R,
98 + D,
99 + D_P2: tl.constexpr,
100 + BLOCK_M: tl.constexpr, # rows per block (>1 for small D)
101 + HAS_GELU: tl.constexpr,
102 + FOLD: tl.constexpr,
103 + # fold geometry: input rows index [N, T, H, W]; the store scatters each
104 + # row to (out_row, slot) of fold_timespace_to_depth's output layout.
105 + FT: tl.constexpr,
106 + FH: tl.constexpr,
107 + FW: tl.constexpr,
108 + TF: tl.constexpr,
109 + HF: tl.constexpr,
110 +):
111 + pid = tl.program_id(0).to(tl.int64)
112 + rows = pid * BLOCK_M + tl.arange(0, BLOCK_M)
113 + rmask = rows < R
114 + offs = tl.arange(0, D_P2)
115 + mask = rmask[:, None] & (offs < D)[None, :]
116 + x32 = tl.load(x_ptr + rows[:, None] * D + offs[None, :], mask=mask, other=0.0).to(
117 + tl.float32
118 + )
119 + var = tl.sum(x32 * x32, axis=1) / D
120 + xn = (x32 * tl.math.rsqrt(var + eps)[:, None]).to(tl.bfloat16)
121 + w = tl.load(w_ptr + offs, mask=offs < D, other=0.0)
122 + h = xn * w[None, :] # bf16 multiply, matching ir.ops.rms_norm
123 + if HAS_GELU:
124 + # Exact (erf) GELU on the bf16-rounded norm output, fp32 math,
125 + # matching F.gelu's opmath on a bf16 tensor.
126 + g32 = h.to(tl.float32)
127 + h = (0.5 * g32 * (1.0 + tldevice.erf(g32 * 0.7071067811865476))).to(tl.bfloat16)
128 + if FOLD:
129 + # Store directly into the next layer's folded layout (a pure
130 + # permutation — replaces the separate fold copy pass).
131 + t = (rows // (FH * FW)) % FT
132 + hh = (rows // FW) % FH
133 + ww = rows % FW
134 + n = rows // (FT * FH * FW)
135 + slot = ((t % TF) * HF + hh % HF) * HF + ww % HF
136 + out_row = ((n * (FT // TF) + t // TF) * (FH // HF) + hh // HF) * (
137 + FW // HF
138 + ) + ww // HF
139 + base = (out_row * (TF * HF * HF) + slot) * D
140 + tl.store(out_ptr + base[:, None] + offs[None, :], h, mask=mask)
141 + else:
142 + tl.store(out_ptr + rows[:, None] * D + offs[None, :], h, mask=mask)
143 +
144 +
145 +def rmsnorm_gelu(
146 + x: torch.Tensor, # [..., D] bf16 contiguous
147 + weight: torch.Tensor,
148 + eps: float,
149 + gelu: bool = True,
150 + fold: tuple[int, int] | None = None, # (t_fold, hw_fold) of the NEXT fold
151 +) -> torch.Tensor:
152 + """Fused ``gelu(rmsnorm(x))`` (or plain rmsnorm); multiple rows per block
153 + when D is small. With ``fold``, x must be [N, T, H, W, D] and the output
154 + comes back as ``fold_timespace_to_depth(result, *fold)``."""
155 + D = x.shape[-1]
156 + flat = x.reshape(-1, D)
157 + assert flat.stride(1) == 1 and flat.stride(0) == D
158 + R = flat.shape[0]
159 + if fold is None:
160 + out = torch.empty_like(flat)
161 + ft = fh = fw = tf = hf = 1
162 + out_shape = x.shape
163 + else:
164 + tf, hf = fold
165 + N, ft, fh, fw, _ = x.shape
166 + out_shape = (N, ft // tf, fh // hf, fw // hf, tf * hf * hf * D)
167 + out = torch.empty(out_shape, dtype=x.dtype, device=x.device)
168 + if R == 0:
169 + return out.reshape(out_shape)
170 + d_p2 = triton.next_power_of_2(D)
171 + block_m = max(1, 4096 // d_p2)
172 + _rmsnorm_gelu_kernel[(triton.cdiv(R, block_m),)](
173 + flat,
174 + weight,
175 + out,
176 + eps,
177 + R,
178 + D,
179 + D_P2=d_p2,
180 + BLOCK_M=block_m,
181 + HAS_GELU=gelu,
182 + FOLD=fold is not None,
183 + FT=ft,
184 + FH=fh,
185 + FW=fw,
186 + TF=tf,
187 + HF=hf,
188 + num_warps=_get_num_warps_from_block_size(d_p2 * block_m),
189 + )
190 + return out.reshape(out_shape)
vllm/models/inkling/nvidia/ops/norm.py new
+315
@@ -0,0 +1,315 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from __future__ import annotations
4 +
5 +from functools import lru_cache
6 +
7 +import torch
8 +
9 +from vllm.triton_utils import tl, triton
10 +
11 +_MAX_FUSED_SIZE = 65536
12 +
13 +
14 +def _get_num_warps_from_block_size(block_size: int) -> int:
15 + if block_size >= 32768:
16 + return 32
17 + if block_size >= 8192:
18 + return 16
19 + if block_size >= 2048:
20 + return 8
21 + return 4
22 +
23 +
24 +def _largest_power_of_2(n: int) -> int:
25 + assert n > 0, f"{n=}"
26 + return 1 << (n.bit_length() - 1)
27 +
28 +
29 +@lru_cache(maxsize=128)
30 +def _get_grid_size_for_mem_bw_kernel(device: torch.device, factor: int = 8) -> int:
31 + num_sms = torch.cuda.get_device_properties(device).multi_processor_count
32 + return _largest_power_of_2(num_sms) * factor
33 +
34 +
35 +@triton.jit
36 +def _rmsnorm_fwd_kernel(
37 + x_ptr,
38 + weight_ptr,
39 + y_ptr,
40 + rstd_ptr,
41 + eps,
42 + x_stride_0,
43 + y_stride_0,
44 + n_cols,
45 + block_size_n: tl.constexpr,
46 +):
47 + pid_m = tl.program_id(0).to(tl.int64)
48 + offs_n = tl.arange(0, block_size_n)
49 + mask_n = offs_n < n_cols
50 +
51 + weight = tl.load(weight_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32)
52 + x = tl.load(x_ptr + pid_m * x_stride_0 + offs_n, mask=mask_n, other=0.0).to(
53 + tl.float32
54 + )
55 + row_var = tl.sum(x * x, axis=0) / n_cols
56 + rstd = tl.math.rsqrt(row_var + eps)
57 + tl.store(rstd_ptr + pid_m, rstd)
58 +
59 + y = x * rstd * weight
60 + tl.store(y_ptr + pid_m * y_stride_0 + offs_n, y, mask=mask_n)
61 +
62 +
63 +@triton.jit(do_not_specialize=["n_rows"])
64 +def _rmsnorm_fwd_kernel_block_m(
65 + x_ptr,
66 + weight_ptr,
67 + y_ptr,
68 + rstd_ptr,
69 + eps,
70 + x_stride_0,
71 + y_stride_0,
72 + n_rows,
73 + n_cols,
74 + block_size_m: tl.constexpr,
75 + block_size_n: tl.constexpr,
76 +):
77 + pid_m = tl.program_id(0).to(tl.int64)
78 + offs_n = tl.arange(0, block_size_n)
79 + mask_n = offs_n < n_cols
80 +
81 + num_blocks_m = tl.cdiv(n_rows, block_size_m)
82 + blocks_per_pid = tl.cdiv(num_blocks_m, tl.num_programs(0))
83 + block_id_start = pid_m * blocks_per_pid
84 + block_id_end = min(block_id_start + blocks_per_pid, num_blocks_m)
85 +
86 + weight = tl.load(weight_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32)
87 +
88 + for block_id in range(block_id_start, block_id_end):
89 + offs_m = block_id * block_size_m + tl.arange(0, block_size_m)
90 + mask_m = offs_m < n_rows
91 + mask_mn = mask_m[:, None] & mask_n[None, :]
92 + x = tl.load(
93 + x_ptr + offs_m[:, None] * x_stride_0 + offs_n[None, :],
94 + mask=mask_mn,
95 + other=0.0,
96 + ).to(tl.float32)
97 + row_var = tl.sum(x * x, axis=1) / n_cols
98 + rstd = tl.math.rsqrt(row_var + eps)
99 + tl.store(rstd_ptr + offs_m, rstd, mask=mask_m)
100 +
101 + y = x * rstd[:, None] * weight
102 + tl.store(
103 + y_ptr + offs_m[:, None] * y_stride_0 + offs_n[None, :],
104 + y,
105 + mask=mask_mn,
106 + )
107 +
108 +
109 +@triton.jit
110 +def _add_rmsnorm_fwd_kernel(
111 + res_ptr, # [T, N] residual (read)
112 + delta_ptr, # [T, N] delta to add (read)
113 + weight_ptr,
114 + y_ptr, # [T, N] normed output
115 + res_out_ptr, # [T, N] updated residual output
116 + eps,
117 + res_stride_0,
118 + delta_stride_0,
119 + y_stride_0,
120 + res_out_stride_0,
121 + n_cols,
122 + block_size_n: tl.constexpr,
123 +):
124 + pid_m = tl.program_id(0).to(tl.int64)
125 + offs_n = tl.arange(0, block_size_n)
126 + mask_n = offs_n < n_cols
127 +
128 + weight = tl.load(weight_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32)
129 + r = tl.load(res_ptr + pid_m * res_stride_0 + offs_n, mask=mask_n, other=0.0).to(
130 + tl.float32
131 + )
132 + d = tl.load(delta_ptr + pid_m * delta_stride_0 + offs_n, mask=mask_n, other=0.0).to(
133 + tl.float32
134 + )
135 + # Round the sum to the residual dtype first (matches the eager
136 + # `residual + delta` then rmsnorm-on-bf16 sequence bit-for-bit).
137 + s = (r + d).to(res_out_ptr.dtype.element_ty)
138 + tl.store(res_out_ptr + pid_m * res_out_stride_0 + offs_n, s, mask=mask_n)
139 + x = s.to(tl.float32)
140 + row_var = tl.sum(x * x, axis=0) / n_cols
141 + rstd = tl.math.rsqrt(row_var + eps)
142 + y = x * rstd * weight
143 + tl.store(y_ptr + pid_m * y_stride_0 + offs_n, y, mask=mask_n)
144 +
145 +
146 +def add_rmsnorm(
147 + residual: torch.Tensor,
148 + delta: torch.Tensor,
149 + weight: torch.Tensor,
150 + eps: float,
151 +) -> tuple[torch.Tensor, torch.Tensor]:
152 + """Fused ``res = residual + delta; y = rmsnorm(res)``.
153 +
154 + Returns ``(y, res)``; both are fresh tensors (cudagraph-friendly, no
155 + in-place update of the inputs).
156 + """
157 + assert residual.ndim == 2 and delta.ndim == 2, (residual.shape, delta.shape)
158 + n_rows, n_cols = residual.shape
159 + assert weight.shape[0] == n_cols
160 + y = torch.empty_like(residual)
161 + res_out = torch.empty_like(residual)
162 + if n_rows == 0:
163 + return y, res_out
164 +
165 + block_size_n = triton.next_power_of_2(n_cols)
166 + max_block_size_n = _MAX_FUSED_SIZE // residual.element_size()
167 + if max_block_size_n < block_size_n:
168 + raise RuntimeError(f"Large {n_cols=} is not supported")
169 + num_warps = _get_num_warps_from_block_size(block_size_n)
170 + _add_rmsnorm_fwd_kernel[(n_rows,)](
171 + residual,
172 + delta,
173 + weight,
174 + y,
175 + res_out,
176 + eps,
177 + residual.stride(0),
178 + delta.stride(0),
179 + y.stride(0),
180 + res_out.stride(0),
181 + n_cols,
182 + block_size_n,
183 + num_warps=num_warps,
184 + )
185 + return y, res_out
186 +
187 +
188 +@triton.jit
189 +def _embed_rmsnorm_kernel(
190 + ids_ptr, # [T] token ids
191 + table_ptr, # [V, N] embedding table
192 + weight_ptr, # [N] (HAS_NORM only)
193 + chain_weight_ptr, # [N] (HAS_CHAIN only)
194 + out_ptr, # [T, N] rmsnorm(table[ids], weight)
195 + chain_out_ptr, # [T, N] rmsnorm(out, chain_weight) (HAS_CHAIN only)
196 + eps,
197 + table_stride_0,
198 + n_cols,
199 + block_size_n: tl.constexpr,
200 + HAS_NORM: tl.constexpr,
201 + HAS_CHAIN: tl.constexpr,
202 +):
203 + pid_m = tl.program_id(0).to(tl.int64)
204 + offs_n = tl.arange(0, block_size_n)
205 + mask_n = offs_n < n_cols
206 + row = tl.load(ids_ptr + pid_m).to(tl.int64)
207 + x = tl.load(table_ptr + row * table_stride_0 + offs_n, mask=mask_n, other=0.0)
208 + if HAS_NORM:
209 + xf = x.to(tl.float32)
210 + rstd = tl.math.rsqrt(tl.sum(xf * xf, axis=0) / n_cols + eps)
211 + w = tl.load(weight_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32)
212 + # Round to the output dtype so the chained norm is bit-exact vs the
213 + # unfused pair (which stores bf16 in between).
214 + x = (xf * rstd * w).to(out_ptr.dtype.element_ty)
215 + tl.store(out_ptr + pid_m * n_cols + offs_n, x, mask=mask_n)
216 + if HAS_CHAIN:
217 + xf = x.to(tl.float32)
218 + rstd = tl.math.rsqrt(tl.sum(xf * xf, axis=0) / n_cols + eps)
219 + w = tl.load(chain_weight_ptr + offs_n, mask=mask_n, other=0.0).to(tl.float32)
220 + tl.store(
221 + chain_out_ptr + pid_m * n_cols + offs_n,
222 + (xf * rstd * w).to(chain_out_ptr.dtype.element_ty),
223 + mask=mask_n,
224 + )
225 +
226 +
227 +def embed_rmsnorm(
228 + input_ids: torch.Tensor,
229 + embed_table: torch.Tensor,
230 + weight: torch.Tensor | None,
231 + eps: float,
232 + chain_weight: torch.Tensor | None = None,
233 +) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
234 + """Fused ``rmsnorm(embed_table[input_ids], weight)`` row gather + norm.
235 +
236 + Requires the full vocab on-rank (replicated or tp_size == 1).
237 + ``weight=None`` skips the norm (``use_embed_norm=False``), leaving a pure
238 + embedding-table gather.
239 + ``chain_weight`` additionally emits ``rmsnorm(out, chain_weight)`` (the
240 + first decoder layer's pre-attention norm) as a second output, still one
241 + launch. Bit-exact vs the unfused module sequence."""
242 + ids = input_ids.view(-1)
243 + (T,) = ids.shape
244 + n = embed_table.shape[1]
245 + out = torch.empty(
246 + (*input_ids.shape, n), dtype=embed_table.dtype, device=embed_table.device
247 + )
248 + chain_out = torch.empty_like(out) if chain_weight is not None else None
249 + if T > 0:
250 + block_size_n = triton.next_power_of_2(n)
251 + _embed_rmsnorm_kernel[(T,)](
252 + ids,
253 + embed_table,
254 + weight if weight is not None else embed_table,
255 + chain_weight if chain_weight is not None else embed_table,
256 + out,
257 + chain_out if chain_out is not None else out,
258 + eps,
259 + embed_table.stride(0),
260 + n,
261 + block_size_n,
262 + HAS_NORM=weight is not None,
263 + HAS_CHAIN=chain_weight is not None,
264 + num_warps=_get_num_warps_from_block_size(block_size_n),
265 + )
266 + if chain_out is not None:
267 + return out, chain_out
268 + return out
269 +
270 +
271 +def rmsnorm(x: torch.Tensor, weight: torch.Tensor, eps: float) -> torch.Tensor:
272 + assert x.ndim == 2, f"{x.shape=}"
273 + assert weight.ndim == 1, f"{weight.shape=}"
274 + n_rows, n_cols = x.shape
275 + assert weight.shape[0] == n_cols, f"{weight.shape=} {x.shape=}"
276 + y = torch.empty_like(x)
277 + rstd = torch.empty((n_rows,), dtype=torch.float32, device=x.device)
278 +
279 + block_size_n = triton.next_power_of_2(n_cols)
280 + max_block_size_n = _MAX_FUSED_SIZE // x.element_size()
281 + if max_block_size_n < block_size_n:
282 + raise RuntimeError(f"Large {n_cols=} is not supported")
283 + block_size_m = max(1, 4096 // block_size_n)
284 + num_warps = _get_num_warps_from_block_size(block_size_n)
285 +
286 + if block_size_m == 1:
287 + _rmsnorm_fwd_kernel[(n_rows,)](
288 + x,
289 + weight,
290 + y,
291 + rstd,
292 + eps,
293 + x.stride(0),
294 + y.stride(0),
295 + n_cols,
296 + block_size_n,
297 + num_warps=num_warps,
298 + )
299 + else:
300 + grid_size = _get_grid_size_for_mem_bw_kernel(x.device)
301 + _rmsnorm_fwd_kernel_block_m[(grid_size,)](
302 + x,
303 + weight,
304 + y,
305 + rstd,
306 + eps,
307 + x.stride(0),
308 + y.stride(0),
309 + n_rows,
310 + n_cols,
311 + block_size_m,
312 + block_size_n,
313 + num_warps=num_warps,
314 + )
315 + return y
vllm/models/inkling/nvidia/ops/qkvr_prep.py new
+918
@@ -0,0 +1,918 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import torch
5 +
6 +from vllm.triton_utils import tl, triton
7 +from vllm.utils.torch_utils import aux_stream
8 +
9 +LOW_BLOCK_M = 32
10 +LOW_BLOCK_N = 64
11 +LOW_NUM_WARPS = 4
12 +THROUGHPUT_BLOCK_M = 32
13 +THROUGHPUT_BLOCK_N = 128
14 +THROUGHPUT_GROUP_M = 2
15 +THROUGHPUT_NUM_WARPS = 4
16 +SMALL_TOKEN_THRESHOLD = 128
17 +SMALL_NUM_WARPS = 2
18 +Q_BLOCK_ROWS = 8
19 +Q_NUM_WARPS = 2
20 +KV_BLOCK_ROWS = 4
21 +KV_NUM_WARPS = 2
22 +
23 +
24 +@triton.jit(do_not_specialize=["rows"])
25 +def _rel_proj_low_latency_kernel(
26 + qkvr_ptr,
27 + rel_proj_ptr,
28 + rel_out_ptr,
29 + log_scaling_ptr,
30 + rows,
31 + stride_x_t,
32 + R_OFFSET: tl.constexpr,
33 + NUM_Q_HEADS: tl.constexpr,
34 + REL_EXTENT: tl.constexpr,
35 + BLOCK_M: tl.constexpr,
36 + BLOCK_N: tl.constexpr,
37 + APPLY_LOG_SCALING: tl.constexpr,
38 +):
39 + row = tl.program_id(0) * BLOCK_M + tl.arange(0, BLOCK_M)
40 + col = tl.program_id(1) * BLOCK_N + tl.arange(0, BLOCK_N)
41 + inner = tl.arange(0, 16)
42 + token = row // NUM_Q_HEADS
43 + head = row % NUM_Q_HEADS
44 + relative = tl.load(
45 + qkvr_ptr
46 + + token[:, None] * stride_x_t
47 + + R_OFFSET
48 + + head[:, None] * 16
49 + + inner[None, :],
50 + mask=row[:, None] < rows,
51 + other=0.0,
52 + )
53 + projection = tl.load(
54 + rel_proj_ptr + inner[:, None] * REL_EXTENT + col[None, :],
55 + mask=col[None, :] < REL_EXTENT,
56 + other=0.0,
57 + )
58 + values = tl.dot(relative, projection, out_dtype=tl.float32).to(
59 + rel_out_ptr.dtype.element_ty
60 + )
61 + values = values.to(tl.float32)
62 + if APPLY_LOG_SCALING:
63 + values *= tl.load(log_scaling_ptr + token, mask=row < rows, other=1.0)[:, None]
64 + tl.store(
65 + rel_out_ptr + row[:, None] * REL_EXTENT + col[None, :],
66 + values.to(rel_out_ptr.dtype.element_ty),
67 + mask=(row[:, None] < rows) & (col[None, :] < REL_EXTENT),
68 + )
69 +
70 +
71 +@triton.jit(do_not_specialize=["rows"])
72 +def _rel_proj_throughput_kernel(
73 + qkvr_ptr,
74 + rel_proj_ptr,
75 + rel_out_ptr,
76 + log_scaling_ptr,
77 + rows,
78 + stride_x_t,
79 + R_OFFSET: tl.constexpr,
80 + NUM_Q_HEADS: tl.constexpr,
81 + REL_EXTENT: tl.constexpr,
82 + BLOCK_M: tl.constexpr,
83 + BLOCK_N: tl.constexpr,
84 + GROUP_M: tl.constexpr,
85 + APPLY_LOG_SCALING: tl.constexpr,
86 +):
87 + row_group = tl.program_id(0)
88 + col = tl.program_id(1) * BLOCK_N + tl.arange(0, BLOCK_N)
89 + inner = tl.arange(0, 16)
90 + projection = tl.load(
91 + rel_proj_ptr + inner[:, None] * REL_EXTENT + col[None, :],
92 + mask=col[None, :] < REL_EXTENT,
93 + other=0.0,
94 + )
95 + row_offsets = tl.arange(0, BLOCK_M)
96 + for group_offset in tl.static_range(GROUP_M):
97 + row = (row_group * GROUP_M + group_offset) * BLOCK_M + row_offsets
98 + token = row // NUM_Q_HEADS
99 + head = row % NUM_Q_HEADS
100 + relative = tl.load(
101 + qkvr_ptr
102 + + token[:, None] * stride_x_t
103 + + R_OFFSET
104 + + head[:, None] * 16
105 + + inner[None, :],
106 + mask=row[:, None] < rows,
107 + other=0.0,
108 + )
109 + values = tl.dot(relative, projection, out_dtype=tl.float32).to(
110 + rel_out_ptr.dtype.element_ty
111 + )
112 + values = values.to(tl.float32)
113 + if APPLY_LOG_SCALING:
114 + values *= tl.load(log_scaling_ptr + token, mask=row < rows, other=1.0)[
115 + :, None
116 + ]
117 + tl.store(
118 + rel_out_ptr + row[:, None] * REL_EXTENT + col[None, :],
119 + values.to(rel_out_ptr.dtype.element_ty),
120 + mask=(row[:, None] < rows) & (col[None, :] < REL_EXTENT),
121 + )
122 +
123 +
124 +def use_rel_proj_throughput(rows: int, rel_extent: int) -> bool:
125 + min_rows = 8192 if rel_extent == 512 else 2048
126 + return rows >= min_rows
127 +
128 +
129 +def qkvr_rel_proj(
130 + qkvr: torch.Tensor,
131 + rel_proj: torch.Tensor,
132 + rel_out: torch.Tensor,
133 + log_scaling: torch.Tensor | None,
134 + *,
135 + num_q_heads: int,
136 + num_kv_heads: int,
137 + head_dim: int,
138 + d_rel: int,
139 +) -> None:
140 + rows = qkvr.shape[0] * num_q_heads
141 + rel_extent = rel_proj.shape[1]
142 + assert d_rel == 16 and rel_proj.shape[0] == 16
143 + r_offset = num_q_heads * head_dim + 2 * num_kv_heads * head_dim
144 + log_scaling_ptr = log_scaling if log_scaling is not None else qkvr
145 + common = dict(
146 + R_OFFSET=r_offset,
147 + NUM_Q_HEADS=num_q_heads,
148 + REL_EXTENT=rel_extent,
149 + APPLY_LOG_SCALING=log_scaling is not None,
150 + )
151 +
152 + if use_rel_proj_throughput(rows, rel_extent):
153 + grid = (
154 + triton.cdiv(rows, THROUGHPUT_BLOCK_M * THROUGHPUT_GROUP_M),
155 + triton.cdiv(rel_extent, THROUGHPUT_BLOCK_N),
156 + )
157 + _rel_proj_throughput_kernel[grid](
158 + qkvr,
159 + rel_proj,
160 + rel_out,
161 + log_scaling_ptr,
162 + rows,
163 + qkvr.stride(0),
164 + BLOCK_M=THROUGHPUT_BLOCK_M,
165 + BLOCK_N=THROUGHPUT_BLOCK_N,
166 + GROUP_M=THROUGHPUT_GROUP_M,
167 + num_warps=THROUGHPUT_NUM_WARPS,
168 + **common,
169 + )
170 + return
171 +
172 + grid = (
173 + triton.cdiv(rows, LOW_BLOCK_M),
174 + triton.cdiv(rel_extent, LOW_BLOCK_N),
175 + )
176 + _rel_proj_low_latency_kernel[grid](
177 + qkvr,
178 + rel_proj,
179 + rel_out,
180 + log_scaling_ptr,
181 + rows,
182 + qkvr.stride(0),
183 + BLOCK_M=LOW_BLOCK_M,
184 + BLOCK_N=LOW_BLOCK_N,
185 + num_warps=LOW_NUM_WARPS,
186 + **common,
187 + )
188 +
189 +
190 +@triton.jit(do_not_specialize=["tokens", "stride_block_table_req", "max_blocks"])
191 +def _qkvr_qkv_kernel(
192 + qkvr_ptr,
193 + q_norm_weight_ptr,
194 + q_out_ptr,
195 + rel_proj_ptr,
196 + rel_out_ptr,
197 + k_weight_ptr,
198 + v_weight_ptr,
199 + k_norm_weight_ptr,
200 + conv_cache_ptr,
201 + key_cache_ptr,
202 + value_cache_ptr,
203 + positions_ptr,
204 + seq_idx_ptr,
205 + conv_slot_mapping_ptr,
206 + conv_block_table_ptr,
207 + query_start_ptr,
208 + attention_slot_mapping_ptr,
209 + log_scaling_ptr,
210 + tokens,
211 + eps,
212 + stride_x_t,
213 + stride_cc_block,
214 + stride_cc_head,
215 + stride_cc_token,
216 + stride_cc_dim,
217 + stride_kc_block,
218 + stride_kc_token,
219 + stride_kc_head,
220 + stride_vc_block,
221 + stride_vc_token,
222 + stride_vc_head,
223 + stride_block_table_req,
224 + max_blocks,
225 + conv_block_size,
226 + attention_page_size,
227 + Q_WIDTH: tl.constexpr,
228 + KV_WIDTH: tl.constexpr,
229 + NUM_Q_HEADS: tl.constexpr,
230 + NUM_KV_HEADS: tl.constexpr,
231 + HEAD_DIM: tl.constexpr,
232 + WINDOW_SIZE: tl.constexpr,
233 + OFF_K: tl.constexpr,
234 + OFF_V: tl.constexpr,
235 + APPLY_LOG_SCALING: tl.constexpr,
236 + D_REL: tl.constexpr,
237 + REL_EXTENT: tl.constexpr,
238 + REL_EXTENT_PADDED: tl.constexpr,
239 +):
240 + block = tl.program_id(0)
241 + num_q_rows = tokens * NUM_Q_HEADS
242 + dims = tl.arange(0, HEAD_DIM)
243 +
244 + if block < num_q_rows:
245 + row = block
246 + token = row // NUM_Q_HEADS
247 + head = row % NUM_Q_HEADS
248 + values = tl.load(
249 + qkvr_ptr + token * stride_x_t + head * HEAD_DIM + dims,
250 + ).to(tl.float32)
251 + weight = tl.load(q_norm_weight_ptr + dims).to(tl.float32)
252 + rstd = tl.rsqrt(tl.sum(values * values, axis=0) / HEAD_DIM + eps)
253 + normalized = values * rstd * weight
254 + if APPLY_LOG_SCALING:
255 + normalized = normalized.to(q_out_ptr.dtype.element_ty).to(tl.float32)
256 + normalized *= tl.load(log_scaling_ptr + token)
257 + tl.store(
258 + q_out_ptr + row * HEAD_DIM + dims,
259 + normalized.to(q_out_ptr.dtype.element_ty),
260 + )
261 + rel_cols = tl.arange(0, REL_EXTENT_PADDED)
262 + rel_mask = rel_cols < REL_EXTENT
263 + projected = tl.zeros([REL_EXTENT_PADDED], dtype=tl.float32)
264 + rel_offset = Q_WIDTH + 2 * KV_WIDTH + head * D_REL
265 + for rel_dim in tl.static_range(D_REL):
266 + rel_value = tl.load(
267 + qkvr_ptr + token * stride_x_t + rel_offset + rel_dim
268 + ).to(tl.float32)
269 + proj = tl.load(
270 + rel_proj_ptr + rel_dim * REL_EXTENT + rel_cols,
271 + mask=rel_mask,
272 + other=0.0,
273 + ).to(tl.float32)
274 + projected += rel_value * proj
275 + projected = projected.to(rel_out_ptr.dtype.element_ty).to(tl.float32)
276 + if APPLY_LOG_SCALING:
277 + projected *= tl.load(log_scaling_ptr + token)
278 + tl.store(
279 + rel_out_ptr + row * REL_EXTENT + rel_cols,
280 + projected.to(rel_out_ptr.dtype.element_ty),
281 + mask=rel_mask,
282 + )
283 + else:
284 + row = block - num_q_rows
285 + if row < tokens * NUM_KV_HEADS:
286 + token = row // NUM_KV_HEADS
287 + head = row % NUM_KV_HEADS
288 + position = tl.load(positions_ptr + token)
289 + request = tl.load(seq_idx_ptr + token)
290 + conv_slot = tl.load(conv_slot_mapping_ptr + token)
291 + query_start = tl.load(query_start_ptr + token)
292 + attention_slot = tl.load(attention_slot_mapping_ptr + token)
293 + valid = conv_slot >= 0
294 +
295 + k_col = Q_WIDTH + head * HEAD_DIM
296 + v_col = Q_WIDTH + KV_WIDTH + head * HEAD_DIM
297 + k_value = tl.load(qkvr_ptr + token * stride_x_t + k_col + dims)
298 + v_value = tl.load(qkvr_ptr + token * stride_x_t + v_col + dims)
299 +
300 + safe_slot = tl.maximum(conv_slot, 0)
301 + cache_base = (
302 + conv_cache_ptr
303 + + (safe_slot // conv_block_size) * stride_cc_block
304 + + head * stride_cc_head
305 + + (safe_slot % conv_block_size) * stride_cc_token
306 + )
307 + tl.store(
308 + cache_base + (OFF_K + dims) * stride_cc_dim,
309 + k_value,
310 + mask=valid,
311 + )
312 + tl.store(
313 + cache_base + (OFF_V + dims) * stride_cc_dim,
314 + v_value,
315 + mask=valid,
316 + )
317 +
318 + acc_k = tl.zeros([HEAD_DIM], dtype=tl.float32)
319 + acc_v = tl.zeros([HEAD_DIM], dtype=tl.float32)
320 + for tap in tl.static_range(WINDOW_SIZE):
321 + source_position = position - (WINDOW_SIZE - 1) + tap
322 + source_row = token - (WINDOW_SIZE - 1) + tap
323 + in_window = valid & (source_position >= 0)
324 + intra = in_window & (source_row >= query_start)
325 + cached = in_window & (source_row < query_start)
326 + safe_row = tl.maximum(source_row, 0)
327 + source_k = tl.load(
328 + qkvr_ptr + safe_row * stride_x_t + k_col + dims,
329 + mask=intra,
330 + other=0.0,
331 + ).to(tl.float32)
332 + source_v = tl.load(
333 + qkvr_ptr + safe_row * stride_x_t + v_col + dims,
334 + mask=intra,
335 + other=0.0,
336 + ).to(tl.float32)
337 + safe_position = tl.maximum(source_position, 0)
338 + logical_block = tl.minimum(
339 + safe_position // conv_block_size, max_blocks - 1
340 + )
341 + physical_block = tl.load(
342 + conv_block_table_ptr
343 + + request * stride_block_table_req
344 + + logical_block,
345 + mask=cached,
346 + other=0,
347 + ).to(tl.int64)
348 + tap_base = (
349 + conv_cache_ptr
350 + + physical_block * stride_cc_block
351 + + head * stride_cc_head
352 + + (safe_position % conv_block_size) * stride_cc_token
353 + )
354 + source_k += tl.load(
355 + tap_base + (OFF_K + dims) * stride_cc_dim,
356 + mask=cached,
357 + other=0.0,
358 + ).to(tl.float32)
359 + source_v += tl.load(
360 + tap_base + (OFF_V + dims) * stride_cc_dim,
361 + mask=cached,
362 + other=0.0,
363 + ).to(tl.float32)
364 + k_weight = tl.load(
365 + k_weight_ptr + (head * HEAD_DIM + dims) * WINDOW_SIZE + tap
366 + ).to(tl.float32)
367 + v_weight = tl.load(
368 + v_weight_ptr + (head * HEAD_DIM + dims) * WINDOW_SIZE + tap
369 + ).to(tl.float32)
370 + acc_k += source_k * k_weight
371 + acc_v += source_v * v_weight
372 +
373 + k_rounded = (acc_k + k_value.to(tl.float32)).to(qkvr_ptr.dtype.element_ty)
374 + v_rounded = (acc_v + v_value.to(tl.float32)).to(qkvr_ptr.dtype.element_ty)
375 + k_float = k_rounded.to(tl.float32)
376 + k_norm_weight = tl.load(k_norm_weight_ptr + dims).to(tl.float32)
377 + rstd = tl.rsqrt(tl.sum(k_float * k_float, axis=0) / HEAD_DIM + eps)
378 + k_normalized = (k_float * rstd * k_norm_weight).to(
379 + qkvr_ptr.dtype.element_ty
380 + )
381 +
382 + safe_attention_slot = tl.maximum(attention_slot, 0)
383 + attention_block = safe_attention_slot // attention_page_size
384 + attention_offset = safe_attention_slot % attention_page_size
385 + tl.store(
386 + key_cache_ptr
387 + + attention_block * stride_kc_block
388 + + attention_offset * stride_kc_token
389 + + head * stride_kc_head
390 + + dims,
391 + k_normalized,
392 + mask=attention_slot >= 0,
393 + )
394 + tl.store(
395 + value_cache_ptr
396 + + attention_block * stride_vc_block
397 + + attention_offset * stride_vc_token
398 + + head * stride_vc_head
399 + + dims,
400 + v_rounded,
401 + mask=attention_slot >= 0,
402 + )
403 +
404 +
405 +@triton.jit(do_not_specialize=["num_rows"])
406 +def _q_kernel(
407 + qkvr_ptr,
408 + q_norm_weight_ptr,
409 + q_out_ptr,
410 + log_scaling_ptr,
411 + num_rows,
412 + stride_x_t,
413 + eps,
414 + NUM_Q_HEADS: tl.constexpr,
415 + HEAD_DIM: tl.constexpr,
416 + BLOCK_ROWS: tl.constexpr,
417 + APPLY_LOG_SCALING: tl.constexpr,
418 +):
419 + rows = tl.program_id(0) * BLOCK_ROWS + tl.arange(0, BLOCK_ROWS)
420 + dims = tl.arange(0, HEAD_DIM)
421 + row_mask = rows < num_rows
422 + tokens = rows // NUM_Q_HEADS
423 + heads = rows % NUM_Q_HEADS
424 + values = tl.load(
425 + qkvr_ptr
426 + + tokens[:, None] * stride_x_t
427 + + heads[:, None] * HEAD_DIM
428 + + dims[None, :],
429 + mask=row_mask[:, None],
430 + other=0.0,
431 + ).to(tl.float32)
432 + weight = tl.load(q_norm_weight_ptr + dims).to(tl.float32)
433 + rstd = tl.rsqrt(tl.sum(values * values, axis=1) / HEAD_DIM + eps)
434 + normalized = values * rstd[:, None] * weight[None, :]
435 + if APPLY_LOG_SCALING:
436 + normalized = normalized.to(q_out_ptr.dtype.element_ty).to(tl.float32)
437 + tau = tl.load(log_scaling_ptr + tokens, mask=row_mask, other=1.0)
438 + normalized *= tau[:, None]
439 + tl.store(
440 + q_out_ptr + rows[:, None] * HEAD_DIM + dims[None, :],
441 + normalized.to(q_out_ptr.dtype.element_ty),
442 + mask=row_mask[:, None],
443 + )
444 +
445 +
446 +@triton.jit(do_not_specialize=["tokens", "stride_block_table_req", "max_blocks"])
447 +def _kv_kernel(
448 + qkvr_ptr,
449 + k_weight_ptr,
450 + v_weight_ptr,
451 + k_norm_weight_ptr,
452 + conv_cache_ptr,
453 + key_cache_ptr,
454 + value_cache_ptr,
455 + positions_ptr,
456 + seq_idx_ptr,
457 + conv_slot_mapping_ptr,
458 + conv_block_table_ptr,
459 + query_start_ptr,
460 + attention_slot_mapping_ptr,
461 + tokens,
462 + eps,
463 + stride_x_t,
464 + stride_cc_block,
465 + stride_cc_head,
466 + stride_cc_token,
467 + stride_cc_dim,
468 + stride_kc_block,
469 + stride_kc_token,
470 + stride_kc_head,
471 + stride_vc_block,
472 + stride_vc_token,
473 + stride_vc_head,
474 + stride_block_table_req,
475 + max_blocks,
476 + conv_block_size,
477 + attention_page_size,
478 + Q_WIDTH: tl.constexpr,
479 + KV_WIDTH: tl.constexpr,
480 + NUM_KV_HEADS: tl.constexpr,
481 + HEAD_DIM: tl.constexpr,
482 + WINDOW_SIZE: tl.constexpr,
483 + OFF_K: tl.constexpr,
484 + OFF_V: tl.constexpr,
485 + BLOCK_ROWS: tl.constexpr,
486 +):
487 + token = tl.program_id(0) * BLOCK_ROWS + tl.arange(0, BLOCK_ROWS)
488 + dims = tl.arange(0, HEAD_DIM)
489 + row_mask = token < tokens
490 + head_id = tl.program_id(1)
491 + head = tl.full([BLOCK_ROWS], head_id, tl.int64)
492 + position = tl.load(positions_ptr + token, mask=row_mask, other=0)
493 + request = tl.load(seq_idx_ptr + token, mask=row_mask, other=0)
494 + conv_slot = tl.load(conv_slot_mapping_ptr + token, mask=row_mask, other=-1)
495 + query_start = tl.load(query_start_ptr + token, mask=row_mask, other=0)
496 + attention_slot = tl.load(
497 + attention_slot_mapping_ptr + token, mask=row_mask, other=-1
498 + )
499 + valid = row_mask & (conv_slot >= 0)
500 +
501 + k_col = Q_WIDTH + head * HEAD_DIM
502 + v_col = Q_WIDTH + KV_WIDTH + head * HEAD_DIM
503 + k_value = tl.load(
504 + qkvr_ptr + token[:, None] * stride_x_t + k_col[:, None] + dims[None, :],
505 + mask=row_mask[:, None],
506 + other=0.0,
507 + )
508 + v_value = tl.load(
509 + qkvr_ptr + token[:, None] * stride_x_t + v_col[:, None] + dims[None, :],
510 + mask=row_mask[:, None],
511 + other=0.0,
512 + )
513 + safe_slot = tl.maximum(conv_slot, 0)
514 + cache_base = (
515 + conv_cache_ptr
516 + + (safe_slot // conv_block_size) * stride_cc_block
517 + + head * stride_cc_head
518 + + (safe_slot % conv_block_size) * stride_cc_token
519 + )
520 + tl.store(
521 + cache_base[:, None] + (OFF_K + dims[None, :]) * stride_cc_dim,
522 + k_value,
523 + mask=valid[:, None],
524 + )
525 + tl.store(
526 + cache_base[:, None] + (OFF_V + dims[None, :]) * stride_cc_dim,
527 + v_value,
528 + mask=valid[:, None],
529 + )
530 +
531 + acc_k = tl.zeros([BLOCK_ROWS, HEAD_DIM], dtype=tl.float32)
532 + acc_v = tl.zeros([BLOCK_ROWS, HEAD_DIM], dtype=tl.float32)
533 + for tap in tl.static_range(WINDOW_SIZE):
534 + source_position = position - (WINDOW_SIZE - 1) + tap
535 + source_row = token - (WINDOW_SIZE - 1) + tap
536 + in_window = valid & (source_position >= 0)
537 + intra = in_window & (source_row >= query_start)
538 + cached = in_window & (source_row < query_start)
539 + safe_row = tl.maximum(source_row, 0)
540 + source_k = tl.load(
541 + qkvr_ptr + safe_row[:, None] * stride_x_t + k_col[:, None] + dims[None, :],
542 + mask=intra[:, None],
543 + other=0.0,
544 + ).to(tl.float32)
545 + source_v = tl.load(
546 + qkvr_ptr + safe_row[:, None] * stride_x_t + v_col[:, None] + dims[None, :],
547 + mask=intra[:, None],
548 + other=0.0,
549 + ).to(tl.float32)
550 + safe_position = tl.maximum(source_position, 0)
551 + logical_block = tl.minimum(safe_position // conv_block_size, max_blocks - 1)
552 + physical_block = tl.load(
553 + conv_block_table_ptr + request * stride_block_table_req + logical_block,
554 + mask=cached,
555 + other=0,
556 + ).to(tl.int64)
557 + tap_base = (
558 + conv_cache_ptr
559 + + physical_block * stride_cc_block
560 + + head * stride_cc_head
561 + + (safe_position % conv_block_size) * stride_cc_token
562 + )
563 + source_k += tl.load(
564 + tap_base[:, None] + (OFF_K + dims[None, :]) * stride_cc_dim,
565 + mask=cached[:, None],
566 + other=0.0,
567 + ).to(tl.float32)
568 + source_v += tl.load(
569 + tap_base[:, None] + (OFF_V + dims[None, :]) * stride_cc_dim,
570 + mask=cached[:, None],
571 + other=0.0,
572 + ).to(tl.float32)
573 + k_weight = tl.load(
574 + k_weight_ptr + (head_id * HEAD_DIM + dims) * WINDOW_SIZE + tap
575 + ).to(tl.float32)
576 + v_weight = tl.load(
577 + v_weight_ptr + (head_id * HEAD_DIM + dims) * WINDOW_SIZE + tap
578 + ).to(tl.float32)
579 + acc_k += source_k * k_weight[None, :]
580 + acc_v += source_v * v_weight[None, :]
581 +
582 + k_rounded = (acc_k + k_value.to(tl.float32)).to(qkvr_ptr.dtype.element_ty)
583 + v_rounded = (acc_v + v_value.to(tl.float32)).to(qkvr_ptr.dtype.element_ty)
584 + k_float = k_rounded.to(tl.float32)
585 + k_norm_weight = tl.load(k_norm_weight_ptr + dims).to(tl.float32)
586 + rstd = tl.rsqrt(tl.sum(k_float * k_float, axis=1) / HEAD_DIM + eps)
587 + k_normalized = (k_float * rstd[:, None] * k_norm_weight[None, :]).to(
588 + qkvr_ptr.dtype.element_ty
589 + )
590 +
591 + safe_attention_slot = tl.maximum(attention_slot, 0)
592 + attention_block = safe_attention_slot // attention_page_size
593 + attention_offset = safe_attention_slot % attention_page_size
594 + attention_mask = row_mask & (attention_slot >= 0)
595 + tl.store(
596 + key_cache_ptr
597 + + attention_block[:, None] * stride_kc_block
598 + + attention_offset[:, None] * stride_kc_token
599 + + head[:, None] * stride_kc_head
600 + + dims[None, :],
601 + k_normalized,
602 + mask=attention_mask[:, None],
603 + )
604 + tl.store(
605 + value_cache_ptr
606 + + attention_block[:, None] * stride_vc_block
607 + + attention_offset[:, None] * stride_vc_token
608 + + head[:, None] * stride_vc_head
609 + + dims[None, :],
610 + v_rounded,
611 + mask=attention_mask[:, None],
612 + )
613 +
614 +
615 +def _run_tiled_q(
616 + qkvr: torch.Tensor,
617 + q_norm_weight: torch.Tensor,
618 + q_out: torch.Tensor,
619 + positions: torch.Tensor,
620 + *,
621 + eps: float,
622 + num_q_heads: int,
623 + head_dim: int,
624 + log_scaling: torch.Tensor | None,
625 +) -> None:
626 + num_rows = qkvr.shape[0] * num_q_heads
627 + _q_kernel[(triton.cdiv(num_rows, Q_BLOCK_ROWS),)](
628 + qkvr,
629 + q_norm_weight,
630 + q_out,
631 + log_scaling if log_scaling is not None else positions,
632 + num_rows,
633 + qkvr.stride(0),
634 + eps,
635 + NUM_Q_HEADS=num_q_heads,
636 + HEAD_DIM=head_dim,
637 + BLOCK_ROWS=Q_BLOCK_ROWS,
638 + APPLY_LOG_SCALING=log_scaling is not None,
639 + num_warps=Q_NUM_WARPS,
640 + )
641 +
642 +
643 +def _run_tiled_kv(
644 + qkvr: torch.Tensor,
645 + k_weight: torch.Tensor,
646 + v_weight: torch.Tensor,
647 + k_norm_weight: torch.Tensor,
648 + conv_cache: torch.Tensor,
649 + key_cache: torch.Tensor,
650 + value_cache: torch.Tensor,
651 + positions: torch.Tensor,
652 + seq_idx: torch.Tensor,
653 + conv_slot_mapping: torch.Tensor,
654 + conv_block_table: torch.Tensor,
655 + query_start: torch.Tensor,
656 + attention_slot_mapping: torch.Tensor,
657 + *,
658 + eps: float,
659 + num_q_heads: int,
660 + num_kv_heads: int,
661 + head_dim: int,
662 + off_k: int,
663 + off_v: int,
664 + conv_block_size: int,
665 +) -> None:
666 + tokens = qkvr.shape[0]
667 + _kv_kernel[(triton.cdiv(tokens, KV_BLOCK_ROWS), num_kv_heads)](
668 + qkvr,
669 + k_weight,
670 + v_weight,
671 + k_norm_weight,
672 + conv_cache,
673 + key_cache,
674 + value_cache,
675 + positions,
676 + seq_idx,
677 + conv_slot_mapping,
678 + conv_block_table,
679 + query_start,
680 + attention_slot_mapping,
681 + tokens,
682 + eps,
683 + qkvr.stride(0),
684 + conv_cache.stride(0),
685 + conv_cache.stride(1),
686 + conv_cache.stride(2),
687 + conv_cache.stride(3),
688 + key_cache.stride(0),
689 + key_cache.stride(1),
690 + key_cache.stride(2),
691 + value_cache.stride(0),
692 + value_cache.stride(1),
693 + value_cache.stride(2),
694 + conv_block_table.stride(0),
695 + conv_block_table.shape[1],
696 + conv_block_size,
697 + key_cache.shape[1],
698 + Q_WIDTH=num_q_heads * head_dim,
699 + KV_WIDTH=num_kv_heads * head_dim,
700 + NUM_KV_HEADS=num_kv_heads,
701 + HEAD_DIM=head_dim,
702 + WINDOW_SIZE=k_weight.shape[1],
703 + OFF_K=off_k,
704 + OFF_V=off_v,
705 + BLOCK_ROWS=KV_BLOCK_ROWS,
706 + num_warps=KV_NUM_WARPS,
707 + )
708 +
709 +
710 +def _run_fused_small(
711 + qkvr: torch.Tensor,
712 + q_norm_weight: torch.Tensor,
713 + q_out: torch.Tensor,
714 + rel_proj: torch.Tensor,
715 + rel_out: torch.Tensor,
716 + k_weight: torch.Tensor,
717 + v_weight: torch.Tensor,
718 + k_norm_weight: torch.Tensor,
719 + conv_cache: torch.Tensor,
720 + key_cache: torch.Tensor,
721 + value_cache: torch.Tensor,
722 + positions: torch.Tensor,
723 + seq_idx: torch.Tensor,
724 + conv_slot_mapping: torch.Tensor,
725 + conv_block_table: torch.Tensor,
726 + query_start: torch.Tensor,
727 + attention_slot_mapping: torch.Tensor,
728 + *,
729 + eps: float,
730 + num_q_heads: int,
731 + num_kv_heads: int,
732 + head_dim: int,
733 + off_k: int,
734 + off_v: int,
735 + conv_block_size: int,
736 + log_scaling: torch.Tensor | None,
737 +) -> None:
738 + tokens = qkvr.shape[0]
739 +
740 + num_q_rows = tokens * num_q_heads
741 + grid = (num_q_rows + tokens * num_kv_heads,)
742 + _qkvr_qkv_kernel[grid](
743 + qkvr,
744 + q_norm_weight,
745 + q_out,
746 + rel_proj,
747 + rel_out,
748 + k_weight,
749 + v_weight,
750 + k_norm_weight,
751 + conv_cache,
752 + key_cache,
753 + value_cache,
754 + positions,
755 + seq_idx,
756 + conv_slot_mapping,
757 + conv_block_table,
758 + query_start,
759 + attention_slot_mapping,
760 + log_scaling if log_scaling is not None else positions,
761 + tokens,
762 + eps,
763 + qkvr.stride(0),
764 + conv_cache.stride(0),
765 + conv_cache.stride(1),
766 + conv_cache.stride(2),
767 + conv_cache.stride(3),
768 + key_cache.stride(0),
769 + key_cache.stride(1),
770 + key_cache.stride(2),
771 + value_cache.stride(0),
772 + value_cache.stride(1),
773 + value_cache.stride(2),
774 + conv_block_table.stride(0),
775 + conv_block_table.shape[1],
776 + conv_block_size,
777 + key_cache.shape[1],
778 + Q_WIDTH=num_q_heads * head_dim,
779 + KV_WIDTH=num_kv_heads * head_dim,
780 + NUM_Q_HEADS=num_q_heads,
781 + NUM_KV_HEADS=num_kv_heads,
782 + HEAD_DIM=head_dim,
783 + WINDOW_SIZE=k_weight.shape[1],
784 + OFF_K=off_k,
785 + OFF_V=off_v,
786 + APPLY_LOG_SCALING=log_scaling is not None,
787 + D_REL=16,
788 + REL_EXTENT=rel_proj.shape[1],
789 + REL_EXTENT_PADDED=triton.next_power_of_2(rel_proj.shape[1]),
790 + num_warps=SMALL_NUM_WARPS,
791 + )
792 +
793 +
794 +def fused_qkvr_prep(
795 + qkvr: torch.Tensor,
796 + k_weight: torch.Tensor,
797 + v_weight: torch.Tensor,
798 + q_norm_weight: torch.Tensor,
799 + k_norm_weight: torch.Tensor,
800 + rel_proj: torch.Tensor,
801 + eps: float,
802 + num_q_heads: int,
803 + num_kv_heads: int,
804 + head_dim: int,
805 + d_rel: int,
806 + conv_cache: torch.Tensor,
807 + key_cache: torch.Tensor,
808 + value_cache: torch.Tensor,
809 + positions: torch.Tensor,
810 + conv_block_table: torch.Tensor,
811 + seq_idx: torch.Tensor,
812 + conv_slot_mapping: torch.Tensor,
813 + query_start: torch.Tensor,
814 + attention_slot_mapping: torch.Tensor,
815 + off_k: int,
816 + off_v: int,
817 + conv_block_size: int,
818 + log_scaling: torch.Tensor | None = None,
819 +) -> tuple[torch.Tensor, torch.Tensor]:
820 + assert d_rel == 16 and rel_proj.shape[0] == 16
821 + assert head_dim == 128
822 + assert qkvr.is_contiguous()
823 + assert k_weight.stride() == (k_weight.shape[1], 1)
824 + assert v_weight.stride() == (v_weight.shape[1], 1)
825 + assert rel_proj.stride() == (rel_proj.shape[1], 1)
826 + assert conv_cache.stride(3) == 1
827 + assert key_cache.stride(3) == 1 and value_cache.stride(3) == 1
828 + tokens = qkvr.shape[0]
829 + q_out = torch.empty(
830 + (tokens, num_q_heads * head_dim), dtype=qkvr.dtype, device=qkvr.device
831 + )
832 + rel_out = torch.empty(
833 + (tokens, num_q_heads, rel_proj.shape[1]),
834 + dtype=qkvr.dtype,
835 + device=qkvr.device,
836 + )
837 + if tokens == 0:
838 + return q_out, rel_out
839 +
840 + if tokens < SMALL_TOKEN_THRESHOLD:
841 + _run_fused_small(
842 + qkvr,
843 + q_norm_weight,
844 + q_out,
845 + rel_proj,
846 + rel_out,
847 + k_weight,
848 + v_weight,
849 + k_norm_weight,
850 + conv_cache,
851 + key_cache,
852 + value_cache,
853 + positions,
854 + seq_idx,
855 + conv_slot_mapping,
856 + conv_block_table,
857 + query_start,
858 + attention_slot_mapping,
859 + eps=eps,
860 + num_q_heads=num_q_heads,
861 + num_kv_heads=num_kv_heads,
862 + head_dim=head_dim,
863 + off_k=off_k,
864 + off_v=off_v,
865 + conv_block_size=conv_block_size,
866 + log_scaling=log_scaling,
867 + )
868 + return q_out, rel_out
869 +
870 + kv_stream = aux_stream()
871 + assert kv_stream is not None
872 + current_stream = torch.cuda.current_stream()
873 + kv_stream.wait_stream(current_stream)
874 + with torch.cuda.stream(kv_stream):
875 + _run_tiled_kv(
876 + qkvr,
877 + k_weight,
878 + v_weight,
879 + k_norm_weight,
880 + conv_cache,
881 + key_cache,
882 + value_cache,
883 + positions,
884 + seq_idx,
885 + conv_slot_mapping,
886 + conv_block_table,
887 + query_start,
888 + attention_slot_mapping,
889 + eps=eps,
890 + num_q_heads=num_q_heads,
891 + num_kv_heads=num_kv_heads,
892 + head_dim=head_dim,
893 + off_k=off_k,
894 + off_v=off_v,
895 + conv_block_size=conv_block_size,
896 + )
897 + _run_tiled_q(
898 + qkvr,
899 + q_norm_weight,
900 + q_out,
901 + positions,
902 + eps=eps,
903 + num_q_heads=num_q_heads,
904 + head_dim=head_dim,
905 + log_scaling=log_scaling,
906 + )
907 + qkvr_rel_proj(
908 + qkvr,
909 + rel_proj,
910 + rel_out,
911 + log_scaling,
912 + num_q_heads=num_q_heads,
913 + num_kv_heads=num_kv_heads,
914 + head_dim=head_dim,
915 + d_rel=d_rel,
916 + )
917 + current_stream.wait_stream(kv_stream)
918 + return q_out, rel_out
vllm/models/inkling/nvidia/ops/sconv.py new
+290
@@ -0,0 +1,290 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling short-convolution kernels backed by a paged sliding-window state cache.
4 +
5 +Each layer's 4 conv streams (K, V, attn-output, mlp-output) share one paged KV
6 +cache ``[num_blocks, H, N, D]`` (head-major; see ``sconv_swa_attn.py``). A stream
7 +occupies the contiguous D-sub-range ``[off_s, off_s + ws)`` across all ``H``
8 +heads, so its flat per-token width is ``H * ws`` and that is the conv channel
9 +dim. The cache stores the conv *input* at every absolute position.
10 +
11 +``fused_sconv`` is the single-launch path used by the model: per token it writes
12 +the current input to its slot and convolves the ``W`` taps ending at its
13 +absolute position. A tap landing inside the current forward is read from the
14 +immutable input ``x`` (row ``src - pos + pid_t``); only pre-forward taps are read
15 +from the paged cache (window position ``src`` -> physical block via
16 +``block_table[req, src // N]``). The just-written slot is never read back this
17 +step, so there is no write/read hazard within or across programs -- which is
18 +why this needs no decode-vs-prefill split and is valid for prefill / decode /
19 +spec alike.
20 +
21 +All kernels address the cache purely by ``(slot, absolute_position)`` and
22 +allocate nothing inside the captured region; their grids depend only on the
23 +token count (``fused_sconv`` on a fixed token/channel tiling), so the same
24 +decode path replays correctly under a full CUDA graph without any
25 +data-dependent shape or branch.
26 +"""
27 +
28 +from __future__ import annotations
29 +
30 +import torch
31 +
32 +from vllm.triton_utils import tl, triton
33 +
34 +
35 +@triton.jit
36 +def _fused_sconv_kernel(
37 + x_ptr, # [T, H*WS] head-major current-token inputs (also residual)
38 + cache_ptr, # [num_blocks, H, N, D] paged (page-strided view)
39 + weight_ptr, # [H*WS, W]
40 + out_ptr, # [T, H*WS]
41 + pos_ptr, # [T] int64 absolute position per token
42 + seq_idx_ptr, # [T] int32 token -> batch request
43 + slot_ptr, # [T] int64 flat slot (block*N + blk_off); < 0 => PAD
44 + block_table_ptr, # [num_reqs, max_blocks] int32 block_table
45 + qstart_ptr, # [T] int32 first x-row of the token's request
46 + T, # num tokens
47 + stride_x_t,
48 + stride_c_blk,
49 + stride_c_h,
50 + stride_c_n,
51 + stride_c_d,
52 + stride_w_d,
53 + stride_w_w,
54 + stride_bt_r,
55 + MAX_BLOCKS,
56 + N, # block_size
57 + W: tl.constexpr,
58 + USE_SILU: tl.constexpr,
59 + USE_RESIDUAL: tl.constexpr,
60 + OFF_S: tl.constexpr,
61 + WS: tl.constexpr,
62 + H: tl.constexpr,
63 + BT: tl.constexpr,
64 + BLOCK_C: tl.constexpr,
65 +):
66 + # Each program owns a [BT tokens, BLOCK_C channels] tile. The flat channel
67 + # index packs all H heads head-major (head = c // WS, in-stream offset =
68 + # c % WS), so one program spans heads -- no per-head launch and no
69 + # next_power_of_2(WS) lane waste.
70 + pid_t = tl.program_id(0)
71 + pid_c = tl.program_id(1)
72 + toff = pid_t * BT + tl.arange(0, BT) # [BT] token rows
73 + coff = pid_c * BLOCK_C + tl.arange(0, BLOCK_C) # [BLOCK_C] flat channels
74 + C = H * WS
75 + t_mask = toff < T
76 + c_mask = coff < C
77 + head = tl.minimum(coff // WS, H - 1) # clamp keeps masked lanes in-buffer
78 + cd = OFF_S + coff % WS # cache D-index of the channel's stream slot
79 +
80 + slot = tl.load(slot_ptr + toff, mask=t_mask, other=-1) # [BT]
81 + valid = slot >= 0
82 + pos = tl.load(pos_ptr + toff, mask=t_mask, other=0)
83 + req = tl.load(seq_idx_ptr + toff, mask=t_mask, other=0)
84 + qstart = tl.load(qstart_ptr + toff, mask=t_mask, other=0)
85 +
86 + tc_mask = t_mask[:, None] & c_mask[None, :]
87 +
88 + # 1) Insert each token's input into its paged slot (skip PAD rows).
89 + xv = tl.load(x_ptr + toff[:, None] * stride_x_t + coff[None, :], mask=tc_mask)
90 + safe_slot = tl.maximum(slot, 0)
91 + dst = (
92 + cache_ptr
93 + + (safe_slot // N)[:, None] * stride_c_blk
94 + + head[None, :] * stride_c_h
95 + + (safe_slot % N)[:, None] * stride_c_n
96 + + cd[None, :] * stride_c_d
97 + )
98 + tl.store(dst, xv, mask=tc_mask & valid[:, None])
99 +
100 + # 2) Convolve the W taps ending at each token's `pos`. Each tap is read from
101 + # `x` if it falls inside this forward (row >= the request's first row), else
102 + # from the paged cache. Exactly one source is unmasked per tap, so we sum both.
103 + acc = tl.zeros([BT, BLOCK_C], dtype=tl.float32)
104 + for iw in tl.static_range(W):
105 + src = pos - (W - 1) + iw # [BT] absolute window position
106 + row = toff - (W - 1) + iw # [BT] x-row of `src` (== src - pos + token)
107 + in_win = valid & (src >= 0)
108 + intra = in_win & (row >= qstart)
109 + cached = in_win & (row < qstart)
110 + # intra-forward tap: read the immutable input x (never the slot we just
111 + # wrote), so there is no write/read hazard.
112 + safe_row = tl.maximum(row, 0)
113 + xt = tl.load(
114 + x_ptr + safe_row[:, None] * stride_x_t + coff[None, :],
115 + mask=c_mask[None, :] & intra[:, None],
116 + other=0.0,
117 + ).to(tl.float32)
118 + # pre-forward tap: read from the paged cache via the block table. Clamp
119 + # addressing terms; the load is masked off when out of window.
120 + safe_src = tl.maximum(src, 0)
121 + safe_lblk = tl.minimum(safe_src // N, MAX_BLOCKS - 1)
122 + blk = tl.load(
123 + block_table_ptr + req * stride_bt_r + safe_lblk, mask=cached, other=0
124 + ).to(tl.int64)
125 + cbase = (
126 + cache_ptr
127 + + blk[:, None] * stride_c_blk
128 + + head[None, :] * stride_c_h
129 + + (safe_src % N)[:, None] * stride_c_n
130 + + cd[None, :] * stride_c_d
131 + )
132 + cv = tl.load(cbase, mask=c_mask[None, :] & cached[:, None], other=0.0).to(
133 + tl.float32
134 + )
135 + wv = tl.load(
136 + weight_ptr + coff * stride_w_d + iw * stride_w_w, mask=c_mask, other=0.0
137 + ).to(tl.float32)
138 + acc += (xt + cv) * wv[None, :]
139 +
140 + if USE_SILU:
141 + acc = acc * tl.sigmoid(acc)
142 + if USE_RESIDUAL:
143 + acc += xv.to(tl.float32)
144 +
145 + tl.store(
146 + out_ptr + toff[:, None] * stride_x_t + coff[None, :],
147 + acc.to(out_ptr.dtype.element_ty),
148 + mask=tc_mask,
149 + )
150 +
151 +
152 +def fused_sconv(
153 + x: torch.Tensor, # [T, H*ws] head-major current-token inputs
154 + weight: torch.Tensor, # [H*ws, W]
155 + cache: torch.Tensor, # [num_blocks, H, N, D] paged
156 + positions: torch.Tensor, # [T] int64 absolute position per token
157 + block_table: torch.Tensor, # [num_reqs, max_blocks] int32
158 + seq_idx: torch.Tensor, # [T] int32 token -> batch request
159 + slot_mapping: torch.Tensor, # [T] int64 flat slot (PAD = -1 => skip)
160 + query_start: torch.Tensor, # [T] int32 first x-row of the token's request
161 + off_s: int,
162 + ws: int,
163 + block_size: int,
164 + activation: str | None = None,
165 + use_residual: bool = True,
166 +) -> torch.Tensor:
167 + """Single-launch insert + depthwise causal conv1d over the paged cache.
168 +
169 + Reads same-forward taps from ``x`` and pre-forward taps from the cache, so
170 + it is race-free in one launch for prefill / decode / spec and supports full
171 + CUDA-graph capture for decode.
172 + """
173 + T = x.shape[0]
174 + out = torch.empty_like(x)
175 + if T == 0:
176 + return out
177 + assert x.is_contiguous()
178 + assert cache.stride(3) == 1, "cache D-dim must be contiguous"
179 + H = cache.shape[1]
180 + W = weight.shape[1]
181 + C = H * ws # flat conv channel dim (all heads, head-major)
182 + # Tile BT tokens x BLOCK_C channels per program: enough work per CTA to
183 + # amortize the per-token addressing, while keeping the grid large for
184 + # prefill. BLOCK_C spans heads so there is no per-head launch. A ~2K-element
185 + # tile at 4 warps measured best on Blackwell; larger tiles spill registers.
186 + BLOCK_C = min(triton.next_power_of_2(C), 256)
187 + BT = 8
188 + grid = (triton.cdiv(T, BT), triton.cdiv(C, BLOCK_C))
189 + _fused_sconv_kernel[grid](
190 + x,
191 + cache,
192 + weight,
193 + out,
194 + positions,
195 + seq_idx,
196 + slot_mapping,
197 + block_table,
198 + query_start,
199 + T,
200 + x.stride(0),
201 + cache.stride(0),
202 + cache.stride(1),
203 + cache.stride(2),
204 + cache.stride(3),
205 + weight.stride(0),
206 + weight.stride(1),
207 + block_table.stride(0),
208 + block_table.shape[1],
209 + block_size,
210 + W=W,
211 + USE_SILU=activation in ("silu", "swish"),
212 + USE_RESIDUAL=use_residual,
213 + OFF_S=off_s,
214 + WS=ws,
215 + H=H,
216 + BT=BT,
217 + BLOCK_C=BLOCK_C,
218 + num_warps=4,
219 + )
220 + return out
221 +
222 +
223 +@triton.jit
224 +def _seq_metadata_kernel(
225 + qsl_ptr, # [num_reqs + 1] int32 cumulative query start rows
226 + seq_idx_ptr, # [T] int32 out: token -> owning request
227 + query_start_ptr, # [T] int32 out: first x-row of the token's request
228 + num_reqs,
229 + num_actual_tokens,
230 + num_padded_tokens,
231 + n_iters, # ceil(log2(num_reqs)): binary-search depth
232 + BLOCK: tl.constexpr,
233 +):
234 + offs = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
235 + tok = offs.to(tl.int32)
236 + # Largest j in [0, num_reqs) with qsl[j] <= tok.
237 + lo = tl.zeros([BLOCK], tl.int32)
238 + hi = tl.full([BLOCK], num_reqs - 1, tl.int32)
239 + for _ in range(n_iters):
240 + mid = (lo + hi + 1) // 2
241 + below = tl.load(qsl_ptr + mid) <= tok
242 + lo = tl.where(below, mid, lo)
243 + hi = tl.where(below, hi, mid - 1)
244 + actual = offs < num_actual_tokens
245 + padded = offs < num_padded_tokens
246 + query_start = tl.load(qsl_ptr + lo)
247 + tl.store(seq_idx_ptr + offs, tl.where(actual, lo, 0), mask=padded)
248 + tl.store(
249 + query_start_ptr + offs,
250 + tl.where(actual, query_start, 0),
251 + mask=padded,
252 + )
253 +
254 +
255 +def sconv_seq_metadata(
256 + query_start_loc: torch.Tensor,
257 + num_reqs: int,
258 + num_actual_tokens: int,
259 + seq_idx_out: torch.Tensor,
260 + query_start_out: torch.Tensor,
261 + num_padded_tokens: int | None = None,
262 +) -> None:
263 + """Fill static per-token seq_idx / query_start buffers in one launch.
264 +
265 + Replaces the arange + searchsorted + clamp + gather + 2x copy chain of the
266 + sconv metadata build with a single kernel writing both persistent buffers.
267 + Padded rows are filled with zero and must have ``slot_mapping == -1``.
268 + """
269 + if num_padded_tokens is None:
270 + num_padded_tokens = num_actual_tokens
271 + if num_padded_tokens < num_actual_tokens:
272 + raise ValueError("num_padded_tokens must cover all actual tokens")
273 + if num_padded_tokens > seq_idx_out.shape[0]:
274 + raise ValueError("seq_idx_out is too small for the padded token count")
275 + if num_padded_tokens > query_start_out.shape[0]:
276 + raise ValueError("query_start_out is too small for the padded token count")
277 +
278 + BLOCK = 256
279 + n_iters = (num_reqs - 1).bit_length()
280 + grid = (triton.cdiv(num_padded_tokens, BLOCK),)
281 + _seq_metadata_kernel[grid](
282 + query_start_loc,
283 + seq_idx_out,
284 + query_start_out,
285 + num_reqs,
286 + num_actual_tokens,
287 + num_padded_tokens,
288 + n_iters,
289 + BLOCK=BLOCK,
290 + )
vllm/models/inkling/nvidia/ops/silu_and_mul.py new
+197
@@ -0,0 +1,197 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""SwiGLU kernels for the Inkling MLP layers.
4 +
5 +``silu_and_mul_triton``: SiLU-and-mul over the checkpoint's interleaved
6 +fused gate/up layout (dense MLP). ``sink_silu_mul_epilogue``: the sink-expert
7 +variant with the per-expert dequant scale and per-token gamma fused in.
8 +"""
9 +
10 +import torch
11 +
12 +from vllm.triton_utils import tl, triton
13 +
14 +
15 +@triton.jit(do_not_specialize=["M"])
16 +def _silu_and_mul_triton_kernel(
17 + gateup_out_ptr,
18 + down_inp_ptr,
19 + M,
20 + N: tl.constexpr,
21 + GRID_SIZE: tl.constexpr,
22 + NUM_STAGES: tl.constexpr,
23 + BLOCK_SIZE_M: tl.constexpr,
24 + BLOCK_SIZE_N: tl.constexpr,
25 + EVEN_N: tl.constexpr,
26 + INT64_INDEX: tl.constexpr,
27 +):
28 + start_pid = tl.program_id(0)
29 + if INT64_INDEX:
30 + start_pid = start_pid.to(tl.int64)
31 + M = M.to(tl.int64)
32 +
33 + NUM_BLOCKS_N: tl.constexpr = tl.cdiv(N, BLOCK_SIZE_N)
34 + num_blocks_mn = tl.cdiv(M, BLOCK_SIZE_M) * NUM_BLOCKS_N
35 +
36 + for pid in tl.range(start_pid, num_blocks_mn, GRID_SIZE, num_stages=NUM_STAGES):
37 + pid_m = pid // NUM_BLOCKS_N
38 + pid_n = pid % NUM_BLOCKS_N
39 +
40 + offs_m = pid_m * BLOCK_SIZE_M + tl.arange(0, BLOCK_SIZE_M)
41 + offs_n = pid_n * BLOCK_SIZE_N + tl.arange(0, BLOCK_SIZE_N)
42 + mask_m = offs_m < M
43 + mask_n = offs_n < N
44 +
45 + # Interleaved fused gate/up: [g0, u0, g1, u1, ...].
46 + mask_offs_2n = pid_n * BLOCK_SIZE_N + tl.arange(0, 2 * BLOCK_SIZE_N) // 2
47 + tl.static_assert(BLOCK_SIZE_N % 8 == 0, f"{BLOCK_SIZE_N=}")
48 + mask_2n = mask_offs_2n < N
49 + mask_2n = tl.max_constancy(mask_2n, [16])
50 +
51 + offs_2n = pid_n * 2 * BLOCK_SIZE_N + tl.arange(0, 2 * BLOCK_SIZE_N)
52 + offs_m2n = offs_m[:, None] * N * 2 + offs_2n[None, :]
53 +
54 + if EVEN_N or pid_n * BLOCK_SIZE_N + BLOCK_SIZE_N <= N:
55 + gateup_out = tl.load(
56 + gateup_out_ptr + offs_m2n, mask=mask_m[:, None], other=0.0
57 + )
58 + else:
59 + mask_m2n = mask_m[:, None] & mask_2n[None, :]
60 + gateup_out = tl.load(gateup_out_ptr + offs_m2n, mask=mask_m2n, other=0.0)
61 +
62 + gate_out, up_out = tl.split(
63 + tl.reshape(gateup_out, (BLOCK_SIZE_M, BLOCK_SIZE_N, 2))
64 + )
65 + gate_out = gate_out.to(tl.float32)
66 + up_out = up_out.to(tl.float32)
67 +
68 + down_inp = gate_out * tl.sigmoid(gate_out) * up_out
69 +
70 + mask_mn = mask_m[:, None] if EVEN_N else mask_m[:, None] & mask_n[None, :]
71 + offs_mn = offs_m[:, None] * N + offs_n[None, :]
72 + tl.store(down_inp_ptr + offs_mn, down_inp, mask=mask_mn)
73 +
74 +
75 +def silu_and_mul_triton(gateup_output: torch.Tensor) -> torch.Tensor:
76 + """SiLU-and-mul for the interleaved fused gate/up layout.
77 +
78 + Adapted from ``inkling_kernels.activation.silu_and_mul_fwd`` (without MXFP).
79 + """
80 + assert gateup_output.is_contiguous(), (
81 + f"{gateup_output.shape=} {gateup_output.stride()=}"
82 + )
83 + assert gateup_output.ndim == 2, f"{gateup_output.shape=}"
84 +
85 + M = gateup_output.shape[0]
86 + hidden_size = gateup_output.shape[1]
87 + assert hidden_size % 2 == 0, f"{hidden_size=}"
88 + N = hidden_size // 2
89 +
90 + down_input = torch.empty(
91 + (M, N), device=gateup_output.device, dtype=gateup_output.dtype
92 + )
93 + if M == 0:
94 + return down_input
95 +
96 + BLOCK_SIZE_N = max(8, min(256, triton.next_power_of_2(N)))
97 + if M <= 1:
98 + BLOCK_SIZE_M = 4
99 + elif M <= 256:
100 + BLOCK_SIZE_M = 2
101 + elif M < 4096:
102 + BLOCK_SIZE_M = 4
103 + else:
104 + BLOCK_SIZE_M = 16
105 + BLOCK_SIZE_N = max(8, min(128, triton.next_power_of_2(N)))
106 + max_grid_size = triton.cdiv(M, BLOCK_SIZE_M) * triton.cdiv(N, BLOCK_SIZE_N)
107 + num_sms = torch.cuda.get_device_properties(
108 + gateup_output.device
109 + ).multi_processor_count
110 + grid_size = min(num_sms * 4, max_grid_size)
111 +
112 + _silu_and_mul_triton_kernel[(grid_size,)](
113 + gateup_out_ptr=gateup_output,
114 + down_inp_ptr=down_input,
115 + M=M,
116 + N=N,
117 + GRID_SIZE=grid_size,
118 + NUM_STAGES=1,
119 + BLOCK_SIZE_M=BLOCK_SIZE_M,
120 + BLOCK_SIZE_N=BLOCK_SIZE_N,
121 + EVEN_N=N % BLOCK_SIZE_N == 0,
122 + INT64_INDEX=gateup_output.nbytes >= 2**31,
123 + num_warps=8,
124 + )
125 +
126 + return down_input
127 +
128 +
129 +@triton.jit(do_not_specialize=["T"])
130 +def _sink_epilogue_kernel(
131 + raw_ptr, # [T, S * 2F] gemm1 output, interleaved g/u pairs per expert block
132 + alpha_ptr, # [S] fp32 per-expert pre-SiLU dequant scale
133 + gamma_ptr, # [T, S] fp32 per-token sink weights (may be strided)
134 + ratio_ptr, # [S] fp32 per-expert post-SiLU scale (gemm2 alpha ratio)
135 + out_ptr, # [T, S * F] output
136 + T,
137 + stride_raw_0,
138 + stride_gamma_0,
139 + F: tl.constexpr,
140 + S: tl.constexpr,
141 + BLOCK_F: tl.constexpr,
142 +):
143 + pid_t = tl.program_id(0).to(tl.int64)
144 + pid_sf = tl.program_id(1)
145 + if pid_t >= T:
146 + return
147 + s = pid_sf // (F // BLOCK_F)
148 + offs_f = (pid_sf % (F // BLOCK_F)) * BLOCK_F + tl.arange(0, BLOCK_F)
149 +
150 + base = pid_t * stride_raw_0 + s * 2 * F
151 + gate = tl.load(raw_ptr + base + 2 * offs_f).to(tl.float32)
152 + up = tl.load(raw_ptr + base + 2 * offs_f + 1).to(tl.float32)
153 + alpha = tl.load(alpha_ptr + s)
154 + weight = tl.load(gamma_ptr + pid_t * stride_gamma_0 + s) * tl.load(ratio_ptr + s)
155 +
156 + gate *= alpha
157 + up *= alpha
158 + h = gate * tl.sigmoid(gate) * up * weight
159 + tl.store(out_ptr + pid_t * (S * F) + s * F + offs_f, h)
160 +
161 +
162 +def sink_silu_mul_epilogue(
163 + raw: torch.Tensor, # [T, S * 2F] gemm1 output (interleaved gate/up rows)
164 + alphas: torch.Tensor, # [S] fp32
165 + gammas: torch.Tensor, # [T, S] fp32
166 + ratios: torch.Tensor, # [S] fp32
167 + n_experts: int,
168 + out_dtype: torch.dtype,
169 +) -> torch.Tensor:
170 + """Fused sink-expert epilogue: silu(g * a_e) * (u * a_e) * (gamma * r_e).
171 +
172 + One kernel replaces the per-expert dequant column scale, the SwiGLU, and
173 + the per-token gamma multiply between the two sink GEMMs.
174 + """
175 + tokens = raw.shape[0]
176 + f = raw.shape[1] // (2 * n_experts)
177 + out = torch.empty((tokens, n_experts * f), device=raw.device, dtype=out_dtype)
178 + if tokens == 0:
179 + return out
180 + # raw may be a column-slice of a padded GEMM output (rows strided).
181 + assert raw.stride(1) == 1 and gammas.stride(1) == 1
182 + # Largest power-of-two divisor of f (f = 768 -> 256), capped at 512.
183 + block_f = min(512, f & (-f))
184 + _sink_epilogue_kernel[(tokens, n_experts * (f // block_f))](
185 + raw,
186 + alphas,
187 + gammas,
188 + ratios,
189 + out,
190 + tokens,
191 + raw.stride(0),
192 + gammas.stride(0),
193 + F=f,
194 + S=n_experts,
195 + BLOCK_F=block_f,
196 + )
197 + return out
vllm/models/inkling/nvidia/sconv_swa_attn.py new
+221
@@ -0,0 +1,221 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling short-conv state managed as a sliding-window KV cache.
4 +
5 +Each decoder layer owns one ``InklingConvState`` (an ``AttentionLayerBase``) that
6 +emits a single ``SlidingWindowSpec`` for the layer's 4 sconv streams (K, V,
7 +attn-output, mlp-output), packed head-major into one block:
8 +
9 + H = num_kv_heads (per-rank), N = block_size = sconv_kernel_size,
10 + D = head_dim(K) + head_dim(V) + hidden/H(attn) + hidden/H(mlp)
11 +
12 +``D`` is TP-invariant; per rank we store ``H/TP`` heads of width ``D``. The conv
13 +reads/writes this cache out-of-band via a custom backend; the (smaller) conv page
14 +is padded up to the uniform attention page by ``unify_kv_cache_spec_page_size``.
15 +"""
16 +
17 +from __future__ import annotations
18 +
19 +from dataclasses import dataclass
20 +from typing import ClassVar
21 +
22 +import torch
23 +from torch import nn
24 +
25 +from vllm.config import VllmConfig, get_current_vllm_config
26 +from vllm.distributed import get_tensor_model_parallel_world_size
27 +from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
28 +from vllm.v1.attention.backend import (
29 + AttentionBackend,
30 + AttentionCGSupport,
31 + AttentionMetadata,
32 + AttentionMetadataBuilder,
33 + CommonAttentionMetadata,
34 +)
35 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheSpec, SlidingWindowSpec
36 +
37 +from .ops.sconv import sconv_seq_metadata
38 +
39 +# Stream order within the per-head packed D (== contiguous sub-ranges).
40 +_K, _V, _ATTN, _MLP = 0, 1, 2, 3
41 +
42 +
43 +@dataclass
44 +class InklingSconvMetadata(AttentionMetadata):
45 + block_table: torch.Tensor # [num_reqs, max_blocks] physical blocks per req
46 + slot_mapping: torch.Tensor # [T] int64 flat slot of each token (-1 => skip)
47 + seq_idx: torch.Tensor # [T] int32 token -> batch request
48 + query_start: torch.Tensor # [T] int32 first x-row of each token's request
49 +
50 +
51 +class InklingSconvMetadataBuilder(AttentionMetadataBuilder[InklingSconvMetadata]):
52 + _cudagraph_support: ClassVar[AttentionCGSupport] = (
53 + AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE
54 + )
55 +
56 + def __init__(
57 + self,
58 + kv_cache_spec: AttentionSpec,
59 + layer_names: list[str],
60 + vllm_config: VllmConfig,
61 + device: torch.device,
62 + ) -> None:
63 + super().__init__(kv_cache_spec, layer_names, vllm_config, device)
64 + assert isinstance(kv_cache_spec, SlidingWindowSpec)
65 + # Persistent per-token buffers for CUDA graph capture.
66 + max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens
67 + self.seq_idx_buffer = torch.empty(
68 + max_num_tokens, dtype=torch.int32, device=device
69 + )
70 + self.query_start_buffer = torch.empty(
71 + max_num_tokens, dtype=torch.int32, device=device
72 + )
73 +
74 + def build(
75 + self,
76 + common_prefix_len: int,
77 + common_attn_metadata: CommonAttentionMetadata,
78 + fast_build: bool = False,
79 + ) -> InklingSconvMetadata:
80 + num_reqs = common_attn_metadata.num_reqs
81 + num_actual_tokens = int(common_attn_metadata.query_start_loc_cpu[-1])
82 + num_padded_tokens = common_attn_metadata.slot_mapping.shape[0]
83 + assert num_padded_tokens >= num_actual_tokens
84 +
85 + # Per-token seq_idx (owning request) and query_start (first x-row of
86 + # that request; the fused kernel uses it to tell same-forward taps,
87 + # read from x, from pre-forward taps, read from cache) in one launch.
88 + sconv_seq_metadata(
89 + common_attn_metadata.query_start_loc,
90 + num_reqs,
91 + num_actual_tokens,
92 + self.seq_idx_buffer,
93 + self.query_start_buffer,
94 + num_padded_tokens,
95 + )
96 +
97 + return InklingSconvMetadata(
98 + block_table=common_attn_metadata.block_table_tensor,
99 + slot_mapping=common_attn_metadata.slot_mapping[:num_padded_tokens],
100 + seq_idx=self.seq_idx_buffer[:num_padded_tokens],
101 + query_start=self.query_start_buffer[:num_padded_tokens],
102 + )
103 +
104 +
105 +class InklingSconvBackend(AttentionBackend):
106 + """Custom dummy backend for the sconv sliding-window cache management."""
107 +
108 + @staticmethod
109 + def get_name() -> str:
110 + return "INKLING_SCONV_SWA"
111 +
112 + @classmethod
113 + def indexes_kv_by_block_stride(cls) -> bool:
114 + # num_blocks is the outermost dim (HND, see get_kv_cache_shape), so the
115 + # padded conv page is read through a strided view.
116 + return True
117 +
118 + @staticmethod
119 + def get_kv_cache_shape(
120 + num_blocks: int,
121 + block_size: int,
122 + num_kv_heads: int,
123 + head_size: int,
124 + cache_dtype_str: str = "auto",
125 + ) -> tuple[int, ...]:
126 + # HND, num-blocks-first, head-major: [num_blocks, H, N, D].
127 + return (num_blocks, num_kv_heads, block_size, head_size)
128 +
129 + @staticmethod
130 + def get_kv_cache_stride_order(
131 + include_num_layers_dimension: bool = False,
132 + ) -> tuple[int, ...]:
133 + # Identity: physical layout == logical [num_blocks, H, N, D].
134 + if include_num_layers_dimension:
135 + return (0, 1, 2, 3, 4)
136 + return (0, 1, 2, 3)
137 +
138 + @staticmethod
139 + def get_impl_cls():
140 + raise NotImplementedError(
141 + "InklingSconvBackend has no attention impl; the conv runs out-of-band."
142 + )
143 +
144 + @staticmethod
145 + def get_builder_cls() -> type[InklingSconvMetadataBuilder]:
146 + return InklingSconvMetadataBuilder
147 +
148 +
149 +class InklingConvState(nn.Module, AttentionLayerBase):
150 + """Per-decoder-layer owner emitting one sliding-window conv-state spec."""
151 +
152 + def __init__(
153 + self,
154 + *,
155 + num_kv_heads: int,
156 + head_dim: int,
157 + hidden_size: int,
158 + kernel_size: int,
159 + prefix: str,
160 + ) -> None:
161 + super().__init__()
162 + self.prefix = prefix
163 + # Bound to the manager-allocated paged cache by bind_kv_cache; a
164 + # placeholder until then. Read out-of-band by InklingShortConv.
165 + self.kv_cache = torch.tensor([])
166 + tp_size = get_tensor_model_parallel_world_size()
167 + # Guardrails for the conv-state layout below; only these are exercised.
168 + # tp_size <= num_kv_heads keeps >=1 whole KV head per rank (no
169 + # replication/clamping), so the per-head width stays TP-invariant.
170 + assert tp_size <= num_kv_heads, (
171 + f"sconv SWA cache supports tp_size <= num_kv_heads ({num_kv_heads}), "
172 + f"got {tp_size}"
173 + )
174 + # Per-rank head count; D is TP-invariant (K/V heads and the hidden
175 + # chunk both scale 1/TP together). The attn-/mlp-output sconv streams
176 + # are hidden-sharded: each rank owns its H/tp chunk (the sublayer
177 + # outputs are reduce-scattered / all-gathered around the conv).
178 + self.num_kv_heads = num_kv_heads // tp_size
179 + hidden_per_head = hidden_size // num_kv_heads
180 + # Packed per-head width: K + V + attn-output chunk + mlp-output chunk,
181 + # padded to a power of two so every layer's conv page is the same size
182 + # and an exact multiple of the attention page (the page unifier then
183 + # scales attention block sizes instead of padding).
184 + raw_head_size = 2 * head_dim + 2 * hidden_per_head
185 + self.head_size = 1 << (raw_head_size - 1).bit_length()
186 + self.sliding_window = kernel_size
187 + self.block_size = kernel_size
188 + # Per-head D-sub-range (offset, width) for each stream. Streams share
189 + # the cache; each writes/reads its own width across all H heads.
190 + self.stream_ranges: tuple[tuple[int, int], ...] = (
191 + (0, head_dim), # _K
192 + (head_dim, head_dim), # _V
193 + (2 * head_dim, hidden_per_head), # _ATTN
194 + (2 * head_dim + hidden_per_head, hidden_per_head), # _MLP
195 + )
196 + vllm_config = get_current_vllm_config()
197 + self._dtype = vllm_config.model_config.dtype
198 + assert self._dtype == torch.bfloat16, (
199 + f"sconv SWA cache supports bfloat16 only, got {self._dtype}"
200 + )
201 + # Register in the forward context so the runner enumerates this owner as
202 + # an attention-like layer (get_kv_cache_spec / get_attn_backend).
203 + compilation_config = vllm_config.compilation_config
204 + if prefix in compilation_config.static_forward_context:
205 + raise ValueError(f"Duplicate layer name: {prefix}")
206 + compilation_config.static_forward_context[prefix] = self
207 +
208 + def forward(self): ...
209 +
210 + def get_attn_backend(self) -> type[AttentionBackend]:
211 + return InklingSconvBackend
212 +
213 + def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
214 + return SlidingWindowSpec(
215 + block_size=self.block_size,
216 + num_kv_heads=self.num_kv_heads,
217 + head_size=self.head_size,
218 + head_size_v=0, # all 4 streams packed into head_size
219 + dtype=self._dtype,
220 + sliding_window=self.sliding_window,
221 + )
vllm/models/inkling/nvidia/short_conv.py new
+93
@@ -0,0 +1,93 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling short convolution: depthwise causal conv1d (+ residual) over a paged
4 +sliding-window conv-state cache.
5 +
6 +Each decoder layer owns one ``InklingConvState`` (``sconv_swa_attn.py``) holding the
7 +manager-allocated paged cache for the layer's 4 sconv streams (K, V, attn-output,
8 +mlp-output), packed head-major into one block. Each ``InklingShortConv`` is a
9 +stateless weight + kernel launcher that, per forward (positions-addressed, the
10 +same path for prefill / decode / mixed), inserts the current tokens' inputs
11 +into their paged slot and convolves each token against the ``W`` taps ending
12 +at its absolute position, reading pre-forward window positions out of the
13 +paged cache via the block table.
14 +
15 +Per-forward metadata (``block_table`` / ``slot_mapping`` / ``seq_idx`` /
16 +``query_start``) is built once by ``InklingSconvMetadataBuilder`` and published under
17 +the owner's prefix in the forward context; the absolute ``positions`` are
18 +threaded in from the model. The insert + conv run in a single ``fused_sconv``
19 +launch (same path for prefill / decode / mixed / spec). All inputs are
20 +fixed-address persistent buffers and the grid is fixed, so decode can replay
21 +under a full CUDA graph.
22 +"""
23 +
24 +from __future__ import annotations
25 +
26 +import torch
27 +from torch import nn
28 +from torch.nn.parameter import Parameter
29 +
30 +from vllm.distributed import get_tensor_model_parallel_rank
31 +from vllm.forward_context import get_forward_context
32 +from vllm.model_executor.utils import set_weight_attrs
33 +
34 +from .ops import fused_sconv
35 +from .sconv_swa_attn import InklingConvState, InklingSconvMetadata
36 +
37 +
38 +class InklingShortConv(nn.Module):
39 + def __init__(
40 + self, dim: int, kernel_size: int, owner: InklingConvState, stream_idx: int
41 + ) -> None:
42 + super().__init__()
43 + self.dim = dim
44 + self.kernel_size = kernel_size
45 + self.owner = owner
46 + self.stream_idx = stream_idx
47 + self.tp_rank = get_tensor_model_parallel_rank()
48 +
49 + # Depthwise conv weight; checkpoint stores (dim, 1, W).
50 + self.weight = Parameter(torch.empty(dim, 1, kernel_size), requires_grad=False)
51 + set_weight_attrs(self.weight, {"weight_loader": self.weight_loader})
52 +
53 + def weight_loader(self, param: Parameter, loaded_weight: torch.Tensor) -> None:
54 + if loaded_weight.shape[0] != param.shape[0]:
55 + shard = param.shape[0]
56 + loaded_weight = loaded_weight.narrow(0, self.tp_rank * shard, shard)
57 + param.data.copy_(loaded_weight)
58 +
59 + def forward(self, x: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
60 + # x: (num_tokens, dim); positions: (num_tokens,) absolute positions.
61 + attn_metadata = get_forward_context().attn_metadata
62 + if not isinstance(attn_metadata, dict):
63 + # Memory-profiling / no metadata: identity (residual).
64 + return x
65 + m = attn_metadata.get(self.owner.prefix)
66 + if m is None:
67 + return x
68 + assert isinstance(m, InklingSconvMetadata)
69 + cache = self.owner.kv_cache
70 + if cache.numel() == 0:
71 + # Cache not yet bound (profiling before KV alloc): identity.
72 + return x
73 +
74 + off_s, ws = self.owner.stream_ranges[self.stream_idx]
75 + block_size = self.owner.block_size
76 + x = x.contiguous()
77 + weight = self.weight.squeeze(1) # (dim, W)
78 +
79 + return fused_sconv(
80 + x,
81 + weight,
82 + cache,
83 + positions,
84 + m.block_table,
85 + m.seq_idx,
86 + m.slot_mapping,
87 + m.query_start,
88 + off_s,
89 + ws,
90 + block_size,
91 + activation=None,
92 + use_residual=True,
93 + )
vllm/parser/engine/parser_engine.py
+2
@@ -110,6 +110,8 @@ class ParserEngine(Parser):
110
111 self._has_reasoning = (
112 "THINK_END" in parser_engine_config.token_id_terminals
113 + or "THINK_START" in parser_engine_config.terminals
114 + or "THINK_END" in parser_engine_config.terminals
115 or parser_engine_config.initial_state == ParserState.REASONING
116 )
117 self._reasoning_ended: bool = not self._has_reasoning
vllm/parser/engine/parser_engine_config.py
+1
@@ -28,6 +28,7 @@ from vllm.parser.engine.events import EventType
28 class ParserState(Enum):
29 CONTENT = auto()
30 REASONING = auto()
31 + MESSAGE_HEADER = auto()
32 TOOL_PREAMBLE = auto()
33 TOOL_NAME = auto()
34 TOOL_ARGS = auto()
vllm/parser/engine/registered_adapters.py
+6
@@ -12,6 +12,7 @@ from vllm.parser.deepseek_v32 import DeepSeekV32Parser
12 from vllm.parser.engine.adapters import make_adapters
13 from vllm.parser.gemma4 import Gemma4Parser
14 from vllm.parser.glm47_moe import Glm47MoeParser
15 +from vllm.parser.inkling import InklingParser
16 from vllm.parser.kimi_k2 import KimiK2Parser
17 from vllm.parser.minimax_m2 import MinimaxM2Parser
18 from vllm.parser.nemotron_v3 import NemotronV3Parser
@@ -62,3 +63,8 @@ from vllm.parser.seed_oss import SeedOssParser
63 KimiK2ParserReasoningAdapter,
64 KimiK2ParserToolAdapter,
65 ) = make_adapters(KimiK2Parser)
66 +
67 +(
68 + InklingParserReasoningAdapter,
69 + InklingParserToolAdapter,
70 +) = make_adapters(InklingParser)
vllm/parser/engine/streaming_parser_engine.py
+11
@@ -269,6 +269,8 @@ class StreamingParserEngine:
269 SemanticEvent(EventType.REASONING_END, tool_index=self.tool_index)
270 )
271 self.state = ParserState.CONTENT
272 + elif self.state == ParserState.MESSAGE_HEADER:
273 + self.state = ParserState.CONTENT
274
275 return events
276
@@ -314,6 +316,15 @@ class StreamingParserEngine:
316 return self._emit_for_state(value)
317
318 if self.skip_tool_parsing and terminal in self._tool_terminals:
319 + if self.state == ParserState.MESSAGE_HEADER:
320 + self.state = ParserState.CONTENT
321 + return [
322 + SemanticEvent(
323 + EventType.TEXT_CHUNK,
324 + value=value,
325 + tool_index=self.tool_index,
326 + )
327 + ]
328 if EventType.REASONING_END in transition.events:
329 self.state = ParserState.CONTENT
330 return [
vllm/parser/inkling.py new
+411
@@ -0,0 +1,411 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling parser: typed content blocks parsed by a single state machine.
4 +
5 +Inkling output format (every marker is a dedicated special token)::
6 +
7 + <|message_model|><|content_thinking|>...reasoning...<|end_message|>
8 + <|message_model|><|content_text|>...visible text...<|end_message|>
9 + <|message_model|><|content_invoke_tool_json|>
10 + {"name":"get_weather","args":{"city":"SF"}}<|end_message|>
11 +
12 +Blocks are self-describing and may repeat in any order; sampling may
13 +also end a block with the standalone ``<|content_model_end_sampling|>``
14 +token. The tool-call payload is a single JSON object whose ``name`` is
15 +extracted by the engine's name-from-args path and whose ``args`` object
16 +is carved out of the wrapper by :func:`_inkling_arg_converter`.
17 +
18 +Note the terminal *labels*: ``THINK_START``/``THINK_END`` are what the
19 +engine keys its reasoning plumbing on (``is_reasoning_end``,
20 +``count_reasoning_tokens``, initial-state seeding), so ``<|end_message|>``
21 +is labelled ``THINK_END`` here even though it ends every block kind —
22 +the transition table, not the label, carries the semantics.
23 +"""
24 +
25 +from __future__ import annotations
26 +
27 +import functools
28 +import json
29 +from collections.abc import Sequence
30 +from typing import TYPE_CHECKING
31 +
32 +from vllm.entrypoints.openai.engine.protocol import (
33 + ExtractedToolCallInformation,
34 +)
35 +from vllm.parser.engine.events import EventType
36 +from vllm.parser.engine.parser_engine import ParserEngine
37 +from vllm.parser.engine.parser_engine_config import (
38 + ParserEngineConfig,
39 + ParserState,
40 + Transition,
41 +)
42 +
43 +if TYPE_CHECKING:
44 + from vllm.tokenizers import TokenizerLike
45 + from vllm.tool_parsers.abstract_tool_parser import Tool
46 +
47 +MESSAGE_MODEL = "<|message_model|>"
48 +CONTENT_TEXT = "<|content_text|>"
49 +CONTENT_THINKING = "<|content_thinking|>"
50 +CONTENT_INVOKE_TOOL_JSON = "<|content_invoke_tool_json|>"
51 +CONTENT_INVOKE_TOOL_TEXT = "<|content_invoke_tool_text|>"
52 +CONTENT_TOOL_ERROR = "<|content_tool_error|>"
53 +CONTENT_MODEL_END_SAMPLING = "<|content_model_end_sampling|>"
54 +END_MESSAGE = "<|end_message|>"
55 +
56 +INKLING_SPECIAL_TOKENS = (
57 + MESSAGE_MODEL,
58 + CONTENT_TEXT,
59 + CONTENT_THINKING,
60 + CONTENT_INVOKE_TOOL_JSON,
61 + CONTENT_INVOKE_TOOL_TEXT,
62 + CONTENT_TOOL_ERROR,
63 + CONTENT_MODEL_END_SAMPLING,
64 + END_MESSAGE,
65 +)
66 +
67 +_WS = " \t\r\n"
68 +
69 +
70 +def _scan_json_value(raw: str, start: int) -> int | None:
71 + """Return the end index (exclusive) of the JSON object starting at
72 + ``raw[start]``, or ``None`` when the object is still unterminated."""
73 + depth = 0
74 + in_string = False
75 + escape = False
76 + for i in range(start, len(raw)):
77 + ch = raw[i]
78 + if escape:
79 + escape = False
80 + continue
81 + if in_string:
82 + if ch == "\\":
83 + escape = True
84 + elif ch == '"':
85 + in_string = False
86 + continue
87 + if ch == '"':
88 + in_string = True
89 + elif ch == "{":
90 + depth += 1
91 + elif ch == "}":
92 + depth -= 1
93 + if depth == 0:
94 + return i + 1
95 + return None
96 +
97 +
98 +def _args_value_span(raw: str) -> str | None:
99 + """Extract the raw text span of the top-level ``"args"`` value from a
100 + (possibly incomplete) ``{"name":...,"args":{...}}`` wrapper.
101 +
102 + Returns the verbatim substring (prefix-stable across growing input,
103 + which the engine's argument-delta diffing relies on), possibly an
104 + unterminated object prefix; ``None`` when the value has not started.
105 + Raises ``ValueError`` when the value is not a JSON object.
106 + """
107 + depth = 0
108 + in_string = False
109 + escape = False
110 + string_start = -1
111 + last_string: str | None = None
112 + for i, ch in enumerate(raw):
113 + if escape:
114 + escape = False
115 + continue
116 + if in_string:
117 + if ch == "\\":
118 + escape = True
119 + elif ch == '"':
120 + in_string = False
121 + if depth == 1:
122 + last_string = raw[string_start + 1 : i]
123 + continue
124 + if ch == '"':
125 + in_string = True
126 + string_start = i
127 + elif ch == ":" and depth == 1 and last_string == "args":
128 + value_start = i + 1
129 + while value_start < len(raw) and raw[value_start] in _WS:
130 + value_start += 1
131 + if value_start >= len(raw):
132 + return None
133 + if raw[value_start] != "{":
134 + raise ValueError("Inkling tool call args must be a JSON object")
135 + value_end = _scan_json_value(raw, value_start)
136 + if value_end is None:
137 + return raw[value_start:]
138 + return raw[value_start:value_end]
139 + elif ch in "{[":
140 + depth += 1
141 + elif ch in "}]":
142 + depth -= 1
143 + return None
144 +
145 +
146 +def _inkling_arg_converter(raw_args: str, partial: bool) -> str:
147 + """Carve the ``args`` object out of the tool-call JSON wrapper.
148 +
149 + Why a converter at all: the engine's ``tool_args_json`` machinery
150 + treats the *entire* TOOL_ARGS text as the tool arguments, but Inkling's
151 + payload is the ``{"name":...,"args":{...}}`` wrapper — without a
152 + converter, ``_compute_arg_delta`` streams the wrapper verbatim into
153 + the OpenAI ``arguments`` field (``converter is None -> raw delta``,
154 + unconditionally; ``stream_arg_deltas=False`` does not stop it).
155 +
156 + Why a hand-rolled scanner instead of (partial) ``json.loads`` +
157 + ``json.dumps``: the engine diffs successive converter outputs and
158 + requires each to extend the previous one (``startswith``); a
159 + violation silently drops argument deltas. Re-serialization changes
160 + whitespace and closes unterminated structures differently across
161 + ticks, so the only prefix-stable output is a verbatim substring of
162 + the input. The scanner is also string/escape-aware so an ``"args"``
163 + literal inside the name or a string value cannot mislead it, and it
164 + still recovers a partial span when EOS truncates the wrapper (where
165 + ``json.loads`` would fail).
166 + """
167 + span = _args_value_span(raw_args)
168 + if span is None:
169 + # No args value yet (streaming) or none at all (treat as empty).
170 + return "" if partial else "{}"
171 + return span
172 +
173 +
174 +@functools.cache
175 +def inkling_config() -> ParserEngineConfig:
176 + terminals = {
177 + "MSG_MODEL": MESSAGE_MODEL,
178 + "TEXT_START": CONTENT_TEXT,
179 + "THINK_START": CONTENT_THINKING,
180 + "THINK_END": END_MESSAGE,
181 + "END_SAMPLING": CONTENT_MODEL_END_SAMPLING,
182 + "TOOL_START": CONTENT_INVOKE_TOOL_JSON,
183 + "TOOL_TEXT": CONTENT_INVOKE_TOOL_TEXT,
184 + "TOOL_ERROR": CONTENT_TOOL_ERROR,
185 + }
186 + transitions: dict[tuple[ParserState, str], Transition] = {
187 + # ── Between blocks / inside a text block ──────────────────────
188 + (ParserState.CONTENT, "MSG_MODEL"): Transition(
189 + ParserState.MESSAGE_HEADER,
190 + (),
191 + ),
192 + (ParserState.CONTENT, "TEXT_START"): Transition(
193 + ParserState.CONTENT,
194 + (),
195 + ),
196 + (ParserState.CONTENT, "THINK_START"): Transition(
197 + ParserState.REASONING,
198 + (EventType.REASONING_START,),
199 + ),
200 + (ParserState.CONTENT, "TOOL_START"): Transition(
201 + ParserState.TOOL_ARGS,
202 + (EventType.TOOL_CALL_START,),
203 + ),
204 + # Raw / error tool blocks render as visible text.
205 + (ParserState.CONTENT, "TOOL_TEXT"): Transition(
206 + ParserState.CONTENT,
207 + (),
208 + ),
209 + (ParserState.CONTENT, "TOOL_ERROR"): Transition(
210 + ParserState.CONTENT,
211 + (),
212 + ),
213 + # The optional function name between the model-role and content-kind
214 + # markers is metadata, not visible assistant content.
215 + (ParserState.MESSAGE_HEADER, "MSG_MODEL"): Transition(
216 + ParserState.MESSAGE_HEADER,
217 + (),
218 + ),
219 + (ParserState.MESSAGE_HEADER, "TEXT_START"): Transition(
220 + ParserState.CONTENT,
221 + (),
222 + ),
223 + (ParserState.MESSAGE_HEADER, "THINK_START"): Transition(
224 + ParserState.REASONING,
225 + (EventType.REASONING_START,),
226 + ),
227 + (ParserState.MESSAGE_HEADER, "TOOL_START"): Transition(
228 + ParserState.TOOL_ARGS,
229 + (EventType.TOOL_CALL_START,),
230 + ),
231 + (ParserState.MESSAGE_HEADER, "TOOL_TEXT"): Transition(
232 + ParserState.CONTENT,
233 + (),
234 + ),
235 + (ParserState.MESSAGE_HEADER, "TOOL_ERROR"): Transition(
236 + ParserState.CONTENT,
237 + (),
238 + ),
239 + # ── Inside a thinking block ───────────────────────────────────
240 + (ParserState.REASONING, "THINK_START"): Transition(
241 + ParserState.REASONING,
242 + (),
243 + ),
244 + # Defensive: tool call opening while a thinking block is still
245 + # unclosed.
246 + (ParserState.REASONING, "TOOL_START"): Transition(
247 + ParserState.TOOL_ARGS,
248 + (EventType.REASONING_END, EventType.TOOL_CALL_START),
249 + ),
250 + }
251 + # Block-end terminals behave identically regardless of label. A
252 + # closed tool block returns to CONTENT (Inkling has no section wrapper;
253 + # blocks of any kind may follow), which also keeps the block-kind
254 + # and role tokens out of the engine's tool-terminal set so the
255 + # skip_tool_parsing reasoning pass still classifies reasoning.
256 + for end in ("THINK_END", "END_SAMPLING"):
257 + transitions[(ParserState.CONTENT, end)] = Transition(
258 + ParserState.CONTENT,
259 + (),
260 + )
261 + transitions[(ParserState.REASONING, end)] = Transition(
262 + ParserState.CONTENT,
263 + (EventType.REASONING_END,),
264 + )
265 + transitions[(ParserState.TOOL_ARGS, end)] = Transition(
266 + ParserState.CONTENT,
267 + (EventType.TOOL_CALL_END,),
268 + )
269 + transitions[(ParserState.MESSAGE_HEADER, end)] = Transition(
270 + ParserState.CONTENT,
271 + (),
272 + )
273 +
274 + return ParserEngineConfig(
275 + name="inkling",
276 + # Normal generation continues after a prompt-prefilled
277 + # `<|message_model|>`. Non-streaming parsing receives only the generated
278 + # suffix, so begin in the corresponding message-header state as well.
279 + initial_state=ParserState.MESSAGE_HEADER,
280 + terminals=terminals,
281 + # Inkling content-kind markers are the grammar. When the engine is
282 + # used through DelegatingParser, the reasoning pass can hand the tool
283 + # pass reconstructed text whose token-id slice no longer contains the
284 + # content-kind marker that starts the tool block, so keep Inkling on
285 + # the text grammar instead of token-id-only terminal matching.
286 + token_id_terminals={},
287 + transitions=transitions,
288 + arg_converter=_inkling_arg_converter,
289 + stream_arg_deltas=True,
290 + tool_args_json=True,
291 + strip_trailing_reasoning_whitespace=True,
292 + drop_whitespace_only_content_before_tools=True,
293 + strip_content_whitespace_with_tools=False,
294 + validate_tool_names=False,
295 + )
296 +
297 +
298 +class InklingParser(ParserEngine):
299 + CONFIG_NAME = "inkling"
300 +
301 + def __init__(
302 + self,
303 + tokenizer: TokenizerLike,
304 + tools: list[Tool] | None = None,
305 + **kwargs,
306 + ) -> None:
307 + kwargs.setdefault("parser_engine_config", inkling_config())
308 + super().__init__(tokenizer, tools, **kwargs)
309 +
310 + def adjust_initial_state_from_prompt(self, prompt_token_ids: Sequence[int]) -> None:
311 + """Seed the initial parsing state from the prompt tail.
312 +
313 + Mirrors the Rust parser's ``initialize()``: scanning the prompt
314 + backwards, the last relevant special token decides whether generation
315 + continues inside a thinking block, a text block, a model message
316 + header, or between blocks.
317 + """
318 + vocab = self.vocab
319 + thinking_id = vocab.get(CONTENT_THINKING)
320 + text_id = vocab.get(CONTENT_TEXT)
321 + model_id = vocab.get(MESSAGE_MODEL)
322 + special_ids = {vocab[text] for text in INKLING_SPECIAL_TOKENS if text in vocab}
323 + for token_id in reversed(prompt_token_ids):
324 + if token_id == thinking_id:
325 + self._engine.reset(initial_state=ParserState.REASONING)
326 + self._streaming_initialized = True
327 + return
328 + if token_id == text_id:
329 + self._engine.reset(initial_state=ParserState.CONTENT)
330 + self._streaming_initialized = True
331 + return
332 + if token_id == model_id:
333 + self._engine.reset(initial_state=ParserState.MESSAGE_HEADER)
334 + self._streaming_initialized = True
335 + return
336 + if token_id in special_ids:
337 + break
338 +
339 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
340 + vocab = self.vocab
341 + thinking_id = vocab.get(CONTENT_THINKING)
342 + text_id = vocab.get(CONTENT_TEXT)
343 + model_id = vocab.get(MESSAGE_MODEL)
344 + end_sampling_id = vocab.get(CONTENT_MODEL_END_SAMPLING)
345 + for token_id in reversed(input_ids):
346 + if token_id in (thinking_id, model_id):
347 + return False
348 + if token_id in (text_id, end_sampling_id):
349 + return True
350 + return False
351 +
352 + def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
353 + vocab = self.vocab
354 + thinking_id = vocab.get(CONTENT_THINKING)
355 + end_ids = {
356 + token_id
357 + for token_id in (
358 + vocab.get(END_MESSAGE),
359 + vocab.get(CONTENT_MODEL_END_SAMPLING),
360 + )
361 + if token_id is not None
362 + }
363 + in_reasoning = False
364 + count = 0
365 + for token_id in token_ids:
366 + if token_id == thinking_id:
367 + in_reasoning = True
368 + continue
369 + if token_id in end_ids:
370 + in_reasoning = False
371 + continue
372 + if in_reasoning:
373 + count += 1
374 + return count
375 +
376 + def _single_pass_parse(
377 + self,
378 + text: str,
379 + token_ids: Sequence[int],
380 + initial_state: ParserState | None = None,
381 + ) -> tuple[str | None, str | None, ExtractedToolCallInformation]:
382 + reasoning, content, tool_call_info = super()._single_pass_parse(
383 + text, token_ids, initial_state=initial_state
384 + )
385 + # The engine defers content that follows tool-call events within a
386 + # single pass; Inkling allows text blocks after tool-call blocks, so
387 + # flush the trailing text (matching the Rust unified parser).
388 + if self._deferred_content:
389 + trailing = self._deferred_content
390 + self._deferred_content = ""
391 + content = self._strip_content_whitespace(
392 + (content or "") + trailing,
393 + tool_call_info.tools_called,
394 + )
395 + tool_call_info = ExtractedToolCallInformation(
396 + tools_called=tool_call_info.tools_called,
397 + tool_calls=tool_call_info.tool_calls,
398 + content=content,
399 + )
400 + return reasoning, content, tool_call_info
401 +
402 + @staticmethod
403 + def _extract_args_value(parsed: dict) -> str | None:
404 + # Inkling wraps arguments under "args" rather than "arguments".
405 + for key in ("args", "arguments", "parameters"):
406 + if key in parsed:
407 + val = parsed[key]
408 + if isinstance(val, str):
409 + return val
410 + return json.dumps(val, ensure_ascii=False)
411 + return None
vllm/reasoning/__init__.py
+4
@@ -128,6 +128,10 @@ _REASONING_PARSERS_TO_REGISTER = {
128 "step3p5_reasoning_parser",
129 "Step3p5ReasoningParser",
130 ),
131 + "inkling": (
132 + "inkling_reasoning_parser",
133 + "InklingParserReasoningAdapter",
134 + ),
135 }
136
137
vllm/reasoning/inkling_reasoning_parser.py new
+6
@@ -0,0 +1,6 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import InklingParserReasoningAdapter
5 +
6 +__all__ = ["InklingParserReasoningAdapter"]
vllm/renderers/inkling.py new
+178
@@ -0,0 +1,178 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Native Inkling chat renderer for the Python frontend.
4 +
5 +Mirrors the Rust frontend's native Inkling renderer
6 +(``rust/src/chat/src/renderer/inkling/mod.rs``): chat messages are rendered
7 +directly to token ids — Inkling has no Jinja chat template and no faithful
8 +text form.
9 +
10 +The encoding logic lives in ``inkling_encoding.py`` behind a narrow
11 +"OpenAI messages + tools -> token ids" call; see the swap-point comment
12 +in :meth:`InklingRenderer._render` for adopting a standalone Inkling
13 +input-processing library (mistral-common style) later.
14 +"""
15 +
16 +from vllm.config import VllmConfig
17 +from vllm.entrypoints.chat_utils import (
18 + ChatCompletionMessageParam,
19 + ConversationMessage,
20 + parse_chat_messages,
21 + parse_chat_messages_async,
22 +)
23 +from vllm.logger import init_logger
24 +from vllm.tokenizers.hf import HfTokenizer
25 +from vllm.utils.async_utils import make_async
26 +
27 +from .base import BaseRenderer
28 +from .inkling_encoding import SPECIAL_TOKEN_SPELLINGS, render_inkling_messages
29 +from .inputs import DictPrompt
30 +from .inputs.preprocess import parse_dec_only_prompt
31 +from .params import ChatParams
32 +
33 +logger = init_logger(__name__)
34 +
35 +_NAMED_REASONING_EFFORT = {
36 + "none": 0.0,
37 + "minimal": 0.1,
38 + "low": 0.2,
39 + "medium": 0.7,
40 + "high": 0.9,
41 + "xhigh": 0.99,
42 + "max": 0.99,
43 +}
44 +
45 +_DEFAULT_REASONING_EFFORT = 0.9
46 +
47 +
48 +def _resolve_reasoning_effort(value: object) -> float | int | None:
49 + if value is None:
50 + return _DEFAULT_REASONING_EFFORT
51 + if isinstance(value, str):
52 + return _NAMED_REASONING_EFFORT.get(value)
53 + if isinstance(value, bool) or not isinstance(value, (int, float)):
54 + return None
55 + return value
56 +
57 +
58 +class _HfBackedTmlTokenizer:
59 + """Adapts an HF tokenizer to the encoding core's tokenizer protocol.
60 +
61 + Special-token ids are resolved from the tokenizer vocab at
62 + construction (never hardcoded), trying each known spelling — the Inkling
63 + HF vocab exposes some semantic slots as ``<|unused_NNNNNN|>`` tokens.
64 + """
65 +
66 + def __init__(self, tokenizer: HfTokenizer) -> None:
67 + self._tokenizer = tokenizer
68 +
69 + vocab = tokenizer.get_vocab()
70 + special_ids: dict[str, int] = {}
71 + missing: list[str] = []
72 + for token, spellings in SPECIAL_TOKEN_SPELLINGS.items():
73 + for spelling in spellings:
74 + token_id = vocab.get(spelling)
75 + if token_id is not None:
76 + special_ids[token] = token_id
77 + break
78 + else:
79 + missing.append(token)
80 + if missing:
81 + raise ValueError(f"Inkling tokenizer is missing special tokens: {missing}")
82 + self._special_ids = special_ids
83 +
84 + def encode_text(self, text: str) -> list[int]:
85 + return self._tokenizer.encode(text, add_special_tokens=False)
86 +
87 + def encode_special(self, token: str) -> int:
88 + return self._special_ids[token]
89 +
90 +
91 +class InklingRenderer(BaseRenderer[HfTokenizer]):
92 + def __init__(
93 + self,
94 + config: VllmConfig,
95 + tokenizer: HfTokenizer | None,
96 + ) -> None:
97 + super().__init__(config, tokenizer)
98 +
99 + self._inkling_tokenizer = _HfBackedTmlTokenizer(self.get_tokenizer())
100 + self._render_async = make_async(self._render, executor=self._executor)
101 +
102 + def _render(
103 + self,
104 + messages: list[ChatCompletionMessageParam],
105 + params: ChatParams,
106 + ) -> list[int]:
107 + kwargs = params.chat_template_kwargs or {}
108 +
109 + if kwargs.get("continue_final_message"):
110 + raise ValueError("Inkling renderer does not support continue_final_message")
111 +
112 + reasoning_effort = _resolve_reasoning_effort(kwargs.get("reasoning_effort"))
113 +
114 + try:
115 + # Swap point: to adopt a standalone Inkling input-processing
116 + # library, replace this call (and the _HfBackedTmlTokenizer
117 + # adapter above) with the library's renderer.
118 + return render_inkling_messages(
119 + messages,
120 + self._inkling_tokenizer,
121 + add_generation_prompt=kwargs.get("add_generation_prompt", True),
122 + tools=kwargs.get("tools"),
123 + reasoning_effort=reasoning_effort,
124 + )
125 + except ValueError:
126 + raise
127 + except (TypeError, KeyError) as e:
128 + # Malformed request content; surface as a request error.
129 + raise ValueError(str(e)) from e
130 + except Exception as e:
131 + logger.exception("Error while rendering Inkling chat messages")
132 + raise ValueError(str(e)) from e
133 +
134 + def render_messages(
135 + self,
136 + messages: list[ChatCompletionMessageParam],
137 + params: ChatParams,
138 + ) -> tuple[list[ConversationMessage], DictPrompt]:
139 + conversation, mm_data, mm_uuids = parse_chat_messages(
140 + messages,
141 + self.model_config,
142 + content_format="string",
143 + media_io_kwargs=params.media_io_kwargs,
144 + mm_processor_kwargs=params.mm_processor_kwargs,
145 + )
146 +
147 + token_ids = self._render(messages, params)
148 +
149 + prompt = parse_dec_only_prompt(token_ids)
150 + if mm_data is not None:
151 + prompt["multi_modal_data"] = mm_data
152 + if mm_uuids is not None:
153 + prompt["multi_modal_uuids"] = mm_uuids
154 +
155 + return conversation, prompt
156 +
157 + async def render_messages_async(
158 + self,
159 + messages: list[ChatCompletionMessageParam],
160 + params: ChatParams,
161 + ) -> tuple[list[ConversationMessage], DictPrompt]:
162 + conversation, mm_data, mm_uuids = await parse_chat_messages_async(
163 + messages,
164 + self.model_config,
165 + content_format="string",
166 + media_io_kwargs=params.media_io_kwargs,
167 + mm_processor_kwargs=params.mm_processor_kwargs,
168 + )
169 +
170 + token_ids = await self._render_async(messages, params)
171 +
172 + prompt = parse_dec_only_prompt(token_ids)
173 + if mm_data is not None:
174 + prompt["multi_modal_data"] = mm_data
175 + if mm_uuids is not None:
176 + prompt["multi_modal_uuids"] = mm_uuids
177 +
178 + return conversation, prompt
vllm/renderers/inkling_encoding.py new
+383
@@ -0,0 +1,383 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inkling chat-encoding core.
4 +
5 +Pure implementation of Inkling chat rendering, kept deliberately free of
6 +vLLM imports: it depends only on the :class:`InklingTextTokenizer` protocol
7 +and speaks OpenAI-style message dicts. If a standalone Inkling
8 +input-processing library becomes available, this module is the unit to
9 +swap out — the swap point is marked in ``vllm/renderers/inkling.py``.
10 +
11 +Multimodal parts follow the contract of vLLM's Inkling multimodal processor
12 +(``InklingMultiModalProcessor`` anchors prompt updates on the bare
13 +content-kind marker and inserts the per-patch placeholder run itself):
14 +
15 +* image parts emit only ``<|content_image|>`` — no seed placeholder id;
16 +* audio parts emit ``<|content_audio_input|><|audio_end|>`` — no seed
17 + placeholder id between them.
18 +
19 +``reasoning_effort`` (a float in [0, 0.99], sourced from
20 +``chat_template_kwargs`` only) renders the ``Thinking effort level:``
21 +system control block after the tool declarations and initial system messages.
22 +"""
23 +
24 +from __future__ import annotations
25 +
26 +import json
27 +from collections.abc import Iterator, Mapping, Sequence
28 +from typing import Any, Protocol
29 +
30 +END_OF_TEXT = "<|endoftext|>"
31 +MESSAGE_USER = "<|message_user|>"
32 +MESSAGE_MODEL = "<|message_model|>"
33 +MESSAGE_SYSTEM = "<|message_system|>"
34 +MESSAGE_TOOL = "<|message_tool|>"
35 +CONTENT_TEXT = "<|content_text|>"
36 +CONTENT_IMAGE = "<|content_image|>"
37 +CONTENT_MODEL_END_SAMPLING = "<|content_model_end_sampling|>"
38 +CONTENT_THINKING = "<|content_thinking|>"
39 +CONTENT_AUDIO_INPUT = "<|content_audio_input|>"
40 +CONTENT_TOOL_ERROR = "<|content_tool_error|>"
41 +CONTENT_XML = "<|content_xml|>"
42 +CONTENT_INVOKE_TOOL_JSON = "<|content_invoke_tool_json|>"
43 +CONTENT_INVOKE_TOOL_TEXT = "<|content_invoke_tool_text|>"
44 +END_MESSAGE = "<|end_message|>"
45 +AUDIO_END = "<|audio_end|>"
46 +
47 +ROLE_MESSAGE_TOKENS: dict[str, str] = {
48 + "user": MESSAGE_USER,
49 + "assistant": MESSAGE_MODEL,
50 + "system": MESSAGE_SYSTEM,
51 + # The developer role folds into the system role on the Inkling wire.
52 + "developer": MESSAGE_SYSTEM,
53 + "tool": MESSAGE_TOOL,
54 +}
55 +
56 +# Alternate vocab spellings per semantic token. The Inkling HF tokenizer
57 +# exposes some semantic slots as ``<|unused_NNNNNN|>`` tokens (notably
58 +# CONTENT_XML = <|unused_200024|>), so ID resolution must try these
59 +# spellings in order.
60 +SPECIAL_TOKEN_SPELLINGS: dict[str, tuple[str, ...]] = {
61 + MESSAGE_USER: (MESSAGE_USER,),
62 + MESSAGE_MODEL: (MESSAGE_MODEL,),
63 + MESSAGE_SYSTEM: (MESSAGE_SYSTEM,),
64 + MESSAGE_TOOL: (MESSAGE_TOOL,),
65 + CONTENT_TEXT: (CONTENT_TEXT,),
66 + CONTENT_IMAGE: (CONTENT_IMAGE,),
67 + CONTENT_MODEL_END_SAMPLING: (CONTENT_MODEL_END_SAMPLING,),
68 + CONTENT_THINKING: (CONTENT_THINKING,),
69 + CONTENT_AUDIO_INPUT: (CONTENT_AUDIO_INPUT,),
70 + CONTENT_XML: (CONTENT_XML, "<|unused_200024|>"),
71 + CONTENT_INVOKE_TOOL_JSON: (CONTENT_INVOKE_TOOL_JSON,),
72 + END_MESSAGE: (END_MESSAGE,),
73 + AUDIO_END: (AUDIO_END,),
74 +}
75 +
76 +
77 +class InklingTextTokenizer(Protocol):
78 + """Structural tokenizer contract required by the renderer."""
79 +
80 + def encode_text(self, text: str) -> list[int]: ...
81 +
82 + def encode_special(self, token: str) -> int: ...
83 +
84 +
85 +# OpenAI content-part type spellings that mean image / audio (rendering only
86 +# needs the kind, not the bytes — the bytes are handled by the MM processor).
87 +_IMAGE_PART_TYPES = frozenset({"image", "input_image", "image_url"})
88 +_AUDIO_PART_TYPES = frozenset({"audio", "input_audio", "audio_url"})
89 +
90 +_MAX_REASONING_EFFORT = 0.99
91 +
92 +
93 +def render_inkling_messages(
94 + messages: Sequence[Mapping[str, Any]],
95 + tokenizer: InklingTextTokenizer,
96 + *,
97 + add_generation_prompt: bool = True,
98 + tools: Sequence[Mapping[str, Any]] | None = None,
99 + reasoning_effort: float | None = None,
100 +) -> list[int]:
101 + """Render chat messages to Inkling input ids.
102 +
103 + PURE renderer: emits Inkling framing plus bare media markers; media
104 + encoding and placeholder expansion happen later in the MM processor.
105 + ``add_generation_prompt`` appends the assistant turn opener so the
106 + model continues into the response.
107 + """
108 + input_ids: list[int] = []
109 + tool_call_id_to_name: dict[str, str] = {}
110 +
111 + # Request-level tools plus per-developer-message tools (Rust renderer
112 + # semantics) are declared in a single leading system block.
113 + all_tools = list(tools or [])
114 + for message in messages:
115 + if message.get("role") == "developer":
116 + all_tools.extend(message.get("tools") or [])
117 +
118 + if all_tools:
119 + _append_message(
120 + input_ids,
121 + tokenizer,
122 + "system",
123 + "xml",
124 + _tool_declare_json(all_tools),
125 + author_name="tool_declare",
126 + )
127 +
128 + for message in messages:
129 + role = _expect_role(message)
130 + if role not in {"system", "developer"} and reasoning_effort is not None:
131 + _append_reasoning_effort(input_ids, tokenizer, reasoning_effort)
132 + reasoning_effort = None
133 +
134 + if role == "tool":
135 + tool_name = message.get("name") or tool_call_id_to_name.get(
136 + str(message.get("tool_call_id") or ""), ""
137 + )
138 + _append_message(
139 + input_ids,
140 + tokenizer,
141 + "tool",
142 + "text",
143 + _flatten_text_content(message.get("content")),
144 + author_name=str(tool_name),
145 + )
146 + continue
147 +
148 + if role == "assistant":
149 + reasoning_content = message.get("reasoning")
150 + if reasoning_content is None:
151 + reasoning_content = message.get("reasoning_content")
152 + if reasoning_content:
153 + if not isinstance(reasoning_content, str):
154 + raise TypeError(
155 + "assistant reasoning_content must be a string for "
156 + "Inkling rendering"
157 + )
158 + _append_message(
159 + input_ids,
160 + tokenizer,
161 + "assistant",
162 + "thinking",
163 + reasoning_content,
164 + )
165 +
166 + for kind, text in _iter_render_parts(message.get("content")):
167 + _append_message(input_ids, tokenizer, role, kind, text)
168 +
169 + if role == "assistant":
170 + for tool_call in message.get("tool_calls") or []:
171 + name, args = _tool_call_name_and_args(tool_call)
172 + tool_call_id = _as_mapping(tool_call).get("id")
173 + if tool_call_id:
174 + tool_call_id_to_name[str(tool_call_id)] = name
175 + _append_message(
176 + input_ids,
177 + tokenizer,
178 + "assistant",
179 + "invoke_tool_json",
180 + _tool_call_json(name, args),
181 + author_name=name,
182 + )
183 +
184 + input_ids.append(tokenizer.encode_special(CONTENT_MODEL_END_SAMPLING))
185 +
186 + if reasoning_effort is not None:
187 + _append_reasoning_effort(input_ids, tokenizer, reasoning_effort)
188 +
189 + if add_generation_prompt:
190 + input_ids.append(tokenizer.encode_special(MESSAGE_MODEL))
191 + return input_ids
192 +
193 +
194 +def _append_reasoning_effort(
195 + input_ids: list[int],
196 + tokenizer: InklingTextTokenizer,
197 + reasoning_effort: float,
198 +) -> None:
199 + _append_message(
200 + input_ids,
201 + tokenizer,
202 + "system",
203 + "text",
204 + _thinking_effort_text(reasoning_effort),
205 + )
206 +
207 +
208 +def _thinking_effort_text(reasoning_effort: Any) -> str:
209 + if isinstance(reasoning_effort, bool) or not isinstance(
210 + reasoning_effort, (int, float)
211 + ):
212 + raise TypeError(
213 + "Inkling reasoning_effort must be a number in [0.0, 0.99], "
214 + f"got {type(reasoning_effort).__name__}"
215 + )
216 + value = float(reasoning_effort)
217 + if not 0.0 <= value <= _MAX_REASONING_EFFORT:
218 + raise ValueError(
219 + f"Inkling reasoning_effort must be in [0.0, 0.99], got {value}"
220 + )
221 +
222 + effort_text = f"{value:.2f}".rstrip("0").rstrip(".")
223 + if effort_text in {"0", "-0"}:
224 + effort_text = "0.0"
225 + return f"Thinking effort level: {effort_text}"
226 +
227 +
228 +def _append_message(
229 + input_ids: list[int],
230 + tokenizer: InklingTextTokenizer,
231 + role: str,
232 + kind: str,
233 + text: str,
234 + *,
235 + author_name: str | None = None,
236 +) -> None:
237 + input_ids.append(tokenizer.encode_special(ROLE_MESSAGE_TOKENS[role]))
238 + if author_name:
239 + input_ids.extend(tokenizer.encode_text(author_name))
240 +
241 + if kind == "text":
242 + input_ids.append(tokenizer.encode_special(CONTENT_TEXT))
243 + input_ids.extend(tokenizer.encode_text(text))
244 + elif kind == "image":
245 + # Bare marker only: the MM processor replaces the marker with
246 + # `marker + placeholder * num_patches` (see mm_preprocess.py).
247 + input_ids.append(tokenizer.encode_special(CONTENT_IMAGE))
248 + elif kind == "audio":
249 + input_ids.append(tokenizer.encode_special(CONTENT_AUDIO_INPUT))
250 + input_ids.append(tokenizer.encode_special(AUDIO_END))
251 + elif kind == "thinking":
252 + input_ids.append(tokenizer.encode_special(CONTENT_THINKING))
253 + input_ids.extend(tokenizer.encode_text(text))
254 + elif kind == "xml":
255 + input_ids.append(tokenizer.encode_special(CONTENT_XML))
256 + input_ids.extend(tokenizer.encode_text(text))
257 + elif kind == "invoke_tool_json":
258 + input_ids.append(tokenizer.encode_special(CONTENT_INVOKE_TOOL_JSON))
259 + input_ids.extend(tokenizer.encode_text(text))
260 + else:
261 + raise ValueError(f"unsupported Inkling render part kind: {kind!r}")
262 +
263 + input_ids.append(tokenizer.encode_special(END_MESSAGE))
264 +
265 +
266 +def _iter_render_parts(content: Any) -> Iterator[tuple[str, str]]:
267 + """Yield (kind, text) per content part: kind in {text, image, audio}."""
268 + if content is None:
269 + return
270 + if isinstance(content, str):
271 + if content:
272 + yield ("text", content)
273 + return
274 + if not isinstance(content, Sequence) or isinstance(content, (bytes, bytearray)):
275 + raise TypeError("message content must be a string or a sequence of parts")
276 + for part in content:
277 + if isinstance(part, str):
278 + yield ("text", part)
279 + continue
280 + if not isinstance(part, Mapping):
281 + raise TypeError(f"content part must be mapping, got {type(part).__name__}")
282 + ptype = part.get("type")
283 + if ptype in (None, "text", "input_text"):
284 + text = part.get("text", "")
285 + yield ("text", text if isinstance(text, str) else "")
286 + elif ptype in _IMAGE_PART_TYPES:
287 + yield ("image", "")
288 + elif ptype in _AUDIO_PART_TYPES:
289 + yield ("audio", "")
290 + else:
291 + raise ValueError(f"unsupported content part type: {ptype!r}")
292 +
293 +
294 +def _flatten_text_content(content: Any) -> str:
295 + """Flatten a tool-response content (string or text parts) to text."""
296 + if content is None:
297 + return ""
298 + if isinstance(content, str):
299 + return content
300 + parts: list[str] = []
301 + for kind, text in _iter_render_parts(content):
302 + if kind != "text":
303 + raise ValueError(
304 + "Inkling tool response content must be text, "
305 + f"got a part of kind {kind!r}"
306 + )
307 + parts.append(text)
308 + return "".join(parts)
309 +
310 +
311 +def _expect_role(message: Mapping[str, Any]) -> str:
312 + role = message.get("role")
313 + if role not in ROLE_MESSAGE_TOKENS:
314 + raise ValueError(
315 + f"unsupported Inkling message role {role!r}; "
316 + f"expected one of {sorted(ROLE_MESSAGE_TOKENS)}"
317 + )
318 + return str(role)
319 +
320 +
321 +def _as_mapping(value: Any) -> Mapping[str, Any]:
322 + if isinstance(value, Mapping):
323 + return value
324 + if hasattr(value, "model_dump"):
325 + dumped = value.model_dump()
326 + if isinstance(dumped, Mapping):
327 + return dumped
328 + raise TypeError(f"expected mapping, got {type(value).__name__}")
329 +
330 +
331 +def _canonical_json(value: Any) -> str:
332 + return json.dumps(
333 + _sort_json(value),
334 + ensure_ascii=False,
335 + allow_nan=False,
336 + separators=(",", ":"),
337 + )
338 +
339 +
340 +def _sort_json(value: Any) -> Any:
341 + if isinstance(value, Mapping):
342 + return {str(key): _sort_json(value[key]) for key in sorted(value)}
343 + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
344 + return [_sort_json(item) for item in value]
345 + return value
346 +
347 +
348 +def _tool_declare_json(tools: Sequence[Mapping[str, Any]]) -> str:
349 + tool_specs = []
350 + for tool_value in tools:
351 + tool = _as_mapping(tool_value)
352 + function = _as_mapping(tool.get("function", {}))
353 + tool_specs.append(
354 + {
355 + "description": function.get("description") or "",
356 + "name": function["name"],
357 + "parameters": function.get("parameters") or {},
358 + "type": tool.get("type", "function"),
359 + }
360 + )
361 + return _canonical_json(tool_specs)
362 +
363 +
364 +def _tool_call_name_and_args(tool_call_value: Any) -> tuple[str, Mapping[str, Any]]:
365 + tool_call = _as_mapping(tool_call_value)
366 + function = _as_mapping(tool_call.get("function", {}))
367 + name = function.get("name")
368 + if not isinstance(name, str):
369 + raise TypeError("tool call function name must be a string")
370 +
371 + raw_args = function.get("arguments") or {}
372 + if isinstance(raw_args, str):
373 + args = json.loads(raw_args) if raw_args.strip() else {}
374 + else:
375 + args = raw_args
376 + if not isinstance(args, Mapping):
377 + raise TypeError("tool call function arguments must decode to an object")
378 + return name, args
379 +
380 +
381 +def _tool_call_json(name: str, args: Mapping[str, Any]) -> str:
382 + name_json = json.dumps(name, ensure_ascii=False, allow_nan=False)
383 + return f'{{"name":{name_json},"args":{_canonical_json(args)}}}'
vllm/renderers/registry.py
+1
@@ -26,6 +26,7 @@ _VLLM_RENDERERS = {
26 "kimi_audio": ("hf", "HfRenderer"),
27 "mistral": ("mistral", "MistralRenderer"),
28 "terratorch": ("terratorch", "TerratorchRenderer"),
29 + "inkling": ("inkling", "InklingRenderer"),
30 }
31
32
vllm/tokenizers/registry.py
+4
@@ -44,6 +44,10 @@ _VLLM_TOKENIZERS = {
44 "hf": ("hf", "CachedHfTokenizer"),
45 "kimi_audio": ("kimi_audio", "KimiAudioTokenizer"),
46 "mistral": ("mistral", "MistralTokenizer"),
47 + # Inkling uses the plain HF tokenizer for token operations; the "inkling"
48 + # mode exists to select the InklingRenderer, which renders chat to
49 + # token ids natively (Inkling has no Jinja chat template).
50 + "inkling": ("hf", "CachedHfTokenizer"),
51 }
52
53
vllm/tool_parsers/__init__.py
+4
@@ -174,6 +174,10 @@ _TOOL_PARSERS_TO_REGISTER = {
174 "step3p5_tool_parser",
175 "Step3p5ToolParser",
176 ),
177 + "inkling": (
178 + "inkling_tool_parser",
179 + "InklingEngineToolParser",
180 + ),
181 "xlam": (
182 "xlam_tool_parser",
183 "xLAMToolParser",
vllm/tool_parsers/inkling_tool_parser.py new
+11
@@ -0,0 +1,11 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from vllm.parser.engine.registered_adapters import InklingParserToolAdapter
5 +
6 +
7 +class InklingEngineToolParser(InklingParserToolAdapter): # type: ignore[valid-type, misc]
8 + # No Inkling structural-tag grammar is wired up yet; fall back to auto
9 + # parsing for named/required tool choice.
10 + structural_tag_model = None
11 + supports_required_and_named = False
vllm/transformers_utils/config.py
+2
@@ -127,6 +127,8 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
127 laguna="LagunaConfig",
128 lfm2_moe="Lfm2MoeConfig",
129 **{"unlimited-ocr": "UnlimitedOCRConfig"},
130 + inkling_mm_model="InklingMMConfig",
131 + inkling_model="InklingModelConfig",
132 )
133
134 _SPECULATIVE_DECODING_CONFIGS: set[str] = {"eagle", "speculators", "medusa"}
vllm/transformers_utils/configs/__init__.py
+8
@@ -94,6 +94,10 @@ _CLASS_TO_MODULE: dict[str, str] = {
94 "Qwen3_5TextConfig": "vllm.transformers_utils.configs.qwen3_5",
95 "Qwen3_5MoeConfig": "vllm.transformers_utils.configs.qwen3_5_moe",
96 "Qwen3_5MoeTextConfig": "vllm.transformers_utils.configs.qwen3_5_moe",
97 + "InklingModelConfig": "vllm.models.inkling.configs",
98 + "InklingAudioConfig": "vllm.models.inkling.configs",
99 + "InklingVisionConfig": "vllm.models.inkling.configs",
100 + "InklingMMConfig": "vllm.models.inkling.configs",
101 # Special case: DeepseekV3Config is from HuggingFace Transformers
102 "DeepseekV3Config": "transformers",
103 }
@@ -174,6 +178,10 @@ __all__ = [
178 "Qwen3_5TextConfig",
179 "Qwen3_5MoeConfig",
180 "Qwen3_5MoeTextConfig",
181 + "InklingModelConfig",
182 + "InklingAudioConfig",
183 + "InklingVisionConfig",
184 + "InklingMMConfig",
185 ]
186
187
vllm/transformers_utils/processors/__init__.py
+6
@@ -44,6 +44,9 @@ __all__ = [
44 "Ovis2_5Processor",
45 "Qwen3ASRProcessor",
46 "Step3VLProcessor",
47 + "InklingProcessor",
48 + "InklingImageProcessor",
49 + "InklingAudioFeatureExtractor",
50 ]
51
52 _CLASS_TO_MODULE: dict[str, str] = {
@@ -80,6 +83,9 @@ _CLASS_TO_MODULE: dict[str, str] = {
83 "Ovis2_5Processor": "vllm.transformers_utils.processors.ovis2_5",
84 "Qwen3ASRProcessor": "vllm.transformers_utils.processors.qwen3_asr",
85 "Step3VLProcessor": "vllm.transformers_utils.processors.step3_vl",
86 + "InklingProcessor": "vllm.transformers_utils.processors.inkling",
87 + "InklingImageProcessor": "vllm.transformers_utils.processors.inkling",
88 + "InklingAudioFeatureExtractor": "vllm.transformers_utils.processors.inkling",
89 }
90
91
vllm/transformers_utils/processors/inkling.py new
+504
@@ -0,0 +1,504 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Vendored HuggingFace-convention processors for the Inkling Titan model.
4 +
5 +Implements the image processor (numba patchifier), the audio feature extractor
6 +(STFT/dMel path), the composite processor, and the MM token-id constants.
7 +
8 +Besides raw bytes / file paths, the extractors also accept the dummy inputs
9 +vLLM generates during profiling (PIL images / numpy audio arrays).
10 +"""
11 +
12 +from __future__ import annotations
13 +
14 +import io
15 +import math
16 +from collections.abc import Sequence
17 +from dataclasses import dataclass
18 +
19 +import numpy as np
20 +import torch
21 +import torch.nn.functional as F
22 +from numba import njit
23 +from transformers.feature_extraction_utils import (
24 + BatchFeature,
25 + FeatureExtractionMixin,
26 +)
27 +from transformers.image_processing_utils import BaseImageProcessor
28 +from transformers.image_utils import ImageInput
29 +
30 +# ---------------------------------------------------------------------------
31 +# MM token-id constants
32 +# ---------------------------------------------------------------------------
33 +
34 +# Block-start marker token ids. These are real tokens the model was trained on
35 +# (``<|content_image|>`` / ``<|content_audio_input|>``) that mark the start of an
36 +# image/audio embedding block; they are kept verbatim in ``input_ids``.
37 +IMAGE_MARKER_ID = 200005 # <|content_image|>
38 +AUDIO_MARKER_ID = 200020 # <|content_audio_input|>
39 +
40 +# Per-patch / per-frame placeholder ids marking where tower embeddings are
41 +# scattered in. These are unused slots in the padded vocabulary and the
42 +# corresponding positions are always overwritten by tower embeddings.
43 +IMAGE_TOKEN_ID = 200054 # <|unused_200054|>
44 +AUDIO_TOKEN_ID = 200053 # <|unused_200053|>
45 +
46 +# ---------------------------------------------------------------------------
47 +# Image processing
48 +# ---------------------------------------------------------------------------
49 +
50 +IMAGE_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
51 +IMAGE_STD = np.array([0.26862954, 0.2613026, 0.2757771], dtype=np.float32)
52 +PAD_RAW_VALUE = np.float32(-1.0 / 255.0)
53 +PAD_NORM = (np.full((3,), PAD_RAW_VALUE, dtype=np.float32) - IMAGE_MEAN) / IMAGE_STD
54 +
55 +
56 +def _validate_image_rescale(
57 + rescale_image_frac: float | None,
58 + rescale_image_max_upscaled_long_edge: int | None,
59 +) -> None:
60 + if rescale_image_frac is not None and (
61 + not math.isfinite(rescale_image_frac) or rescale_image_frac <= 0
62 + ):
63 + raise ValueError(
64 + "rescale_image_frac must be positive and finite or None, "
65 + f"got {rescale_image_frac}"
66 + )
67 + if rescale_image_max_upscaled_long_edge is None:
68 + return
69 + if rescale_image_max_upscaled_long_edge <= 0:
70 + raise ValueError(
71 + "rescale_image_max_upscaled_long_edge must be positive or None, "
72 + f"got {rescale_image_max_upscaled_long_edge}"
73 + )
74 + if rescale_image_frac is None or rescale_image_frac <= 1.0:
75 + raise ValueError(
76 + "rescale_image_max_upscaled_long_edge requires rescale_image_frac > 1, "
77 + f"got {rescale_image_frac}"
78 + )
79 +
80 +
81 +def _scaled_image_dimensions(
82 + width: int,
83 + height: int,
84 + rescale_image_frac: float | None,
85 + rescale_image_max_upscaled_long_edge: int | None,
86 +) -> tuple[int, int]:
87 + """Return the long-edge-scaled ``(width, height)``."""
88 + if rescale_image_frac is None:
89 + return width, height
90 +
91 + long_edge = max(width, height)
92 + if long_edge == 0:
93 + return width, height
94 +
95 + target_long_edge = float(long_edge) * rescale_image_frac
96 + if rescale_image_max_upscaled_long_edge is not None:
97 + effective_cap = max(rescale_image_max_upscaled_long_edge, long_edge)
98 + target_long_edge = min(target_long_edge, float(effective_cap))
99 +
100 + ratio = target_long_edge / float(long_edge)
101 + if ratio == 1.0:
102 + return width, height
103 +
104 + def scale(value: int) -> int:
105 + return max(1, math.floor(float(value) * ratio + 0.5))
106 +
107 + return scale(width), scale(height)
108 +
109 +
110 +def _load_image_bytes(image) -> bytes:
111 + """Encode a PIL image as raw PNG bytes for preprocessing.
112 +
113 + The HF processor is always handed ``PIL.Image`` instances by vLLM, so no
114 + other input types need to be supported here.
115 + """
116 + if image.mode != "RGB":
117 + image = image.convert("RGB")
118 + buf = io.BytesIO()
119 + image.save(buf, format="PNG")
120 + return buf.getvalue()
121 +
122 +
123 +@njit(cache=True)
124 +def _fill_patches_numba(
125 + arr: np.ndarray,
126 + patch_size: int,
127 + patches: np.ndarray,
128 + mean: np.ndarray,
129 + std: np.ndarray,
130 + pad_norm: np.ndarray,
131 +) -> None:
132 + h = arr.shape[0]
133 + w = arr.shape[1]
134 + nph = (h + patch_size - 1) // patch_size
135 + npw = w // patch_size + 1
136 + inv255 = np.float32(1.0 / 255.0)
137 +
138 + for k in range(nph * npw):
139 + i = k // npw
140 + j = k - i * npw
141 + y_base = i * patch_size
142 + x_base = j * patch_size
143 +
144 + for y in range(patch_size):
145 + iy = y_base + y
146 + for x in range(patch_size):
147 + ix = x_base + x
148 + if iy < h and ix < w:
149 + for c in range(3):
150 + raw = np.float32(arr[iy, ix, c]) * inv255
151 + patches[k, y, x, c] = (raw - mean[c]) / std[c]
152 + else:
153 + for c in range(3):
154 + patches[k, y, x, c] = pad_norm[c]
155 +
156 +
157 +def _encode_image_bytes(
158 + image_bytes: bytes,
159 + *,
160 + patch_size: int,
161 + rescale_image_frac: float | None,
162 + rescale_image_max_upscaled_long_edge: int | None,
163 +) -> torch.Tensor:
164 + if patch_size <= 0:
165 + raise ValueError("patch_size must be greater than zero")
166 + _validate_image_rescale(
167 + rescale_image_frac,
168 + rescale_image_max_upscaled_long_edge,
169 + )
170 +
171 + from PIL import Image
172 +
173 + image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
174 + scaled_size = _scaled_image_dimensions(
175 + image.width,
176 + image.height,
177 + rescale_image_frac=rescale_image_frac,
178 + rescale_image_max_upscaled_long_edge=rescale_image_max_upscaled_long_edge,
179 + )
180 + if scaled_size != image.size:
181 + image = image.resize(scaled_size, resample=Image.Resampling.LANCZOS)
182 + arr = np.array(image, dtype=np.uint8, copy=True)
183 + height, width, _ = arr.shape
184 +
185 + nph = (height + patch_size - 1) // patch_size
186 + npw = width // patch_size + 1
187 + num_patches = nph * npw
188 +
189 + patches = np.empty((num_patches, patch_size, patch_size, 3), dtype=np.float32)
190 + _fill_patches_numba(arr, patch_size, patches, IMAGE_MEAN, IMAGE_STD, PAD_NORM)
191 +
192 + return (
193 + torch.from_numpy(patches)
194 + .to(torch.bfloat16)
195 + .view(num_patches, 1, patch_size, patch_size, 3)
196 + .expand(num_patches, 2, patch_size, patch_size, 3)
197 + )
198 +
199 +
200 +class InklingImageProcessor(BaseImageProcessor):
201 + r"""Turn raw images into ``vision_patches_bthwc`` for Inkling hMLP.
202 +
203 + ``rescale_image_frac`` scales the long edge while preserving aspect ratio.
204 + ``rescale_image_max_upscaled_long_edge`` optionally caps only upscaling and
205 + therefore requires a scale factor greater than one. The defaults, ``2.0`` and
206 + ``2048``, grow images toward a 2048-pixel long edge by at most 2x, while leaving
207 + images already at or above 2048 unchanged.
208 + """
209 +
210 + model_input_names = ["vision_patches_bthwc"]
211 +
212 + def __init__(
213 + self,
214 + patch_size: int = 40,
215 + rescale_image_frac: float | None = 2.0,
216 + rescale_image_max_upscaled_long_edge: int | None = 2048,
217 + **kwargs,
218 + ):
219 + if patch_size <= 0:
220 + raise ValueError("patch_size must be greater than zero")
221 + _validate_image_rescale(
222 + rescale_image_frac,
223 + rescale_image_max_upscaled_long_edge,
224 + )
225 + super().__init__(**kwargs)
226 + self.patch_size = patch_size
227 + self.rescale_image_frac = rescale_image_frac
228 + self.rescale_image_max_upscaled_long_edge = rescale_image_max_upscaled_long_edge
229 +
230 + def _encode_one(self, image) -> torch.Tensor:
231 + return _encode_image_bytes(
232 + _load_image_bytes(image),
233 + patch_size=self.patch_size,
234 + rescale_image_frac=self.rescale_image_frac,
235 + rescale_image_max_upscaled_long_edge=self.rescale_image_max_upscaled_long_edge,
236 + )
237 +
238 + def preprocess(
239 + self,
240 + images: ImageInput | list,
241 + return_tensors: str | None = "pt",
242 + **kwargs,
243 + ) -> BatchFeature:
244 + del return_tensors, kwargs
245 + if not isinstance(images, (list, tuple)):
246 + images = [images]
247 +
248 + per_image_patches: list[torch.Tensor] = []
249 + num_patches: list[int] = []
250 + num_tokens: list[int] = []
251 + for img in images:
252 + vp = self._encode_one(img)
253 + n_patches = int(vp.shape[0])
254 + per_image_patches.append(vp)
255 + num_patches.append(n_patches)
256 + num_tokens.append(n_patches)
257 +
258 + if len(per_image_patches) == 1:
259 + vision_patches_bthwc = per_image_patches[0]
260 + elif per_image_patches:
261 + vision_patches_bthwc = torch.cat(per_image_patches, dim=0)
262 + else:
263 + vision_patches_bthwc = torch.empty(0)
264 +
265 + data = {
266 + "vision_patches_bthwc": vision_patches_bthwc,
267 + "num_patches": num_patches,
268 + "num_tokens": num_tokens,
269 + }
270 + return BatchFeature(data=data, tensor_type=None)
271 +
272 +
273 +# ---------------------------------------------------------------------------
274 +# Audio feature extraction
275 +# ---------------------------------------------------------------------------
276 +
277 +
278 +@dataclass
279 +class InklingAudioEncoderParams:
280 + """Audio preprocessing parameters used to convert raw audio into dMel bins."""
281 +
282 + sample_rate: int = 16_000
283 + window_size_multiplier: float = 2.0
284 + n_fft: int | None = None
285 + n_mels: int = 80
286 + num_dmel_bins: int = 16
287 + dmel_min_value: float = -7.0
288 + dmel_max_value: float = 2.0
289 + audio_token_duration_s: float = 0.05
290 +
291 +
292 +def _to_exact_int(value: float, name: str, tolerance: float = 1e-6) -> int:
293 + rounded = round(value)
294 + if abs(value - rounded) > tolerance:
295 + raise ValueError(f"{name} must resolve to an integer sample count, got {value}")
296 + return int(rounded)
297 +
298 +
299 +def _hz_to_mel(frequencies: np.ndarray) -> np.ndarray:
300 + """Slaney mel scale, matching the librosa/torchaudio convention."""
301 + frequencies = np.asarray(frequencies, dtype=np.float64)
302 + f_sp = 200.0 / 3.0
303 + min_log_hz = 1000.0
304 + min_log_mel = min_log_hz / f_sp
305 + logstep = np.log(6.4) / 27.0
306 + linear = frequencies / f_sp
307 + log = (
308 + min_log_mel + np.log(np.maximum(frequencies, min_log_hz) / min_log_hz) / logstep
309 + )
310 + return np.where(frequencies >= min_log_hz, log, linear)
311 +
312 +
313 +def _mel_to_hz(mels: np.ndarray) -> np.ndarray:
314 + mels = np.asarray(mels, dtype=np.float64)
315 + f_sp = 200.0 / 3.0
316 + min_log_hz = 1000.0
317 + min_log_mel = min_log_hz / f_sp
318 + logstep = np.log(6.4) / 27.0
319 + linear = mels * f_sp
320 + log = min_log_hz * np.exp(logstep * (mels - min_log_mel))
321 + return np.where(mels >= min_log_mel, log, linear)
322 +
323 +
324 +_MEL_BASIS_CACHE: dict[tuple[int, int, int], torch.Tensor] = {}
325 +
326 +
327 +def _mel_basis(sample_rate: int, n_fft: int, n_mels: int) -> torch.Tensor:
328 + key = (sample_rate, n_fft, n_mels)
329 + cached = _MEL_BASIS_CACHE.get(key)
330 + if cached is not None:
331 + return cached
332 +
333 + fft_bins = n_fft // 2 + 1
334 + fft_freqs = np.arange(fft_bins, dtype=np.float64) * sample_rate / n_fft
335 + mel_edges = _mel_to_hz(
336 + np.linspace(
337 + _hz_to_mel(np.array([0.0]))[0],
338 + _hz_to_mel(np.array([sample_rate / 2.0]))[0],
339 + n_mels + 2,
340 + dtype=np.float64,
341 + )
342 + )
343 + mel_widths = np.diff(mel_edges)
344 + lower = (fft_freqs[None, :] - mel_edges[:-2, None]) / mel_widths[:-1, None]
345 + upper = (mel_edges[2:, None] - fft_freqs[None, :]) / mel_widths[1:, None]
346 + weights = np.maximum(0.0, np.minimum(lower, upper))
347 +
348 + # Slaney area normalization.
349 + weights *= (2.0 / (mel_edges[2:] - mel_edges[:-2]))[:, None]
350 + basis = torch.from_numpy(weights.astype(np.float32, copy=False)).contiguous()
351 + _MEL_BASIS_CACHE[key] = basis
352 + return basis
353 +
354 +
355 +def _dmel_bins(audio: torch.Tensor, params: InklingAudioEncoderParams) -> torch.Tensor:
356 + hop_length = _to_exact_int(
357 + params.audio_token_duration_s * params.sample_rate,
358 + "audio_token_duration_s * sample_rate",
359 + )
360 + window_size = _to_exact_int(
361 + params.audio_token_duration_s
362 + * params.window_size_multiplier
363 + * params.sample_rate,
364 + "audio_token_duration_s * window_size_multiplier * sample_rate",
365 + )
366 + n_fft = params.n_fft or window_size
367 + if hop_length <= 0 or window_size <= 0 or n_fft <= 0:
368 + raise ValueError("audio hop length, window size, and n_fft must be positive")
369 + if audio.numel() == 0:
370 + return torch.empty((0, params.n_mels), dtype=torch.int32)
371 +
372 + right_pad = math.ceil(audio.numel() / hop_length) * hop_length - audio.numel()
373 + left_pad = max(n_fft - hop_length, 0)
374 + audio = F.pad(audio, (left_pad, right_pad))
375 +
376 + window = torch.hann_window(window_size, periodic=True, dtype=torch.float32)
377 + spec = torch.stft(
378 + audio.unsqueeze(0),
379 + n_fft=n_fft,
380 + hop_length=hop_length,
381 + win_length=window_size,
382 + window=window,
383 + center=False,
384 + normalized=False,
385 + onesided=True,
386 + return_complex=True,
387 + )
388 + spec_ri = torch.view_as_real(spec)
389 + magnitude = (
390 + (spec_ri[..., 0].square() + spec_ri[..., 1].square())
391 + .clamp_min(1e-10)
392 + .sqrt()
393 + .squeeze(0)
394 + )
395 +
396 + mel = (
397 + _mel_basis(params.sample_rate, n_fft, params.n_mels)
398 + .matmul(magnitude)
399 + .clamp_min(1e-10)
400 + .log10()
401 + )
402 + mel = mel.to(torch.float64).clamp(
403 + min=params.dmel_min_value, max=params.dmel_max_value
404 + )
405 + bin_centers = torch.linspace(
406 + params.dmel_min_value,
407 + params.dmel_max_value,
408 + params.num_dmel_bins,
409 + dtype=torch.float64,
410 + )
411 + dmel_bins = (mel.unsqueeze(-1) - bin_centers).abs().argmin(dim=-1)
412 + return dmel_bins.to(torch.int32).T.contiguous()
413 +
414 +
415 +class InklingAudioFeatureExtractor(FeatureExtractionMixin):
416 + """Convert raw audio into Inkling dMel bins in the HF feature-extractor API."""
417 +
418 + model_input_names = ["dmel_bins"]
419 +
420 + def __init__(self, params: dict | None = None, **kwargs):
421 + super().__init__(**kwargs)
422 + merged = InklingAudioEncoderParams()
423 + if params:
424 + for k, v in params.items():
425 + if hasattr(merged, k):
426 + setattr(merged, k, v)
427 + # also accept flat kwargs (HF config style)
428 + for k in list(kwargs.keys()):
429 + if hasattr(merged, k):
430 + setattr(merged, k, kwargs[k])
431 + self.params = merged
432 +
433 + def _decode_one(self, audio) -> torch.Tensor:
434 + # vLLM hands the feature extractor numpy arrays (the dummy-input builder
435 + # during profiling, and MultiModalDataParser after resampling to the
436 + # target sample rate), so no other input types need to be supported.
437 + return torch.from_numpy(
438 + np.ascontiguousarray(audio.astype(np.float32, copy=False))
439 + ).flatten()
440 +
441 + def _encode_one(self, audio) -> torch.Tensor:
442 + return _dmel_bins(self._decode_one(audio), self.params)
443 +
444 + def __call__(
445 + self,
446 + audios: Sequence | None,
447 + return_tensors: str | None = None,
448 + **kwargs,
449 + ) -> BatchFeature:
450 + del return_tensors, kwargs
451 + if audios is None:
452 + audios = []
453 + if not isinstance(audios, (list, tuple)):
454 + audios = [audios]
455 +
456 + dmel_bins = [self._encode_one(a) for a in audios]
457 + data = {
458 + # per-clip feature: dmel bins as float32 [T, n_mels]
459 + "dmel_bins": [bins.to(torch.float32) for bins in dmel_bins],
460 + "num_audio_tokens": [int(bins.shape[0]) for bins in dmel_bins],
461 + }
462 + # return_tensors intentionally ignored: per-clip features have ragged T.
463 + return BatchFeature(data=data, tensor_type=None)
464 +
465 +
466 +# ---------------------------------------------------------------------------
467 +# Composite processor
468 +# ---------------------------------------------------------------------------
469 +
470 +
471 +class InklingProcessor:
472 + """Bundle Inkling image + audio preprocessing with the MM token ids."""
473 +
474 + def __init__(
475 + self,
476 + image_processor: InklingImageProcessor | None = None,
477 + audio_feature_extractor: InklingAudioFeatureExtractor | None = None,
478 + tokenizer=None,
479 + ):
480 + self.image_processor = image_processor or InklingImageProcessor()
481 + self.audio_feature_extractor = (
482 + audio_feature_extractor or InklingAudioFeatureExtractor()
483 + )
484 + self.tokenizer = tokenizer
485 +
486 + def process_images(self, images: list):
487 + """Raw images -> BatchFeature(vision_patches_bthwc, num_patches, num_tokens)."""
488 + return self.image_processor.preprocess(images, return_tensors="pt")
489 +
490 + def process_audios(self, audios: list):
491 + """Raw audios -> BatchFeature(dmel_bins, num_audio_tokens)."""
492 + return self.audio_feature_extractor(audios)
493 +
494 +
495 +__all__ = [
496 + "InklingImageProcessor",
497 + "InklingAudioFeatureExtractor",
498 + "InklingAudioEncoderParams",
499 + "InklingProcessor",
500 + "IMAGE_MARKER_ID",
501 + "AUDIO_MARKER_ID",
502 + "IMAGE_TOKEN_ID",
503 + "AUDIO_TOKEN_ID",
504 +]