Skip to content

Commit ef3318e

Browse files
authored
Merge pull request #30 from SharpAI/feat/qwen3next-dflash-support
feat: Qwen3Next and Qwen35 DFlash Speculative Decoding Support
2 parents 63707c0 + 63493dc commit ef3318e

7 files changed

Lines changed: 256 additions & 17 deletions

File tree

Libraries/MLXLLM/Models/Qwen35.swift

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -574,7 +574,7 @@ final class Qwen35DecoderLayer: Module {
574574
// MARK: - Text Model
575575

576576
public class Qwen35TextModelInner: Module, LayerPartitionable, StreamableMoE {
577-
@ModuleInfo(key: "embed_tokens") var embedTokens: Embedding
577+
@ModuleInfo(key: "embed_tokens") public var embedTokens: Embedding
578578

579579
fileprivate let layers: [Qwen35DecoderLayer]
580580
let norm: MathRMSNorm
@@ -613,7 +613,7 @@ public class Qwen35TextModelInner: Module, LayerPartitionable, StreamableMoE {
613613
var hiddenStates = embedTokens(inputs)
614614

615615
var cacheArray = cache
616-
if cacheArray == nil {
616+
if cacheArray == nil || cacheArray?.count != layers.count {
617617
cacheArray = Array(repeating: nil as KVCache?, count: layers.count)
618618
}
619619

@@ -633,6 +633,37 @@ public class Qwen35TextModelInner: Module, LayerPartitionable, StreamableMoE {
633633

634634
return norm(hiddenStates)
635635
}
636+
637+
public func callCapturing(_ inputs: MLXArray, cache: [KVCache?]? = nil, captureLayerIDs: Set<Int>) -> (MLXArray, [Int: MLXArray]) {
638+
var hiddenStates = embedTokens(inputs)
639+
640+
var cacheArray = cache
641+
if cacheArray == nil || cacheArray?.count != layers.count {
642+
cacheArray = Array(repeating: nil as KVCache?, count: layers.count)
643+
}
644+
645+
let faMask = createAttentionMask(h: hiddenStates, cache: cacheArray?[faIdx])
646+
let ssmMask = createSSMMask(h: hiddenStates, cache: cacheArray?[ssmIdx] as? MambaCache)
647+
648+
var captured = [Int: MLXArray]()
649+
650+
for (i, layer) in layers.enumerated() {
651+
let mask = layer.isLinear ? ssmMask : nil
652+
let attnMask =
653+
layer.isLinear
654+
? MLXFast.ScaledDotProductAttentionMaskMode.none : faMask
655+
hiddenStates = partitionedLayerCall(index: i, gpuLayerCount: gpuLayerCount, stream: streamExperts, cacheToEval: cacheArray?[i]) {
656+
layer(
657+
hiddenStates, attentionMask: attnMask, ssmMask: mask, cache: cacheArray?[i])
658+
}
659+
660+
if captureLayerIDs.contains(i) {
661+
captured[i] = hiddenStates
662+
}
663+
}
664+
665+
return (norm(hiddenStates), captured)
666+
}
636667
}
637668

638669
public class Qwen35TextModel: Module, LLMModel, KVCacheDimensionProvider {
@@ -642,7 +673,7 @@ public class Qwen35TextModel: Module, LLMModel, KVCacheDimensionProvider {
642673
public let model: Qwen35TextModelInner
643674
let configuration: Qwen35TextConfiguration
644675

645-
@ModuleInfo(key: "lm_head") var lmHead: Linear?
676+
@ModuleInfo(key: "lm_head") public var lmHead: Linear?
646677

647678
public init(_ args: Qwen35TextConfiguration) {
648679
self.configuration = args
@@ -725,7 +756,7 @@ public class Qwen35Model: Module, LLMModel, KVCacheDimensionProvider {
725756
public let vocabularySize: Int
726757
public let kvHeads: [Int]
727758

728-
@ModuleInfo(key: "language_model") var languageModel: Qwen35TextModel
759+
@ModuleInfo(key: "language_model") public var languageModel: Qwen35TextModel
729760

730761
public init(_ args: Qwen35Configuration) {
731762
let textModel = Qwen35TextModel(args.textConfig)

Libraries/MLXLLM/Models/Qwen3Next.swift

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ final class Qwen3NextDecoderLayer: Module {
419419
}
420420

421421
public class Qwen3NextModelInner: Module {
422-
@ModuleInfo(key: "embed_tokens") var embedTokens: Embedding
422+
@ModuleInfo(key: "embed_tokens") public var embedTokens: Embedding
423423

424424
fileprivate let layers: [Qwen3NextDecoderLayer]
425425
let norm: RMSNorm
@@ -451,7 +451,7 @@ public class Qwen3NextModelInner: Module {
451451
var hiddenStates = embedTokens(inputs)
452452

453453
var cacheArray = cache
454-
if cacheArray == nil {
454+
if cacheArray == nil || cacheArray?.count != layers.count {
455455
cacheArray = Array(repeating: nil as KVCache?, count: layers.count)
456456
}
457457

@@ -467,6 +467,33 @@ public class Qwen3NextModelInner: Module {
467467

468468
return norm(hiddenStates)
469469
}
470+
471+
public func callCapturing(_ inputs: MLXArray, cache: [KVCache?]? = nil, captureLayerIDs: Set<Int>) -> (MLXArray, [Int: MLXArray]) {
472+
var hiddenStates = embedTokens(inputs)
473+
474+
var cacheArray = cache
475+
if cacheArray == nil || cacheArray?.count != layers.count {
476+
cacheArray = Array(repeating: nil as KVCache?, count: layers.count)
477+
}
478+
479+
let faMask = createAttentionMask(h: hiddenStates, cache: cacheArray?[faIdx])
480+
let ssmMask = createSSMMask(h: hiddenStates, cache: cacheArray?[ssmIdx] as? MambaCache)
481+
482+
var captured = [Int: MLXArray]()
483+
484+
for (i, layer) in layers.enumerated() {
485+
let mask = layer.isLinear ? ssmMask : nil
486+
let attnMask = layer.isLinear ? MLXFast.ScaledDotProductAttentionMaskMode.none : faMask
487+
hiddenStates = layer(
488+
hiddenStates, attentionMask: attnMask, ssmMask: mask, cache: cacheArray?[i])
489+
490+
if captureLayerIDs.contains(i) {
491+
captured[i] = hiddenStates
492+
}
493+
}
494+
495+
return (norm(hiddenStates), captured)
496+
}
470497
}
471498

472499
public class Qwen3NextModel: Module, LLMModel, KVCacheDimensionProvider {
@@ -476,7 +503,7 @@ public class Qwen3NextModel: Module, LLMModel, KVCacheDimensionProvider {
476503
public let model: Qwen3NextModelInner
477504
let configuration: Qwen3NextConfiguration
478505

479-
@ModuleInfo(key: "lm_head") var lmHead: Linear?
506+
@ModuleInfo(key: "lm_head") public var lmHead: Linear?
480507

481508
public init(_ args: Qwen3NextConfiguration) {
482509
self.configuration = args

Libraries/MLXLMCommon/KVCache.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,7 +1251,7 @@ public class ChunkedKVCache: KVCacheSimple {
12511251
}
12521252

12531253
/// Base cache for array-based state storage
1254-
public class ArraysCache: BaseKVCache {
1254+
open class ArraysCache: BaseKVCache {
12551255
private var cache: [MLXArray?]
12561256
internal var leftPadding: MLXArray?
12571257

@@ -1265,12 +1265,12 @@ public class ArraysCache: BaseKVCache {
12651265
cache.compactMap { $0 }
12661266
}
12671267

1268-
public subscript(index: Int) -> MLXArray? {
1268+
open subscript(index: Int) -> MLXArray? {
12691269
get { cache[index] }
12701270
set { cache[index] = newValue }
12711271
}
12721272

1273-
public override var state: [MLXArray] {
1273+
open override var state: [MLXArray] {
12741274
get {
12751275
return cache.compactMap { $0 }
12761276
}
@@ -1279,7 +1279,7 @@ public class ArraysCache: BaseKVCache {
12791279
}
12801280
}
12811281

1282-
public override func copy() -> any KVCache {
1282+
open override func copy() -> any KVCache {
12831283
let new = ArraysCache(size: cache.count)
12841284
let s = self.state
12851285
if !s.isEmpty {
@@ -1322,7 +1322,7 @@ public class ArraysCache: BaseKVCache {
13221322

13231323
/// metaState format: [slotCount, presentSlots (comma-separated), leftPadding (comma-separated, optional)]
13241324
/// Legacy format (BaseKVCache default): [""]
1325-
public override var metaState: [String] {
1325+
open override var metaState: [String] {
13261326
get {
13271327
var result = [
13281328
"\(cache.count)",
@@ -1379,7 +1379,7 @@ public class ArraysCache: BaseKVCache {
13791379
}
13801380

13811381
/// Simple cache for Mamba-style state space models
1382-
public class MambaCache: ArraysCache {
1382+
open class MambaCache: ArraysCache {
13831383
/// Saved state for speculative decoding rollback.
13841384
/// Mamba state is recurrent and cannot be partially "trimmed" like attention KV caches.
13851385
/// Instead, we checkpoint before speculation and restore on rollback.
@@ -1390,10 +1390,10 @@ public class MambaCache: ArraysCache {
13901390
}
13911391

13921392
/// Mark as trimmable to enable speculative decoding on hybrid Attention+Mamba models.
1393-
public override var isTrimmable: Bool { true }
1393+
open override var isTrimmable: Bool { true }
13941394

13951395
/// Save a checkpoint of the current Mamba state (call before speculative draft round).
1396-
public func checkpoint() {
1396+
open func checkpoint() {
13971397
let s = self.state
13981398
if !s.isEmpty {
13991399
savedState = s.map { $0[.ellipsis] } // deep copy
@@ -1404,7 +1404,7 @@ public class MambaCache: ArraysCache {
14041404
/// When n > 0, rejected draft tokens have polluted the state — restore checkpoint.
14051405
/// When n == 0, all drafts accepted — keep current state and clear checkpoint.
14061406
@discardableResult
1407-
public override func trim(_ n: Int) -> Int {
1407+
open override func trim(_ n: Int) -> Int {
14081408
if n > 0, let saved = savedState {
14091409
self.state = saved
14101410
savedState = nil
@@ -1414,7 +1414,7 @@ public class MambaCache: ArraysCache {
14141414
return 0
14151415
}
14161416

1417-
public override func copy() -> any KVCache {
1417+
open override func copy() -> any KVCache {
14181418
let new = MambaCache()
14191419
let s = self.state
14201420
if !s.isEmpty {

Libraries/MLXLMCommon/SwitchLayers.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ public class SwitchGLU: Module, @unchecked Sendable {
144144
_persistentUp = qUp.allocateExpertBuffers(maxBuffers)
145145
_persistentDown = qDown.allocateExpertBuffers(maxBuffers)
146146

147+
147148
// Merged eval: idx + buffer allocations (same as ssd-opt-v1)
148149
var toEval: [MLXArray] = [idx]
149150
toEval.append(contentsOf: _persistentGate!)

Tests/MLXLMTests/Qwen35Tests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Foundation
2+
import MLX
3+
import MLXLLM
4+
import MLXLMCommon
5+
import MLXNN
6+
import Testing
7+
8+
extension MLXTestingSuite {
9+
@Suite
10+
struct Qwen35Tests {
11+
12+
private func makeTinyConfigData() -> Data {
13+
let json = """
14+
{
15+
"model_type": "qwen3_5",
16+
"hidden_size": 64,
17+
"num_hidden_layers": 4,
18+
"intermediate_size": 128,
19+
"num_attention_heads": 4,
20+
"num_key_value_heads": 2,
21+
"rms_norm_eps": 1e-6,
22+
"vocab_size": 100,
23+
"rope_theta": 10000.0,
24+
"max_position_embeddings": 512
25+
}
26+
"""
27+
return json.data(using: .utf8)!
28+
}
29+
30+
@Test("Qwen35 callCapturing returns captured layers")
31+
func testQwen35CallCapturing() throws {
32+
33+
let data = makeTinyConfigData()
34+
let config = try JSONDecoder().decode(Qwen35Configuration.self, from: data)
35+
let model = Qwen35Model(config)
36+
37+
let input = MLXArray(0..<8).reshaped(1, 8)
38+
let captureLayerIDs: Set<Int> = [0, 1]
39+
40+
let (hiddenStates, captured) = model.languageModel.model.callCapturing(input, captureLayerIDs: captureLayerIDs)
41+
42+
#expect(hiddenStates.shape == [1, 8, 64])
43+
#expect(captured.count == 2)
44+
#expect(captured[0]?.shape == [1, 8, 64])
45+
#expect(captured[1]?.shape == [1, 8, 64])
46+
}
47+
}
48+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Foundation
2+
import MLX
3+
import MLXLLM
4+
import MLXLMCommon
5+
import MLXNN
6+
import Testing
7+
8+
extension MLXTestingSuite {
9+
@Suite
10+
struct Qwen3NextTests {
11+
12+
private func makeTinyConfigData() -> Data {
13+
let json = """
14+
{
15+
"model_type": "qwen3_next",
16+
"hidden_size": 64,
17+
"num_hidden_layers": 4,
18+
"intermediate_size": 128,
19+
"num_attention_heads": 4,
20+
"linear_num_value_heads": 4,
21+
"linear_num_key_heads": 2,
22+
"linear_key_head_dim": 16,
23+
"linear_value_head_dim": 16,
24+
"linear_conv_kernel_dim": 4,
25+
"num_experts": 2,
26+
"num_experts_per_tok": 1,
27+
"decoder_sparse_step": 2,
28+
"shared_expert_intermediate_size": 64,
29+
"mlp_only_layers": [],
30+
"moe_intermediate_size": 64,
31+
"rms_norm_eps": 1e-6,
32+
"vocab_size": 100,
33+
"num_key_value_heads": 2,
34+
"rope_theta": 10000.0,
35+
"max_position_embeddings": 512,
36+
"full_attention_interval": 2
37+
}
38+
"""
39+
return json.data(using: .utf8)!
40+
}
41+
42+
@Test("Qwen3Next callCapturing returns captured layers")
43+
func testQwen3NextCallCapturing() throws {
44+
45+
let data = makeTinyConfigData()
46+
let config = try JSONDecoder().decode(Qwen3NextConfiguration.self, from: data)
47+
let model = Qwen3NextModel(config)
48+
49+
let input = MLXArray(0..<8).reshaped(1, 8)
50+
let captureLayerIDs: Set<Int> = [0, 2]
51+
52+
let (hiddenStates, captured) = model.model.callCapturing(input, captureLayerIDs: captureLayerIDs)
53+
54+
#expect(hiddenStates.shape == [1, 8, 64])
55+
#expect(captured.count == 2)
56+
#expect(captured[0]?.shape == [1, 8, 64])
57+
#expect(captured[2]?.shape == [1, 8, 64])
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)