Created
February 23, 2026 21:36
-
-
Save Blaizzy/7e56017eee617852d403d6b643899844 to your computer and use it in GitHub Desktop.
Get started with MLX-Swift with Qwen3 port
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // BaseConfiguration.swift | |
| // mlx-test | |
| // | |
| // Created by Prince Canuma on 29/12/25. | |
| // | |
| import Foundation | |
| import MLX | |
| /// Base ``LanguageModel`` configuration -- provides `modelType` | |
| /// and `quantization` (used in loading the model). | |
| /// | |
| /// This is used by ``ModelFactory/load(hub:configuration:progressHandler:)`` | |
| /// to determine the type of model to load. | |
| public struct BaseConfiguration: Codable, Sendable { | |
| public let modelType: String | |
| public struct Quantization: Codable, Sendable, Equatable { | |
| public init(groupSize: Int, bits: Int) { | |
| self.groupSize = groupSize | |
| self.bits = bits | |
| } | |
| public let groupSize: Int | |
| public let bits: Int | |
| private var _mode: QuantizationMode? = nil | |
| public var mode: QuantizationMode { _mode ?? .affine } | |
| public var asTuple: (Int, Int, QuantizationMode) { (groupSize, bits, mode) } | |
| enum CodingKeys: String, CodingKey { | |
| case groupSize = "group_size" | |
| case bits = "bits" | |
| case _mode = "mode" | |
| } | |
| } | |
| /// handling instructions for ``PerLayerQuantization`` | |
| public enum QuantizationOption: Sendable { | |
| case skip | |
| case quantize(Quantization) | |
| } | |
| /// Per-layer ``Quantization`` values with optional default. | |
| public struct PerLayerQuantization: Sendable { | |
| public var quantization: Quantization? = nil | |
| public var perLayerQuantization: [String: QuantizationOption] | |
| public init( | |
| quantization: BaseConfiguration.Quantization? = nil, | |
| perLayerQuantization: [String: BaseConfiguration.QuantizationOption] | |
| ) { | |
| self.quantization = quantization | |
| self.perLayerQuantization = perLayerQuantization | |
| } | |
| /// The quantization to apply for the given layer name or nil for no quantization. | |
| public func quantization(layer: String) -> Quantization? { | |
| if let perLayer = perLayerQuantization[layer] { | |
| switch perLayer { | |
| case .skip: | |
| return nil | |
| case .quantize(let quantization): | |
| return quantization | |
| } | |
| } else { | |
| return quantization | |
| } | |
| } | |
| } | |
| /// Special codable to support a mixed key: Int / key: Quantization | |
| /// structure for hereogenous quantization, e.g. | |
| /// | |
| /// ``` | |
| /// "quantization": { | |
| /// "group_size": 64, | |
| /// "bits": 4, | |
| /// "model.embed_tokens": { | |
| /// "group_size": 32, | |
| /// "bits": 4 | |
| /// }, | |
| /// "model.layers.0.self_attn.q_norm": false, | |
| /// ``` | |
| /// | |
| /// This mixed type structure requires manual decoding. | |
| struct QuantizationContainer: Codable, Sendable { | |
| var quantization: Quantization | |
| var perLayerQuantization: PerLayerQuantization | |
| // based on Dictionary's coding key | |
| internal struct _DictionaryCodingKey: CodingKey { | |
| internal let stringValue: String | |
| internal let intValue: Int? | |
| internal init(stringValue: String) { | |
| self.stringValue = stringValue | |
| self.intValue = Int(stringValue) | |
| } | |
| internal init(intValue: Int) { | |
| self.stringValue = "\(intValue)" | |
| self.intValue = intValue | |
| } | |
| } | |
| init(from decoder: any Decoder) throws { | |
| // handle the embedded Quantization | |
| self.quantization = try Quantization(from: decoder) | |
| // and the interleaved per-layer values | |
| var perLayerQuantization = [String: QuantizationOption]() | |
| let container = try decoder.container(keyedBy: _DictionaryCodingKey.self) | |
| for key in container.allKeys { | |
| switch key.stringValue { | |
| case Quantization.CodingKeys.groupSize.rawValue: continue | |
| case Quantization.CodingKeys.bits.rawValue: continue | |
| case Quantization.CodingKeys._mode.rawValue: continue | |
| // additional keys that are not layer instructions, see | |
| // mlx-community/bitnet-b1.58-2B-4T-4bit | |
| case "quant_method", "linear_class", "quantization_mode": continue | |
| default: | |
| if let f = try? container.decode(Bool.self, forKey: key) { | |
| if !f { | |
| perLayerQuantization[key.stringValue] = .skip | |
| } | |
| } else { | |
| perLayerQuantization[key.stringValue] = .quantize( | |
| try container.decode(Quantization.self, forKey: key)) | |
| } | |
| } | |
| } | |
| self.perLayerQuantization = PerLayerQuantization( | |
| quantization: quantization, perLayerQuantization: perLayerQuantization) | |
| } | |
| func encode(to encoder: any Encoder) throws { | |
| try quantization.encode(to: encoder) | |
| var container = encoder.container(keyedBy: _DictionaryCodingKey.self) | |
| for (key, value) in perLayerQuantization.perLayerQuantization { | |
| switch value { | |
| case .skip: | |
| try container.encode(false, forKey: .init(stringValue: key)) | |
| case .quantize(let q): | |
| try container.encode(q, forKey: .init(stringValue: key)) | |
| } | |
| } | |
| } | |
| } | |
| var quantizationContainer: QuantizationContainer? | |
| @available(*, deprecated, message: "Please use perLayerQuantization instead") | |
| public var quantization: Quantization? { | |
| quantizationContainer?.quantization | |
| } | |
| public var perLayerQuantization: PerLayerQuantization? { | |
| quantizationContainer?.perLayerQuantization | |
| } | |
| enum CodingKeys: String, CodingKey { | |
| case modelType = "model_type" | |
| case quantizationContainer = "quantization" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // KVCache.swift | |
| // mlx-test | |
| // | |
| // Created by Prince Canuma on 27/12/25. | |
| // | |
| import Foundation | |
| import MLX | |
| import MLXNN | |
| // MARK: - Model Protocols and Parameters | |
| /// Protocol for language models that support KV caching | |
| public protocol LanguageModel { | |
| /// Create a new cache for this model | |
| func newCache(parameters: GenerateParameters?) -> [KVCache] | |
| } | |
| /// Parameters for text generation | |
| public struct GenerateParameters: Sendable { | |
| public var maxKVSize: Int? | |
| public var maxTokens: Int? | |
| public var temperature: Float | |
| public var topP: Float | |
| public var repetitionPenalty: Float? | |
| public var repetitionContextSize: Int | |
| public init( | |
| maxKVSize: Int? = nil, | |
| maxTokens: Int? = nil, | |
| temperature: Float = 0.7, | |
| topP: Float = 1.0, | |
| repetitionPenalty: Float? = nil, | |
| repetitionContextSize: Int = 20 | |
| ) { | |
| self.maxKVSize = maxKVSize | |
| self.maxTokens = maxTokens | |
| self.temperature = temperature | |
| self.topP = topP | |
| self.repetitionPenalty = repetitionPenalty | |
| self.repetitionContextSize = repetitionContextSize | |
| } | |
| } | |
| /// Protocol for models that can provide KV cache dimensions | |
| public protocol KVCacheDimensionProvider { | |
| /// Number of layers in the model | |
| var numLayers: Int { get } | |
| } | |
| /// Default implementation for models that conform to KVCacheDimensionProvider | |
| extension LanguageModel where Self: KVCacheDimensionProvider { | |
| public func newCache(parameters: GenerateParameters?) -> [KVCache] { | |
| if let maxKVSize = parameters?.maxKVSize { | |
| return (0..<numLayers).map { _ in | |
| RotatingKVCache(maxSize: maxKVSize, keep: 4) | |
| } | |
| } else { | |
| return (0..<numLayers).map { _ in KVCacheSimple() } | |
| } | |
| } | |
| } | |
| /// Implementation of KV cache functionality for MLX Swift | |
| /// | |
| /// | |
| /// ## Quantized Cache Usage | |
| /// | |
| /// **Standard caches:** | |
| /// ```swift | |
| /// let cache = KVCacheSimple() | |
| /// let (keys, values) = cache.update(keys: keys, values: values) | |
| /// let output = MLXFast.scaledDotProductAttention(queries: q, keys: keys, values: values, ...) | |
| /// ``` | |
| /// | |
| /// **Quantized cache:** | |
| /// ```swift | |
| /// let quantizedCache = QuantizedKVCache(groupSize: 64, bits: 4) | |
| /// let (qKeys, qValues) = quantizedCache.updateQuantized(keys: keys, values: values) | |
| /// | |
| /// let output = quantizedScaledDotProductAttention( | |
| /// queries: queries, | |
| /// quantizedKeys: qKeys, | |
| /// quantizedValues: qValues, | |
| /// scale: scale, | |
| /// mask: mask, | |
| /// groupSize: quantizedCache.groupSize, | |
| /// bits: quantizedCache.bits | |
| /// ) | |
| /// ``` | |
| /// | |
| /// Interface for Key/Value cache for LLMs. | |
| /// | |
| /// See ``LanguageModel/newCache(parameters:)`` | |
| public protocol KVCache: Evaluatable { | |
| /// get the current offset | |
| var offset: Int { get } | |
| /// get the maximum size (if any) | |
| var maxSize: Int? { get } | |
| /// update the cache with new keys and values and return all keys/values | |
| func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) | |
| /// get the current state for serialization | |
| var state: [MLXArray] { get set } | |
| /// get/set metadata state as string array for serialization | |
| var metaState: [String] { get set } | |
| /// whether this cache can be trimmed | |
| var isTrimmable: Bool { get } | |
| /// trim n tokens from the cache, returning actual number trimmed | |
| @discardableResult | |
| func trim(_ n: Int) -> Int | |
| /// Create an attention mask for this cache | |
| /// | |
| /// This method encapsulates cache-specific mask creation logic. Implementations should handle offset capping, window size logic, | |
| /// and optimization decisions (symbolic vs array masks). | |
| /// | |
| /// - Parameters: | |
| /// - n: The sequence length for the new tokens | |
| /// - windowSize: Optional sliding window size | |
| /// - returnArray: Force return of array mask instead of symbolic | |
| /// - Returns: Attention mask mode for scaled dot product attention | |
| func makeMask( | |
| n: Int, windowSize: Int?, returnArray: Bool | |
| ) -> MLXFast.ScaledDotProductAttentionMaskMode | |
| } | |
| /// Protocol for caches that support efficient quantized operations | |
| /// | |
| /// **Usage Example:** | |
| /// ```swift | |
| /// // Efficient quantized path | |
| /// if let quantizedCache = cache as? QuantizedKVCacheProtocol { | |
| /// let (qKeys, qValues) = quantizedCache.updateQuantized(keys: k, values: v) | |
| /// // Use native quantized operations | |
| /// let scores = quantizedMatmul(queries, w: qKeys.0, scales: qKeys.1, biases: qKeys.2, ...) | |
| /// } else { | |
| /// // Regular path | |
| /// let (k, v) = cache.update(keys: k, values: v) | |
| /// let output = MLXFast.scaledDotProductAttention(queries: q, keys: k, values: v, ...) | |
| /// } | |
| /// ``` | |
| public protocol QuantizedKVCacheProtocol: KVCache { | |
| /// The quantization group size used | |
| var groupSize: Int { get } | |
| /// The number of quantization bits used | |
| var bits: Int { get } | |
| /// Quantization mode | |
| var mode: QuantizationMode { get } | |
| /// Update cache and return quantized tuples for maximum efficiency | |
| /// | |
| /// - Parameters: | |
| /// - keys: New key data to add to cache | |
| /// - values: New value data to add to cache | |
| /// - Returns: Quantized tuples (keys, values) as ((weight, scales, biases), (weight, scales, biases)) | |
| func updateQuantized(keys: MLXArray, values: MLXArray) -> ( | |
| (MLXArray, MLXArray, MLXArray?), (MLXArray, MLXArray, MLXArray?) | |
| ) | |
| /// Get current quantized state without updating | |
| /// | |
| /// Useful for accessing cached data without adding new tokens. | |
| /// - Returns: Current quantized state, or nil if cache is empty | |
| func getQuantizedState() -> ((MLXArray, MLXArray, MLXArray?), (MLXArray, MLXArray, MLXArray?))? | |
| } | |
| /// Base cache implementation providing default behaviors | |
| open class BaseKVCache: KVCache { | |
| public var offset: Int = 0 | |
| public var maxSize: Int? { nil } | |
| public func innerState() -> [MLXArray] { [] } | |
| open func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| fatalError("update(keys:values:) must be implemented by subclass") | |
| } | |
| open var state: [MLXArray] { | |
| get { [] } | |
| set { | |
| if !newValue.isEmpty { | |
| fatalError("This cache has no state but a state was set.") | |
| } | |
| } | |
| } | |
| open var metaState: [String] { | |
| get { | |
| // Python base class returns empty string, but we return empty array for Swift compatibility | |
| // This is handled in the save/load functions | |
| [] | |
| } | |
| set { | |
| if !newValue.isEmpty { | |
| fatalError("This cache has no meta_state but a meta_state was set.") | |
| } | |
| } | |
| } | |
| open var isTrimmable: Bool { false } | |
| @discardableResult | |
| open func trim(_ n: Int) -> Int { 0 } | |
| /// Default implementation for caches without special mask requirements | |
| open func makeMask( | |
| n: Int, windowSize: Int?, returnArray: Bool | |
| ) -> MLXFast.ScaledDotProductAttentionMaskMode { | |
| // For single token, no mask needed | |
| if n == 1 { | |
| return .none | |
| } | |
| // For multi-token sequences | |
| if returnArray || (windowSize != nil && n > windowSize!) { | |
| return .array(createCausalMask(n: n, offset: offset, windowSize: windowSize)) | |
| } | |
| return .causal | |
| } | |
| } | |
| public func createCausalMask( | |
| n: Int, | |
| offset: Int, | |
| windowSize: Int? = nil, | |
| lengths: MLXArray? = nil | |
| ) -> MLXArray { | |
| var rinds = MLXArray(Int32(0) ..< Int32(offset + n)) | |
| var linds = offset != 0 ? MLXArray(Int32(offset) ..< Int32(offset + n)) : rinds | |
| linds = linds[0..., .newAxis] | |
| rinds = rinds[.newAxis] | |
| var mask = linds .>= rinds | |
| if let windowSize { | |
| mask = mask & (linds .< rinds + windowSize) | |
| } | |
| if var lengths { | |
| lengths = lengths[0..., .newAxis, .newAxis, .newAxis] | |
| mask = mask & (rinds .< lengths) | |
| } | |
| return mask | |
| } | |
| /// Create an attention mask using the parameters from the KVCache. | |
| /// | |
| /// See also ``MultiHeadAttention/createAdditiveCausalMask(_:dtype:)`` -- same idea | |
| /// but doesn't honor the cache offset. | |
| @_disfavoredOverload | |
| public func createAttentionMask(h: MLXArray, cache: [KVCache]?) -> MLXArray? { | |
| let t = h.dim(1) | |
| if t > 1 { | |
| var offset = 0 | |
| if let c = cache?.first { | |
| offset = c.offset | |
| } | |
| return createCausalMask(n: t, offset: offset) | |
| } | |
| return nil | |
| } | |
| @available( | |
| *, deprecated, | |
| message: "Use createAttentionMask(h:cache:windowSize:returnArray:) with a single cache instead" | |
| ) | |
| public func createAttentionMask(h: MLXArray, cache: [KVCache]?, returnArray: Bool = false) | |
| -> MLXFast.ScaledDotProductAttentionMaskMode | |
| { | |
| let t = h.dim(1) | |
| if t > 1 { | |
| var returnArray = returnArray | |
| var offset = 0 | |
| var windowSize: Int? = nil | |
| if let c = cache?.first { | |
| offset = c.offset | |
| if let maxSize = c.maxSize { | |
| windowSize = maxSize | |
| offset = min(maxSize - 1, offset) | |
| if !returnArray { | |
| returnArray = offset + t > maxSize | |
| } | |
| } | |
| } | |
| if returnArray { | |
| return .array(createCausalMask(n: t, offset: offset, windowSize: windowSize)) | |
| } else { | |
| return .causal | |
| } | |
| } | |
| return .none | |
| } | |
| /// Create an attention mask with explicit window size parameter. | |
| /// | |
| /// - Parameters: | |
| /// - h: The input array (used to determine sequence length) | |
| /// - cache: Optional single KV cache | |
| /// - windowSize: Optional sliding window size (if provided, creates windowed attention) | |
| /// - returnArray: Force return of array mask instead of symbolic "causal" | |
| /// - Returns: Attention mask mode for scaled dot product attention | |
| public func createAttentionMask( | |
| h: MLXArray, | |
| cache: KVCache?, | |
| windowSize: Int? = nil, | |
| returnArray: Bool = false | |
| ) -> MLXFast.ScaledDotProductAttentionMaskMode { | |
| let n = h.dim(1) | |
| // Delegate to cache's makeMask if available | |
| if let cache = cache { | |
| return cache.makeMask(n: n, windowSize: windowSize, returnArray: returnArray) | |
| } | |
| // Fallback for no cache | |
| if n == 1 { | |
| return .none | |
| } | |
| if returnArray || (windowSize != nil && n > windowSize!) { | |
| return .array(createCausalMask(n: n, offset: 0, windowSize: windowSize)) | |
| } | |
| return .causal | |
| } | |
| public func createSSMMask(h: MLXArray, cache: MambaCache?) -> MLXArray? { | |
| if let cache { | |
| return cache.makeMask(N: h.dim(1)) | |
| } | |
| return nil | |
| } | |
| /// Standard KV cache implementation based on Python's KVCache | |
| /// See https://github.com/ml-explore/mlx-examples/blob/main/llms/mlx_lm/models/base.py#L11 | |
| public class KVCacheSimple: BaseKVCache, CustomDebugStringConvertible { | |
| internal var keys: MLXArray? | |
| internal var values: MLXArray? | |
| public var step = 256 | |
| public override init() { | |
| super.init() | |
| } | |
| public override func innerState() -> [MLXArray] { | |
| [self.keys, self.values].compactMap { $0 } | |
| } | |
| public override func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| let previous = self.offset | |
| let reset = | |
| if let currentKeys = self.keys, (previous + keys.dim(2)) > currentKeys.dim(2) { | |
| true | |
| } else { | |
| self.keys == nil | |
| } | |
| if reset { | |
| let B = keys.dim(0) | |
| let kvHeads = keys.dim(1) | |
| let kHeadDim = keys.dim(3) | |
| let vHeadDim = values.dim(3) | |
| let nSteps = (step + keys.dim(2) - 1) / step | |
| let kShape = [B, kvHeads, nSteps * step, kHeadDim] | |
| let vShape = [B, kvHeads, nSteps * step, vHeadDim] | |
| let newK = MLXArray.zeros(kShape, dtype: keys.dtype) | |
| let newV = MLXArray.zeros(vShape, dtype: values.dtype) | |
| if var currentKeys = self.keys, var currentValues = self.values { | |
| if previous % step != 0 { | |
| currentKeys = currentKeys[.ellipsis, ..<previous, 0...] | |
| currentValues = currentValues[.ellipsis, ..<previous, 0...] | |
| } | |
| self.keys = concatenated([currentKeys, newK], axis: 2) | |
| self.values = concatenated([currentValues, newV], axis: 2) | |
| } else { | |
| self.keys = newK | |
| self.values = newV | |
| } | |
| } | |
| self.offset += keys.dim(2) | |
| self.keys?[.ellipsis, previous ..< self.offset, 0...] = keys | |
| self.values?[.ellipsis, previous ..< self.offset, 0...] = values | |
| let returnedKeys = self.keys![.ellipsis, ..<self.offset, 0...] | |
| let returnedValues = self.values![.ellipsis, ..<self.offset, 0...] | |
| return (returnedKeys, returnedValues) | |
| } | |
| public override var state: [MLXArray] { | |
| get { | |
| guard let keys = self.keys, let values = self.values else { return [] } | |
| if offset == keys.dim(2) { | |
| return [keys, values] | |
| } else { | |
| return [ | |
| keys[.ellipsis, ..<offset, 0...], | |
| values[.ellipsis, ..<offset, 0...], | |
| ] | |
| } | |
| } | |
| set { | |
| guard newValue.count == 2 else { | |
| fatalError("KVCacheSimple state must have exactly 2 arrays (keys, values)") | |
| } | |
| self.keys = newValue[0] | |
| self.values = newValue[1] | |
| self.offset = self.keys!.dim(2) | |
| } | |
| } | |
| public override var metaState: [String] { | |
| get { [] } | |
| set { | |
| if !newValue.isEmpty { | |
| fatalError("KVCacheSimple should not have metaState.") | |
| } | |
| } | |
| } | |
| public override var isTrimmable: Bool { true } | |
| @discardableResult | |
| public override func trim(_ n: Int) -> Int { | |
| let trimmed = min(offset, n) | |
| offset -= trimmed | |
| return trimmed | |
| } | |
| /// Convert to quantized cache for maximum efficiency | |
| /// | |
| /// Use `updateQuantized()` and `quantizedScaledDotProductAttention()` for zero-overhead operation. | |
| public func toQuantized(groupSize: Int = 64, bits: Int = 4) -> QuantizedKVCache { | |
| let quantizedCache = QuantizedKVCache(groupSize: groupSize, bits: bits) | |
| quantizedCache.offset = self.offset | |
| if let keys = self.keys, let values = self.values { | |
| // Quantize the current keys and values | |
| let currentKeys = keys[.ellipsis, ..<offset, 0...] | |
| let currentValues = values[.ellipsis, ..<offset, 0...] | |
| let quantizedKeys = quantized(currentKeys, groupSize: groupSize, bits: bits) | |
| let quantizedValues = quantized(currentValues, groupSize: groupSize, bits: bits) | |
| // Set the quantized state | |
| quantizedCache.state = [ | |
| quantizedKeys.wq, quantizedKeys.scales, quantizedKeys.biases, | |
| quantizedValues.wq, quantizedValues.scales, quantizedValues.biases, | |
| ].compactMap { $0 } | |
| } | |
| return quantizedCache | |
| } | |
| public var debugDescription: String { | |
| "\(String(describing: Self.self)) \(Unmanaged.passUnretained(self).toOpaque()), offset: \(offset), step: \(step), keys: \(keys?.shape.description ?? "-"), values: \(values?.shape.description ?? "-")" | |
| } | |
| } | |
| /// Rotating KV cache for sliding window attention | |
| public class RotatingKVCache: BaseKVCache, CustomDebugStringConvertible { | |
| private var keep: Int | |
| private var keys: MLXArray? | |
| private var values: MLXArray? | |
| private var maxCacheSize: Int | |
| private var step: Int | |
| private var idx: Int = 0 | |
| public override var maxSize: Int? { maxCacheSize } | |
| public init(maxSize: Int, keep: Int = 0, step: Int = 256) { | |
| self.maxCacheSize = maxSize | |
| self.keep = keep | |
| self.step = step | |
| super.init() | |
| } | |
| public override func innerState() -> [MLXArray] { | |
| [self.keys, self.values].compactMap { $0 } | |
| } | |
| private func trim(trimSize: Int, _ array: MLXArray, append: MLXArray? = nil) -> MLXArray { | |
| var toCat: [MLXArray] = [] | |
| if trimSize > 0 { | |
| toCat = [ | |
| array[.ellipsis, ..<keep, 0...], | |
| array[.ellipsis, (trimSize + keep)..., 0...], | |
| ] | |
| } else { | |
| toCat = [array] | |
| } | |
| if let append { | |
| toCat.append(append) | |
| } | |
| return concatenated(toCat, axis: 2) | |
| } | |
| private func temporalOrder(_ array: MLXArray) -> MLXArray { | |
| // Rearrange the cache into temporal order, slicing off the end if unused | |
| if idx == array.dim(2) { | |
| return array | |
| } else if idx < offset { | |
| return concatenated( | |
| [ | |
| array[.ellipsis, ..<keep, 0...], | |
| array[.ellipsis, idx..., 0...], | |
| array[.ellipsis, keep ..< idx, 0...], | |
| ], axis: 2) | |
| } else { | |
| return array[.ellipsis, ..<idx, 0...] | |
| } | |
| } | |
| private func updateConcat(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| if self.keys == nil { | |
| self.keys = keys | |
| self.values = values | |
| } else { | |
| // Put the keys/values in temporal order to preserve context | |
| self.keys = temporalOrder(self.keys!) | |
| self.values = temporalOrder(self.values!) | |
| idx = self.keys!.dim(2) | |
| // Allow temporary cache growth during multi-token processing (e.g., prompt prefill). | |
| // The largest size is maxCacheSize + S - 1 to ensure | |
| // every token gets at least maxCacheSize context | |
| let trimSize = idx - maxCacheSize + 1 | |
| self.keys = trim(trimSize: trimSize, self.keys!, append: keys) | |
| self.values = trim(trimSize: trimSize, self.values!, append: values) | |
| } | |
| offset += keys.dim(2) | |
| idx = self.keys!.dim(2) | |
| return (self.keys!, self.values!) | |
| } | |
| private func updateInPlace(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| let B = keys.dim(0) | |
| let nKVHeads = keys.dim(1) | |
| let S = keys.dim(2) | |
| let kHeadDim = keys.dim(3) | |
| let vHeadDim = values.dim(3) | |
| let prev = offset | |
| // May not have hit the max size yet, so potentially keep growing the cache | |
| if self.keys == nil | |
| || (prev >= self.keys!.dim(2) && self.keys!.dim(2) < maxCacheSize) | |
| { | |
| let newSize = min(step, maxCacheSize - prev) | |
| let kShape = [B, nKVHeads, newSize, kHeadDim] | |
| let vShape = [B, nKVHeads, newSize, vHeadDim] | |
| let newK = MLXArray.zeros(kShape, dtype: keys.dtype) | |
| let newV = MLXArray.zeros(vShape, dtype: values.dtype) | |
| if let currentKeys = self.keys, let currentValues = self.values { | |
| self.keys = concatenated([currentKeys, newK], axis: 2) | |
| self.values = concatenated([currentValues, newV], axis: 2) | |
| } else { | |
| self.keys = newK | |
| self.values = newV | |
| } | |
| idx = prev | |
| } | |
| // Trim if needed | |
| let trimSize = self.keys!.dim(2) - maxCacheSize | |
| if trimSize > 0 { | |
| self.keys = trim(trimSize: trimSize, self.keys!) | |
| self.values = trim(trimSize: trimSize, self.values!) | |
| idx = maxCacheSize | |
| } | |
| // Rotate if we've hit the end | |
| if idx == maxCacheSize { | |
| idx = keep | |
| } | |
| // Assign | |
| self.keys![.ellipsis, idx ..< (idx + S), 0...] = keys | |
| self.values![.ellipsis, idx ..< (idx + S), 0...] = values | |
| offset += S | |
| idx += S | |
| // Return the appropriate cache slice | |
| if offset < maxCacheSize { | |
| return ( | |
| self.keys![.ellipsis, ..<offset, 0...], | |
| self.values![.ellipsis, ..<offset, 0...] | |
| ) | |
| } | |
| return (self.keys!, self.values!) | |
| } | |
| public override func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| let result = | |
| if keys.dim(2) == 1 { | |
| updateInPlace(keys: keys, values: values) | |
| } else { | |
| updateConcat(keys: keys, values: values) | |
| } | |
| return result | |
| } | |
| public override var state: [MLXArray] { | |
| get { | |
| guard let keys = self.keys, let values = self.values else { return [] } | |
| if offset < keys.dim(2) { | |
| return [ | |
| keys[.ellipsis, ..<offset, 0...], | |
| values[.ellipsis, ..<offset, 0...], | |
| ] | |
| } else { | |
| return [keys, values] | |
| } | |
| } | |
| set { | |
| guard newValue.count == 2 else { | |
| fatalError("RotatingKVCache state must have exactly 2 arrays") | |
| } | |
| self.keys = newValue[0] | |
| self.values = newValue[1] | |
| // Note: RotatingKVCache doesn't set offset from keys like KVCache does | |
| // The offset is managed through meta_state | |
| } | |
| } | |
| public override var metaState: [String] { | |
| get { | |
| return [String(keep), String(maxCacheSize), String(step), String(offset), String(idx)] | |
| } | |
| set { | |
| guard newValue.count == 5 else { | |
| fatalError("RotatingKVCache metaState must have exactly 5 values") | |
| } | |
| guard let keepVal = Int(newValue[0]), | |
| let stepVal = Int(newValue[2]), | |
| let offsetVal = Int(newValue[3]), | |
| let idxVal = Int(newValue[4]) | |
| else { | |
| fatalError("Failed to convert metaState values to integers") | |
| } | |
| if newValue[1] == "None" { | |
| fatalError( | |
| "RotatingKVCache requires a non-nil maxSize. Cannot load cache with maxSize=None." | |
| ) | |
| } | |
| guard let maxSizeVal = Int(newValue[1]) else { | |
| fatalError("Failed to convert maxCacheSize '\(newValue[1])' to integer") | |
| } | |
| self.keep = keepVal | |
| self.maxCacheSize = maxSizeVal | |
| self.step = stepVal | |
| self.offset = offsetVal | |
| self.idx = idxVal | |
| } | |
| } | |
| public override var isTrimmable: Bool { | |
| return offset < maxCacheSize | |
| } | |
| @discardableResult | |
| public override func trim(_ n: Int) -> Int { | |
| let trimmed = min(offset, n) | |
| offset -= trimmed | |
| idx -= trimmed | |
| return trimmed | |
| } | |
| /// Optimized mask creation for rotating cache with offset capping | |
| public override func makeMask( | |
| n: Int, windowSize: Int?, returnArray: Bool | |
| ) -> MLXFast.ScaledDotProductAttentionMaskMode { | |
| if n > 1 { | |
| // Multi-token case | |
| let actualWindowSize = windowSize ?? maxCacheSize | |
| let cappedOffset = min(maxCacheSize - 1, offset) | |
| // Decide if we need an array mask | |
| if cappedOffset + n > actualWindowSize || returnArray { | |
| return .array( | |
| createCausalMask(n: n, offset: cappedOffset, windowSize: actualWindowSize)) | |
| } | |
| return .causal | |
| } else { | |
| // Single token case (n == 1) | |
| guard let windowSize = windowSize else { | |
| return .none | |
| } | |
| // May need a mask when window_size < max_size and cache has wrapped | |
| if offset >= windowSize, maxCacheSize > windowSize { | |
| var currentIdx = idx | |
| if currentIdx >= maxCacheSize { | |
| currentIdx = 0 | |
| } | |
| let maskSize = offset < maxCacheSize ? offset + 1 : maxCacheSize | |
| let mask = MLXArray(0 ..< Int32(maskSize)) .>= Int32(maskSize - windowSize) | |
| // Roll the mask to account for rotation | |
| let rolledMask = roll(mask, shift: currentIdx + 1) | |
| return .array(rolledMask) | |
| } | |
| return .none | |
| } | |
| } | |
| public var debugDescription: String { | |
| "\(String(describing: Self.self)) offset: \(offset), maxSize: \(maxCacheSize.description), keep: \(keep), idx: \(idx)" | |
| } | |
| /// Convert to quantized cache | |
| /// Note: This is complex due to the rotating nature and temporal ordering | |
| public func toQuantized(groupSize: Int = 64, bits: Int = 4) -> QuantizedKVCache { | |
| // For now, throw an error like the Python version does | |
| // A full implementation would need to handle the temporal ordering correctly | |
| fatalError( | |
| "RotatingKVCache quantization not yet implemented - temporal ordering makes this complex" | |
| ) | |
| // Future implementation would need to: | |
| // 1. Put keys/values in temporal order using temporalOrder() | |
| // 2. Quantize the temporally ordered arrays | |
| // 3. Store metadata about rotation state | |
| // 4. Implement corresponding dequantization with rotation restoration | |
| } | |
| } | |
| /// Quantized KV cache for memory efficiency using MLX quantization | |
| public class QuantizedKVCache: BaseKVCache, QuantizedKVCacheProtocol { | |
| private var keys: (MLXArray, MLXArray, MLXArray?)? | |
| private var values: (MLXArray, MLXArray, MLXArray?)? | |
| private let step: Int | |
| public let groupSize: Int | |
| public let bits: Int | |
| public let mode: QuantizationMode | |
| public init(groupSize: Int = 64, bits: Int = 8, mode: QuantizationMode = .affine) { | |
| self.groupSize = groupSize | |
| self.bits = bits | |
| self.step = 256 | |
| self.mode = mode | |
| super.init() | |
| } | |
| public override func innerState() -> [MLXArray] { | |
| var arrays: [MLXArray] = [] | |
| if let keys = keys { | |
| arrays.append(contentsOf: [keys.0, keys.1, keys.2].compactMap { $0 }) | |
| } | |
| if let values = values { | |
| arrays.append(contentsOf: [values.0, values.1, values.2].compactMap { $0 }) | |
| } | |
| return arrays | |
| } | |
| /// Tree map equivalent for applying function to tuple elements | |
| private func treeMap<T>(_ transform: (MLXArray) -> T, _ tuple: (MLXArray, MLXArray, MLXArray?)) | |
| -> (T, T, T?) | |
| { | |
| if let biases = tuple.2 { | |
| return (transform(tuple.0), transform(tuple.1), transform(biases)) | |
| } else { | |
| return (transform(tuple.0), transform(tuple.1), nil) | |
| } | |
| } | |
| /// Tree map for two tuples (like Python's tree_map over (keys, values)) | |
| private func treeMapPair<T>( | |
| _ transform: (MLXArray) -> T, _ tuple1: (MLXArray, MLXArray, MLXArray?), | |
| _ tuple2: (MLXArray, MLXArray, MLXArray?) | |
| ) -> ((T, T, T?), (T, T, T?)) { | |
| return (treeMap(transform, tuple1), treeMap(transform, tuple2)) | |
| } | |
| /// Create initial quantized tuples (like Python's init_quant) | |
| private func initQuant(dim: Int, shape: [Int], dtype: DType) -> (MLXArray, MLXArray, MLXArray?) | |
| { | |
| // Create temporary zero arrays and quantize them using native MLX Swift | |
| let tempArray = MLXArray.zeros(shape + [dim], dtype: dtype) | |
| let quantized = quantized(tempArray, groupSize: groupSize, bits: bits) | |
| return (quantized.wq, quantized.scales, quantized.biases) | |
| } | |
| /// Expand quantized tuple | |
| private func expandQuant(_ quantTuple: (MLXArray, MLXArray, MLXArray?), newShape: [Int]) -> ( | |
| MLXArray, MLXArray, MLXArray? | |
| ) { | |
| return treeMap( | |
| { array in | |
| let newArray = MLXArray.zeros(newShape + [array.dim(-1)], dtype: array.dtype) | |
| return concatenated([array, newArray], axis: -2) | |
| }, quantTuple) | |
| } | |
| /// Get current quantized keys and values as tuples (efficient access) | |
| /// - Returns: Tuple of ((keyWeight, keyScales, keyBiases), (valueWeight, valueScales, valueBiases)) | |
| public func getQuantizedState() -> ( | |
| (MLXArray, MLXArray, MLXArray?), (MLXArray, MLXArray, MLXArray?) | |
| )? { | |
| guard let keys = keys, let values = values else { return nil } | |
| let trimmedKeys = treeMap({ $0[.ellipsis, ..<offset, 0...] }, keys) | |
| let trimmedValues = treeMap({ $0[.ellipsis, ..<offset, 0...] }, values) | |
| return (trimmedKeys, trimmedValues) | |
| } | |
| /// Update cache and return quantized tuples (Python's update_and_fetch) | |
| /// This is needed because `update` in Swift must return `(MLXArray, MLXArray)` | |
| /// | |
| /// - Parameters: | |
| /// - keys: New key data to add to cache | |
| /// - values: New value data to add to cache | |
| /// - Returns: Quantized tuples (keys, values) as ((weight, scales, biases), (weight, scales, biases)) | |
| public func updateQuantized(keys: MLXArray, values: MLXArray) -> ( | |
| (MLXArray, MLXArray, MLXArray?), (MLXArray, MLXArray, MLXArray?) | |
| ) { | |
| let B = keys.dim(0) | |
| let nKVHeads = keys.dim(1) | |
| let numSteps = keys.dim(2) | |
| let kHeadDim = keys.dim(3) | |
| let vHeadDim = values.dim(3) | |
| let prev = offset | |
| // Check if we need to expand the cache | |
| if self.keys == nil || (prev + numSteps) > self.keys!.0.dim(-2) { | |
| let newSteps = ((step + numSteps - 1) / step) * step | |
| let shape = [B, nKVHeads, newSteps] | |
| if let existingKeys = self.keys, let existingValues = self.values { | |
| // Trim if needed | |
| if prev % step != 0 { | |
| // Use tree_map equivalent to trim both keys and values | |
| let (trimmedKeys, trimmedValues) = treeMapPair( | |
| { array in | |
| array[.ellipsis, ..<prev, 0...] | |
| }, existingKeys, existingValues) | |
| self.keys = trimmedKeys | |
| self.values = trimmedValues | |
| } | |
| // Expand using tree_map equivalent (Python's tree_map(expand_quant, ...)) | |
| self.keys = expandQuant(self.keys!, newShape: shape) | |
| self.values = expandQuant(self.values!, newShape: shape) | |
| } else { | |
| // Initialize new quantized cache | |
| self.keys = initQuant(dim: kHeadDim, shape: shape, dtype: keys.dtype) | |
| self.values = initQuant(dim: vHeadDim, shape: shape, dtype: keys.dtype) | |
| } | |
| } | |
| offset += numSteps | |
| let quantizedKeys = quantized(keys, groupSize: groupSize, bits: bits) | |
| let quantizedValues = quantized(values, groupSize: groupSize, bits: bits) | |
| // Convert named tuples to positional tuples | |
| let qKeys = (quantizedKeys.wq, quantizedKeys.scales, quantizedKeys.biases) | |
| let qValues = (quantizedValues.wq, quantizedValues.scales, quantizedValues.biases) | |
| // Assign to storage | |
| guard let currentKeys = self.keys, let currentValues = self.values else { | |
| fatalError("Quantized cache not properly initialized") | |
| } | |
| // Update each component of the quantized tuples | |
| currentKeys.0[.ellipsis, prev ..< offset, 0...] = qKeys.0 | |
| currentKeys.1[.ellipsis, prev ..< offset, 0...] = qKeys.1 | |
| if let qKeysBiases = qKeys.2 { | |
| currentKeys.2![.ellipsis, prev ..< offset, 0...] = qKeysBiases | |
| } | |
| currentValues.0[.ellipsis, prev ..< offset, 0...] = qValues.0 | |
| currentValues.1[.ellipsis, prev ..< offset, 0...] = qValues.1 | |
| if let qValuesBiases = qValues.2 { | |
| currentValues.2![.ellipsis, prev ..< offset, 0...] = qValuesBiases | |
| } | |
| self.keys = currentKeys | |
| self.values = currentValues | |
| // Return quantized tuples | |
| let trimmedKeys = treeMap({ $0[.ellipsis, ..<offset, 0...] }, currentKeys) | |
| let trimmedValues = treeMap({ $0[.ellipsis, ..<offset, 0...] }, currentValues) | |
| return (trimmedKeys, trimmedValues) | |
| } | |
| /// This method is required by the KVCache protocol, but it is not intended to be used with QuantizedKVCache. | |
| /// Use `updateQuantized` instead. | |
| public override func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| fatalError( | |
| "`update` was called on `QuantizedKVCache`. Use `updateQuantized` instead." | |
| ) | |
| } | |
| /// Array of keys and values -- this will have either 6 elements or 4 elements (if biases are nil). | |
| public override var state: [MLXArray] { | |
| get { | |
| guard let keys = keys, let values = values else { return [] } | |
| if offset < keys.0.dim(2) { | |
| // Trim to current offset using tree_map | |
| let trimmedKeys = treeMap({ $0[.ellipsis, ..<offset, 0...] }, keys) | |
| let trimmedValues = treeMap({ $0[.ellipsis, ..<offset, 0...] }, values) | |
| // Flatten tuples to array for serialization | |
| return [ | |
| trimmedKeys.0, trimmedKeys.1, trimmedKeys.2, trimmedValues.0, trimmedValues.1, | |
| trimmedValues.2, | |
| ].compactMap { $0 } | |
| } else { | |
| // Flatten tuples to array for serialization | |
| return [keys.0, keys.1, keys.2, values.0, values.1, values.2].compactMap { $0 } | |
| } | |
| } | |
| set { | |
| switch newValue.count { | |
| case 4: | |
| // nil biases case | |
| keys = (newValue[0], newValue[1], nil) | |
| values = (newValue[2], newValue[3], nil) | |
| case 6: | |
| keys = (newValue[0], newValue[1], newValue[2]) | |
| values = (newValue[3], newValue[4], newValue[5]) | |
| default: | |
| fatalError( | |
| "QuantizedKVCache state must have exactly 6 or 4 arrays (3/2 for keys, 3/2 for values)" | |
| ) | |
| } | |
| } | |
| } | |
| public override var metaState: [String] { | |
| get { [String(step), String(offset), String(groupSize), String(bits)] } | |
| set { | |
| guard newValue.count == 4 else { | |
| fatalError("QuantizedKVCache metaState must have exactly 4 values") | |
| } | |
| self.offset = Int(newValue[1]) ?? 0 | |
| } | |
| } | |
| public override var isTrimmable: Bool { true } | |
| @discardableResult | |
| public override func trim(_ n: Int) -> Int { | |
| let trimmed = min(offset, n) | |
| offset -= trimmed | |
| return trimmed | |
| } | |
| /// Convert to unquantized cache | |
| public func toUnquantized() -> KVCacheSimple { | |
| let simpleCache = KVCacheSimple() | |
| simpleCache.offset = self.offset | |
| if let keys = keys, let values = values { | |
| // Dequantize the current state using tree_map approach | |
| let currentKeys = treeMap({ $0[.ellipsis, ..<offset, 0...] }, keys) | |
| let currentValues = treeMap({ $0[.ellipsis, ..<offset, 0...] }, values) | |
| let dequantizedKeys = dequantized( | |
| currentKeys.0, scales: currentKeys.1, biases: currentKeys.2, | |
| groupSize: groupSize, bits: bits, mode: mode) | |
| let dequantizedValues = dequantized( | |
| currentValues.0, scales: currentValues.1, biases: currentValues.2, | |
| groupSize: groupSize, bits: bits, mode: mode) | |
| // Set the unquantized state | |
| simpleCache.state = [dequantizedKeys, dequantizedValues] | |
| } | |
| return simpleCache | |
| } | |
| } | |
| /// Chunked KV cache for processing large contexts in chunks | |
| public class ChunkedKVCache: KVCacheSimple { | |
| private var chunkSize: Int? | |
| private var startPosition: Int = 0 | |
| public init(chunkSize: Int? = nil) { | |
| self.chunkSize = chunkSize | |
| super.init() | |
| } | |
| public func maybeTrimFront() { | |
| guard let keys = self.keys, | |
| let chunkSize = chunkSize, | |
| keys.dim(2) >= chunkSize | |
| else { return } | |
| startPosition += keys.dim(2) - chunkSize | |
| self.keys = keys[.ellipsis, (-chunkSize)..., 0...] | |
| self.values = values?[.ellipsis, (-chunkSize)..., 0...] | |
| } | |
| public override func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| let prev = offset - startPosition | |
| if self.keys == nil || (prev + keys.dim(2)) > self.keys!.dim(2) { | |
| let B = keys.dim(0) | |
| let kvHeads = keys.dim(1) | |
| let kHeadDim = keys.dim(3) | |
| let vHeadDim = values.dim(3) | |
| let nSteps = (step + keys.dim(2) - 1) / step | |
| let kShape = [B, kvHeads, nSteps * step, kHeadDim] | |
| let vShape = [B, kvHeads, nSteps * step, vHeadDim] | |
| let newK = MLXArray.zeros(kShape, dtype: keys.dtype) | |
| let newV = MLXArray.zeros(vShape, dtype: values.dtype) | |
| if var currentKeys = self.keys, var currentValues = self.values { | |
| if prev % step != 0 { | |
| currentKeys = currentKeys[.ellipsis, ..<prev, 0...] | |
| currentValues = currentValues[.ellipsis, ..<prev, 0...] | |
| } | |
| self.keys = concatenated([currentKeys, newK], axis: 2) | |
| self.values = concatenated([currentValues, newV], axis: 2) | |
| } else { | |
| self.keys = newK | |
| self.values = newV | |
| } | |
| } | |
| offset += keys.dim(2) | |
| let end = offset - startPosition | |
| self.keys![.ellipsis, prev ..< end, 0...] = keys | |
| self.values![.ellipsis, prev ..< end, 0...] = values | |
| return (self.keys![.ellipsis, ..<end, 0...], self.values![.ellipsis, ..<end, 0...]) | |
| } | |
| @discardableResult | |
| public override func trim(_ n: Int) -> Int { | |
| let trimmed = min(offset - startPosition, n) | |
| offset -= trimmed | |
| return trimmed | |
| } | |
| public override var metaState: [String] { | |
| get { | |
| let chunkSizeStr = chunkSize?.description ?? "None" | |
| return [chunkSizeStr, String(startPosition)] | |
| } | |
| set { | |
| guard newValue.count == 2 else { | |
| fatalError("ChunkedKVCache metaState must have exactly 2 values") | |
| } | |
| if newValue[0] == "None" { | |
| self.chunkSize = nil | |
| } else { | |
| self.chunkSize = Int(newValue[0]) | |
| } | |
| self.startPosition = Int(newValue[1]) ?? 0 | |
| } | |
| } | |
| } | |
| /// Base cache for array-based state storage | |
| public class ArraysCache: BaseKVCache { | |
| private var cache: [MLXArray?] | |
| private var leftPadding: MLXArray? | |
| public init(size: Int, leftPadding: [Int]? = nil) { | |
| self.cache = Array(repeating: nil, count: size) | |
| self.leftPadding = leftPadding.map { MLXArray($0) } | |
| super.init() | |
| } | |
| public override func innerState() -> [MLXArray] { | |
| cache.compactMap { $0 } | |
| } | |
| public subscript(index: Int) -> MLXArray? { | |
| get { cache[index] } | |
| set { cache[index] = newValue } | |
| } | |
| public override var state: [MLXArray] { | |
| get { | |
| return cache.compactMap { $0 } | |
| } | |
| set { | |
| cache = newValue.map { $0 as MLXArray? } | |
| } | |
| } | |
| /// In-place filter to keep just the given indices in the cache | |
| public func filter(batchIndices: MLXArray) { | |
| cache = cache.map { c in | |
| c?[batchIndices] | |
| } | |
| leftPadding = nil | |
| } | |
| /// In-place extend this cache with the other cache | |
| public func extend(other: ArraysCache) { | |
| cache = zip(cache, other.cache).map { (c, o) in | |
| if let c = c, let o = o { | |
| return MLX.concatenated([c, o]) | |
| } | |
| return c ?? o | |
| } | |
| leftPadding = nil | |
| } | |
| /// Create attention mask based on left padding | |
| public func makeMask(N: Int) -> MLXArray? { | |
| if cache[0] == nil, let leftPadding = leftPadding { | |
| return MLXArray(0 ..< N) .>= leftPadding[0..., .newAxis] | |
| } else { | |
| return nil | |
| } | |
| } | |
| } | |
| /// Simple cache for Mamba-style state space models | |
| public class MambaCache: ArraysCache { | |
| public init(leftPadding: [Int]? = nil) { | |
| super.init(size: 2, leftPadding: leftPadding) | |
| } | |
| } | |
| /// Composite cache that manages multiple sub-caches | |
| public class CacheList: BaseKVCache { | |
| private var caches: [KVCache] | |
| public init(_ caches: KVCache...) { | |
| self.caches = caches | |
| super.init() | |
| } | |
| public override func innerState() -> [MLXArray] { | |
| caches.flatMap { $0.innerState() } | |
| } | |
| public subscript(index: Int) -> KVCache { | |
| return caches[index] | |
| } | |
| public override func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray) { | |
| fatalError("CacheList should not use update(keys:values:) - use subscript access instead") | |
| } | |
| public override var state: [MLXArray] { | |
| get { caches.flatMap { $0.state } } | |
| set { | |
| let stateLengths = caches.map { $0.state.count } | |
| var start = 0 | |
| for i in 0 ..< caches.count { | |
| let length = stateLengths[i] | |
| caches[i].state = Array(newValue[start ..< (start + length)]) | |
| start += length | |
| } | |
| } | |
| } | |
| public override var isTrimmable: Bool { | |
| caches.allSatisfy { $0.isTrimmable } | |
| } | |
| @discardableResult | |
| public override func trim(_ n: Int) -> Int { | |
| var result = 0 | |
| for cache in caches { | |
| result = cache.trim(n) | |
| } | |
| return result | |
| } | |
| } | |
| // MARK: - Error Types | |
| struct KVCacheError: Error { | |
| let message: String | |
| } | |
| // MARK: - Utility Functions | |
| /// Save a pre-computed prompt cache to a file. | |
| /// | |
| /// - Parameters: | |
| /// - url: The URL to the `.safetensors` file | |
| /// - cache: The model cache state | |
| /// - metadata: Optional metadata to save along with cache state | |
| public func savePromptCache( | |
| url: URL, | |
| cache: [KVCache], | |
| metadata: [String: String] = [:] | |
| ) throws { | |
| let cacheData = cache.map { $0.state } | |
| let cacheInfo = cache.map { $0.metaState } | |
| // Use Python-compatible class names for cross-platform compatibility | |
| let cacheClasses = cache.map { cache -> String in | |
| switch cache { | |
| case is KVCacheSimple: | |
| return "KVCache" // Python uses "KVCache" for the basic cache | |
| case is RotatingKVCache: | |
| return "RotatingKVCache" | |
| case is QuantizedKVCache: | |
| return "QuantizedKVCache" | |
| case is ChunkedKVCache: | |
| return "ChunkedKVCache" | |
| case is MambaCache: | |
| return "MambaCache" | |
| case is CacheList: | |
| return "CacheList" | |
| default: | |
| return "KVCache" // Default fallback | |
| } | |
| } | |
| // Flatten cache data using tree_flatten compatible structure: "i.j" format | |
| var flattenedData: [String: MLXArray] = [:] | |
| for (i, arrays) in cacheData.enumerated() { | |
| for (j, array) in arrays.enumerated() { | |
| flattenedData["\(i).\(j)"] = array | |
| } | |
| } | |
| // Create cache_metadata structure compatible with Python: [cache_info, metadata, cache_classes] | |
| var flattenedMetadata: [String: String] = [:] | |
| // Flatten cache_info as "0.i.j" (first element of cache_metadata) | |
| for (i, info) in cacheInfo.enumerated() { | |
| for (j, metaValue) in info.enumerated() { | |
| flattenedMetadata["0.\(i).\(j)"] = metaValue | |
| } | |
| } | |
| // Flatten user metadata as "1.key" (second element of cache_metadata) | |
| for (key, value) in metadata { | |
| flattenedMetadata["1.\(key)"] = value | |
| } | |
| // Flatten cache_classes as "2.i" (third element of cache_metadata) | |
| for (i, className) in cacheClasses.enumerated() { | |
| flattenedMetadata["2.\(i)"] = className | |
| } | |
| try save(arrays: flattenedData, metadata: flattenedMetadata, url: url) | |
| } | |
| /// Load a prompt cache from a file. | |
| /// | |
| /// - Parameters: | |
| /// - url: The URL to the `.safetensors` file | |
| /// - Returns: The prompt cache and the metadata | |
| public func loadPromptCache( | |
| url: URL | |
| ) throws -> ([KVCache], [String: String]?) { | |
| let (arrays, metadata) = try loadArraysAndMetadata(url: url) | |
| // Unflatten arrays using tree_unflatten compatible logic | |
| let cacheData = unflattenArrays(arrays) | |
| // Unflatten metadata using tree_unflatten compatible logic | |
| let unflattenedMetadata = unflattenMetadata(metadata) | |
| // Extract cache_info, user_metadata, and cache_classes from unflattened structure | |
| // Structure: [cache_info, user_metadata, cache_classes] | |
| guard unflattenedMetadata.count >= 3 else { | |
| throw KVCacheError(message: "Invalid cache metadata format") | |
| } | |
| let cacheInfo = unflattenedMetadata[0] as? [[String]] ?? [] | |
| let userMetadata = unflattenedMetadata[1] as? [String: String] ?? [:] | |
| let cacheClasses = unflattenedMetadata[2] as? [String] ?? [] | |
| guard cacheData.count == cacheInfo.count && cacheData.count == cacheClasses.count else { | |
| throw KVCacheError(message: "Mismatch in cache counts") | |
| } | |
| // Reconstruct cache instances | |
| var caches: [KVCache] = [] | |
| for i in 0 ..< cacheData.count { | |
| let className = cacheClasses[i] | |
| var cache: KVCache | |
| switch className { | |
| case "KVCache", "KVCacheSimple": // Handle both Python and Swift names | |
| cache = KVCacheSimple() | |
| case "RotatingKVCache": | |
| // Parse metaState first to get maxSize, then create cache | |
| let info = i < cacheInfo.count ? cacheInfo[i] : [] | |
| guard info.count >= 5 else { | |
| throw KVCacheError(message: "Invalid RotatingKVCache metaState - expected 5 values") | |
| } | |
| if info[1] == "None" { | |
| throw KVCacheError( | |
| message: | |
| "RotatingKVCache with maxSize=None is not supported. This cache was created with invalid parameters." | |
| ) | |
| } | |
| guard let maxSize = Int(info[1]) else { | |
| throw KVCacheError( | |
| message: "Failed to parse RotatingKVCache maxSize from: \(info[1])") | |
| } | |
| cache = RotatingKVCache(maxSize: maxSize) // Create with parsed maxSize | |
| case "QuantizedKVCache": | |
| cache = QuantizedKVCache() | |
| case "ChunkedKVCache": | |
| cache = ChunkedKVCache() | |
| case "MambaCache": | |
| cache = MambaCache() | |
| case "CacheList": | |
| // Note: CacheList requires special handling as it contains sub-caches | |
| // For now, create an empty CacheList - this may not work correctly | |
| // for complex cache hierarchies loaded from Python | |
| cache = CacheList() | |
| print("Warning: CacheList loading may not preserve sub-cache structure correctly") | |
| default: | |
| throw KVCacheError(message: "Unknown cache class: \(className)") | |
| } | |
| cache.state = cacheData[i] | |
| if i < cacheInfo.count { | |
| cache.metaState = cacheInfo[i] | |
| } | |
| caches.append(cache) | |
| } | |
| return (caches, userMetadata) | |
| } | |
| /// Unflatten arrays from tree_flatten format (e.g., "0.1", "1.0") to nested structure | |
| private func unflattenArrays(_ flatArrays: [String: MLXArray]) -> [[MLXArray]] { | |
| var arrayMap: [Int: [Int: MLXArray]] = [:] | |
| // Parse all keys and organize by indices | |
| for (key, array) in flatArrays { | |
| let components = key.split(separator: ".") | |
| if components.count >= 2, | |
| let i = Int(components[0]), | |
| let j = Int(components[1]) | |
| { | |
| if arrayMap[i] == nil { | |
| arrayMap[i] = [:] | |
| } | |
| arrayMap[i]![j] = array | |
| } | |
| } | |
| // Convert to ordered array structure | |
| var result: [[MLXArray]] = [] | |
| let maxI = arrayMap.keys.max() ?? -1 | |
| for i in 0 ... maxI { | |
| if let innerMap = arrayMap[i] { | |
| let maxJ = innerMap.keys.max() ?? -1 | |
| var innerArray: [MLXArray] = [] | |
| for j in 0 ... maxJ { | |
| if let array = innerMap[j] { | |
| innerArray.append(array) | |
| } | |
| } | |
| result.append(innerArray) | |
| } else { | |
| result.append([]) | |
| } | |
| } | |
| return result | |
| } | |
| /// Unflatten metadata from tree_flatten format to nested structure | |
| private func unflattenMetadata(_ flatMetadata: [String: String]) -> [Any] { | |
| var cacheInfo: [[String]] = [] | |
| var userMetadata: [String: String] = [:] | |
| var cacheClasses: [String] = [] | |
| for (key, value) in flatMetadata { | |
| let components = key.split(separator: ".") | |
| if components.count >= 3 && components[0] == "0" { | |
| // Cache info: "0.i.j" format | |
| if let i = Int(components[1]), let j = Int(components[2]) { | |
| // Ensure cacheInfo is large enough | |
| while cacheInfo.count <= i { | |
| cacheInfo.append([]) | |
| } | |
| // Ensure inner array is large enough | |
| while cacheInfo[i].count <= j { | |
| cacheInfo[i].append("") | |
| } | |
| cacheInfo[i][j] = value | |
| } | |
| } else if components.count >= 2 && components[0] == "1" { | |
| // User metadata: "1.key" format | |
| let metaKey = components.dropFirst().joined(separator: ".") | |
| userMetadata[metaKey] = value | |
| } else if components.count >= 2 && components[0] == "2" { | |
| // Cache classes: "2.i" format | |
| if let i = Int(components[1]) { | |
| // Ensure cacheClasses is large enough | |
| while cacheClasses.count <= i { | |
| cacheClasses.append("") | |
| } | |
| cacheClasses[i] = value | |
| } | |
| } | |
| } | |
| return [cacheInfo, userMetadata, cacheClasses] | |
| } | |
| /// Construct the model's cache for use when generating. | |
| /// | |
| /// This function will defer the cache construction to the model if it has a | |
| /// `newCache` method, otherwise it will make a default KV cache. | |
| public func makePromptCache( | |
| model: any LanguageModel, | |
| parameters: GenerateParameters? = nil | |
| ) -> [KVCache] { | |
| // The model already conforms to LanguageModel which has newCache | |
| // If it also conforms to KVCacheDimensionProvider, the extension will provide the implementation | |
| return model.newCache(parameters: parameters) | |
| } | |
| /// Legacy function for backwards compatibility | |
| public func makePromptCache( | |
| model: any LanguageModel, | |
| maxKVSize: Int? = nil | |
| ) -> [KVCache] { | |
| let parameters = maxKVSize.map { GenerateParameters(maxKVSize: $0) } | |
| return makePromptCache(model: model, parameters: parameters) | |
| } | |
| /// Fallback function to create cache when layer count is known | |
| /// | |
| /// This function creates a default cache structure when the number of layers is known. | |
| /// Use this when `makePromptCache` cannot determine the layer count automatically. | |
| public func makePromptCacheWithLayerCount( | |
| numLayers: Int, | |
| maxKVSize: Int? = nil | |
| ) -> [KVCache] { | |
| if let maxKVSize = maxKVSize { | |
| return (0 ..< numLayers).map { _ in | |
| RotatingKVCache(maxSize: maxKVSize, keep: 4) | |
| } | |
| } else { | |
| return (0 ..< numLayers).map { _ in KVCacheSimple() } | |
| } | |
| } | |
| /// Check if model's cache can be trimmed. | |
| public func canTrimPromptCache(_ cache: [KVCache]) -> Bool { | |
| return cache.allSatisfy { $0.isTrimmable } | |
| } | |
| /// Trim the model's cache by the given number of tokens. | |
| /// | |
| /// This function will trim the cache if possible (in-place) and return the | |
| /// number of tokens that were trimmed. | |
| @discardableResult | |
| public func trimPromptCache(_ cache: [KVCache], numTokens: Int) -> Int { | |
| guard canTrimPromptCache(cache), !cache.isEmpty else { return 0 } | |
| return cache.first?.trim(numTokens) ?? 0 | |
| } | |
| // MARK: - Type Aliases | |
| /// Standard KV cache - alias to KVCacheSimple for compatibility | |
| public typealias StandardKVCache = KVCacheSimple | |
| // MARK: - Quantized Attention Operations | |
| public func quantizedScaledDotProductAttention( | |
| queries: MLXArray, | |
| quantizedKeys: (MLXArray, MLXArray, MLXArray?), | |
| quantizedValues: (MLXArray, MLXArray, MLXArray?), | |
| scale: Float, | |
| mask: MLXFast.ScaledDotProductAttentionMaskMode = .none, | |
| groupSize: Int = 64, | |
| bits: Int = 8, | |
| mode: QuantizationMode = .affine | |
| ) -> MLXArray { | |
| let (B, nQHeads, L, D) = (queries.dim(0), queries.dim(1), queries.dim(2), queries.dim(3)) | |
| let nKVHeads = quantizedKeys.0.dim(-3) | |
| let nRepeats = nQHeads / nKVHeads | |
| // Scale queries | |
| var scaledQueries = queries * scale | |
| // Handle GQA (Grouped Query Attention) | |
| var qKeys = quantizedKeys | |
| var qValues = quantizedValues | |
| if nRepeats > 1 { | |
| scaledQueries = scaledQueries.reshaped([B, nKVHeads, nRepeats, L, D]) | |
| qKeys = ( | |
| expandedDimensions(qKeys.0, axis: -3), | |
| expandedDimensions(qKeys.1, axis: -3), | |
| qKeys.2 == nil ? nil : expandedDimensions(qKeys.2!, axis: -3) | |
| ) | |
| qValues = ( | |
| expandedDimensions(qValues.0, axis: -3), | |
| expandedDimensions(qValues.1, axis: -3), | |
| qValues.2 == nil ? nil : expandedDimensions(qValues.2!, axis: -3) | |
| ) | |
| } | |
| // Compute attention scores using quantized matmul | |
| var scores = quantizedMatmul( | |
| scaledQueries, qKeys.0, scales: qKeys.1, biases: qKeys.2, | |
| transpose: true, groupSize: groupSize, bits: bits, | |
| mode: mode | |
| ) | |
| // Apply mask | |
| switch mask { | |
| case .causal: | |
| let (qL, kL) = (scores.dim(-2), scores.dim(-1)) | |
| let qIndices = MLXArray(0 ..< qL) + MLXArray(kL - qL) | |
| let kIndices = MLXArray(0 ..< kL) | |
| let causalMask = greaterEqual( | |
| expandedDimensions(qIndices, axis: -1), expandedDimensions(kIndices, axis: -2)) | |
| scores = MLX.where(causalMask, scores, MLXArray(Float.leastNormalMagnitude)) | |
| case .array(let maskArray): | |
| if maskArray.dtype == .bool { | |
| scores = MLX.where(maskArray, scores, MLXArray(Float.leastNormalMagnitude)) | |
| } else { | |
| scores = scores + maskArray | |
| } | |
| case .arrays(let maskArrays): | |
| // Handle multiple mask arrays - just use the first one for simplicity | |
| if let maskArray = maskArrays.first { | |
| if maskArray.dtype == .bool { | |
| scores = MLX.where(maskArray, scores, MLXArray(Float.leastNormalMagnitude)) | |
| } else { | |
| scores = scores + maskArray | |
| } | |
| } | |
| case .none: | |
| break | |
| } | |
| let attentionWeights = softmax(scores, axis: -1) | |
| // Compute output using quantized matmul | |
| var output = quantizedMatmul( | |
| attentionWeights, qValues.0, scales: qValues.1, biases: qValues.2, | |
| transpose: false, groupSize: groupSize, bits: bits, | |
| mode: mode | |
| ) | |
| // Reshape output for GQA | |
| if nRepeats > 1 { | |
| output = output.reshaped([B, nQHeads, L, D]) | |
| } | |
| return output | |
| } | |
| // MARK: - Dynamic Cache Quantization | |
| /// Dynamically quantize KV caches during generation if conditions are met | |
| /// | |
| /// Converts regular caches to quantized caches when: | |
| /// - kvBits is specified | |
| /// - The cache is not already quantized | |
| /// - The cache offset is greater than quantizedKVStart | |
| /// | |
| /// - Parameters: | |
| /// - cache: Array of KV caches to potentially quantize | |
| /// - kvBits: Number of bits for quantization (nil = no quantization) | |
| /// - kvGroupSize: Group size for quantization | |
| /// - quantizedKVStart: Token count threshold to begin quantizing | |
| public func maybeQuantizeKVCache( | |
| cache: inout [KVCache], | |
| kvBits: Int?, | |
| kvGroupSize: Int = 64, | |
| quantizedKVStart: Int = 0 | |
| ) { | |
| guard let kvBits = kvBits, | |
| !cache.isEmpty, | |
| !(cache[0] is QuantizedKVCache), | |
| cache[0].offset > quantizedKVStart | |
| else { | |
| return | |
| } | |
| for i in 0 ..< cache.count { | |
| // Handle cache types that support quantization | |
| if let simpleCache = cache[i] as? KVCacheSimple { | |
| cache[i] = simpleCache.toQuantized(groupSize: kvGroupSize, bits: kvBits) | |
| } | |
| // TODO: RotatingKVCache.toQuantized() is not implemented yet, like in Python. | |
| // When implemented, add: else if let rotatingCache = cache[i] as? RotatingKVCache { ... } | |
| // MambaCache and CacheList don't use traditional KV quantization | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // main.swift | |
| // mlx-test | |
| // | |
| // Created by Prince Canuma on 27/12/25. | |
| // | |
| import Foundation | |
| import MLX | |
| import HuggingFace | |
| import Tokenizers | |
| import MLXFast | |
| import MLXNN | |
| import Combine | |
| // MARK: - StringOrNumber Helper | |
| public enum StringOrNumber: Codable, Sendable, Equatable { | |
| case string(String) | |
| case int(Int) | |
| case float(Float) | |
| case double(Double) | |
| public init(from decoder: Swift.Decoder) throws { | |
| let container = try decoder.singleValueContainer() | |
| if let stringValue = try? container.decode(String.self) { | |
| self = .string(stringValue) | |
| } else if let intValue = try? container.decode(Int.self) { | |
| self = .int(intValue) | |
| } else if let doubleValue = try? container.decode(Double.self) { | |
| self = .double(doubleValue) | |
| } else if let floatValue = try? container.decode(Float.self) { | |
| self = .float(floatValue) | |
| } else { | |
| throw DecodingError.typeMismatch( | |
| StringOrNumber.self, | |
| DecodingError.Context( | |
| codingPath: container.codingPath, | |
| debugDescription: "Expected String, Int, Float, or Double" | |
| ) | |
| ) | |
| } | |
| } | |
| public func encode(to encoder: Swift.Encoder) throws { | |
| var container = encoder.singleValueContainer() | |
| switch self { | |
| case .string(let value): | |
| try container.encode(value) | |
| case .int(let value): | |
| try container.encode(value) | |
| case .float(let value): | |
| try container.encode(value) | |
| case .double(let value): | |
| try container.encode(value) | |
| } | |
| } | |
| public func asFloat() -> Float? { | |
| switch self { | |
| case .float(let value): | |
| return value | |
| case .double(let value): | |
| return Float(value) | |
| case .int(let value): | |
| return Float(value) | |
| case .string: | |
| return nil | |
| } | |
| } | |
| public func asString() -> String? { | |
| switch self { | |
| case .string(let value): | |
| return value | |
| default: | |
| return nil | |
| } | |
| } | |
| } | |
| // MARK: - Configuration | |
| public struct Qwen3Configuration: Codable, Sendable { | |
| var hiddenSize: Int | |
| var hiddenLayers: Int | |
| var intermediateSize: Int | |
| var eosTokenId: Int | |
| var attentionHeads: Int | |
| var rmsNormEps: Float | |
| var vocabularySize: Int | |
| var quantization: BaseConfiguration.Quantization? | |
| var perLayerQuantization: BaseConfiguration.PerLayerQuantization? | |
| var kvHeads: Int | |
| var ropeTheta: Float = 1_000_000 | |
| var headDim: Int | |
| var ropeScaling: [String: StringOrNumber]? = nil | |
| var tieWordEmbeddings = false | |
| var maxPositionEmbeddings: Int = 32768 | |
| enum CodingKeys: String, CodingKey { | |
| case hiddenSize = "hidden_size" | |
| case eosTokenId = "eos_token_id" | |
| case hiddenLayers = "num_hidden_layers" | |
| case intermediateSize = "intermediate_size" | |
| case attentionHeads = "num_attention_heads" | |
| case rmsNormEps = "rms_norm_eps" | |
| case vocabularySize = "vocab_size" | |
| case quantization = "quantization" | |
| case quantization_config = "quantization_config" | |
| case kvHeads = "num_key_value_heads" | |
| case ropeTheta = "rope_theta" | |
| case headDim = "head_dim" | |
| case ropeScaling = "rope_scaling" | |
| case tieWordEmbeddings = "tie_word_embeddings" | |
| case maxPositionEmbeddings = "max_position_embeddings" | |
| } | |
| public init(from decoder: Swift.Decoder) throws { | |
| // custom implementation to handle optional keys with required values | |
| let container: KeyedDecodingContainer<Qwen3Configuration.CodingKeys> = | |
| try decoder.container( | |
| keyedBy: Qwen3Configuration.CodingKeys.self) | |
| self.hiddenSize = try container.decode( | |
| Int.self, forKey: Qwen3Configuration.CodingKeys.hiddenSize) | |
| self.eosTokenId = try container.decode( | |
| Int.self, forKey: Qwen3Configuration.CodingKeys.eosTokenId) | |
| self.hiddenLayers = try container.decode( | |
| Int.self, forKey: Qwen3Configuration.CodingKeys.hiddenLayers) | |
| self.intermediateSize = try container.decode( | |
| Int.self, forKey: Qwen3Configuration.CodingKeys.intermediateSize) | |
| self.attentionHeads = try container.decode( | |
| Int.self, forKey: Qwen3Configuration.CodingKeys.attentionHeads) | |
| self.rmsNormEps = try container.decode( | |
| Float.self, forKey: Qwen3Configuration.CodingKeys.rmsNormEps) | |
| self.vocabularySize = try container.decode( | |
| Int.self, forKey: Qwen3Configuration.CodingKeys.vocabularySize) | |
| self.kvHeads = try container.decode(Int.self, forKey: Qwen3Configuration.CodingKeys.kvHeads) | |
| self.ropeTheta = | |
| try container.decodeIfPresent( | |
| Float.self, forKey: Qwen3Configuration.CodingKeys.ropeTheta) | |
| ?? 1_000_000 | |
| self.headDim = try container.decode( | |
| Int.self, forKey: Qwen3Configuration.CodingKeys.headDim) | |
| self.ropeScaling = try container.decodeIfPresent( | |
| [String: StringOrNumber].self, forKey: Qwen3Configuration.CodingKeys.ropeScaling) | |
| self.tieWordEmbeddings = | |
| try container.decodeIfPresent(Bool.self, forKey: .tieWordEmbeddings) ?? false | |
| self.maxPositionEmbeddings = | |
| try container.decodeIfPresent(Int.self, forKey: .maxPositionEmbeddings) ?? 32768 | |
| // MARK: - Decode Quantization | |
| let baseConfig = try? BaseConfiguration(from: decoder) | |
| self.quantization = baseConfig?.quantization | |
| self.perLayerQuantization = baseConfig?.perLayerQuantization | |
| } | |
| public func encode(to encoder: Swift.Encoder) throws { | |
| var container = encoder.container(keyedBy: CodingKeys.self) | |
| try container.encode(hiddenSize, forKey: .hiddenSize) | |
| try container.encode(eosTokenId, forKey: .eosTokenId) | |
| try container.encode(hiddenLayers, forKey: .hiddenLayers) | |
| try container.encode(intermediateSize, forKey: .intermediateSize) | |
| try container.encode(attentionHeads, forKey: .attentionHeads) | |
| try container.encode(rmsNormEps, forKey: .rmsNormEps) | |
| try container.encode(vocabularySize, forKey: .vocabularySize) | |
| try container.encode(kvHeads, forKey: .kvHeads) | |
| try container.encode(ropeTheta, forKey: .ropeTheta) | |
| try container.encode(headDim, forKey: .headDim) | |
| try container.encodeIfPresent(ropeScaling, forKey: .ropeScaling) | |
| try container.encode(tieWordEmbeddings, forKey: .tieWordEmbeddings) | |
| try container.encode(maxPositionEmbeddings, forKey: .maxPositionEmbeddings) | |
| try container.encodeIfPresent(quantization, forKey: .quantization) | |
| // Note: perLayerQuantization doesn't have a direct coding key, so we skip it | |
| } | |
| } | |
| // MARK: - Attention | |
| public class Attention: Module { | |
| let args: Qwen3Configuration | |
| let scale: Float | |
| @ModuleInfo(key: "q_proj") var wq: Linear | |
| @ModuleInfo(key: "k_proj") var wk: Linear | |
| @ModuleInfo(key: "v_proj") var wv: Linear | |
| @ModuleInfo(key: "o_proj") var wo: Linear | |
| @ModuleInfo(key: "q_norm") var qNorm: RMSNorm | |
| @ModuleInfo(key: "k_norm") var kNorm: RMSNorm | |
| let rope: RoPE | |
| public init(_ args: Qwen3Configuration) { | |
| self.args = args | |
| let dim = args.hiddenSize | |
| let heads = args.attentionHeads | |
| let kvHeads = args.kvHeads | |
| let headDim = args.headDim | |
| self.scale = pow(Float(headDim), -0.5) | |
| self._wq.wrappedValue = Linear(dim, heads * headDim, bias: false) | |
| self._wk.wrappedValue = Linear(dim, kvHeads * headDim, bias: false) | |
| self._wv.wrappedValue = Linear(dim, kvHeads * headDim, bias: false) | |
| self._wo.wrappedValue = Linear(heads * headDim, dim, bias: false) | |
| self._qNorm.wrappedValue = RMSNorm(dimensions: headDim, eps: args.rmsNormEps) | |
| self._kNorm.wrappedValue = RMSNorm(dimensions: headDim, eps: args.rmsNormEps) | |
| let ropeScale: Float | |
| if let ropeScaling = args.ropeScaling, ropeScaling["type"] == .string("linear"), | |
| let factor = ropeScaling["factor"] | |
| { | |
| if let v = factor.asFloat() { | |
| ropeScale = 1 / v | |
| } else { | |
| fatalError("ropeScaling.factor must be a Float") | |
| } | |
| } else { | |
| ropeScale = 1 | |
| } | |
| self.rope = RoPE( | |
| dimensions: headDim, traditional: false, base: args.ropeTheta, scale: ropeScale | |
| ) | |
| } | |
| public func callAsFunction( | |
| _ x: MLXArray, mask: MLXFast.ScaledDotProductAttentionMaskMode, cache: KVCache? | |
| ) -> MLXArray { | |
| let (B, L) = (x.dim(0), x.dim(1)) | |
| var queries = wq(x) | |
| var keys = wk(x) | |
| var values = wv(x) | |
| queries = qNorm(queries.reshaped(B, L, args.attentionHeads, -1)).transposed(0, 2, 1, 3) | |
| keys = kNorm(keys.reshaped(B, L, args.kvHeads, -1)).transposed(0, 2, 1, 3) | |
| values = values.reshaped(B, L, args.kvHeads, -1).transposed(0, 2, 1, 3) | |
| if let cache { | |
| queries = rope(queries, offset: cache.offset) | |
| keys = rope(keys, offset: cache.offset) | |
| } else { | |
| queries = rope(queries) | |
| keys = rope(keys) | |
| } | |
| let output = MLXFast.scaledDotProductAttention( | |
| queries: queries, | |
| keys: keys, | |
| values: values, | |
| scale: scale, | |
| mask: mask | |
| ).transposed(0, 2, 1, 3).reshaped(B, L, -1) | |
| return wo( | |
| output | |
| ) | |
| } | |
| } | |
| // MARK: - MLP | |
| private class MLP: Module { | |
| @ModuleInfo(key: "gate_proj") var gate: Linear | |
| @ModuleInfo(key: "down_proj") var down: Linear | |
| @ModuleInfo(key: "up_proj") var up: Linear | |
| public init(dimensions: Int, hiddenDimensions: Int) { | |
| self._gate.wrappedValue = Linear(dimensions, hiddenDimensions, bias: false) | |
| self._down.wrappedValue = Linear(hiddenDimensions, dimensions, bias: false) | |
| self._up.wrappedValue = Linear(dimensions, hiddenDimensions, bias: false) | |
| } | |
| public func callAsFunction(_ x: MLXArray) -> MLXArray { | |
| return down(silu(gate(x)) * up(x)) | |
| } | |
| } | |
| private class TransformerBlock: Module { | |
| @ModuleInfo(key: "self_attn") var attention: Attention | |
| let mlp: MLP | |
| @ModuleInfo(key: "input_layernorm") var inputLayerNorm: RMSNorm | |
| @ModuleInfo(key: "post_attention_layernorm") var postAttentionLayerNorm: RMSNorm | |
| public init(_ args: Qwen3Configuration) { | |
| self._attention.wrappedValue = Attention(args) | |
| self.mlp = MLP(dimensions: args.hiddenSize, hiddenDimensions: args.intermediateSize) | |
| self._inputLayerNorm.wrappedValue = RMSNorm(dimensions: args.hiddenSize, eps: args.rmsNormEps) | |
| self._postAttentionLayerNorm.wrappedValue = RMSNorm(dimensions: args.hiddenSize, eps: args.rmsNormEps) | |
| } | |
| public func callAsFunction( | |
| _ x: MLXArray, mask: MLXFast.ScaledDotProductAttentionMaskMode, cache: KVCache? | |
| ) -> MLXArray { | |
| var r = attention(inputLayerNorm(x), mask: mask, cache: cache) | |
| let h = x + r | |
| r = mlp(postAttentionLayerNorm(h)) | |
| return h + r | |
| } | |
| } | |
| private class Qwen3ModelInner: Module { | |
| @ModuleInfo(key: "embed_tokens") var embedTokens: Embedding | |
| fileprivate let layers: [TransformerBlock] | |
| let norm: RMSNorm | |
| public init(_ args: Qwen3Configuration) { | |
| precondition(args.vocabularySize > 0) | |
| self._embedTokens.wrappedValue = Embedding( | |
| embeddingCount: args.vocabularySize, | |
| dimensions: args.hiddenSize | |
| ) | |
| self.layers = (0..<args.hiddenLayers) | |
| .map { _ in TransformerBlock(args) } | |
| self.norm = RMSNorm(dimensions: args.hiddenSize, eps: args.rmsNormEps) | |
| } | |
| public func callAsFunction(_ inputs: MLXArray, cache: [KVCache]? = nil) -> MLXArray { | |
| var h = embedTokens(inputs) | |
| let mask = createAttentionMask(h: h, cache: cache?.first) | |
| for (i, layer) in layers.enumerated() { | |
| h = layer(h, mask: mask, cache: cache?[i]) | |
| } | |
| return norm(h) | |
| } | |
| } | |
| public class Qwen3Model: Module, LanguageModel, KVCacheDimensionProvider { | |
| public let vocabularySize: Int | |
| public let kvHeads: [Int] | |
| private let model: Qwen3ModelInner | |
| let configuration: Qwen3Configuration | |
| @ModuleInfo(key: "lm_head") var lmHead: Linear? | |
| // KVCacheDimensionProvider conformance | |
| public var numLayers: Int { | |
| configuration.hiddenLayers | |
| } | |
| public init(_ args: Qwen3Configuration){ | |
| self.configuration = args | |
| self.vocabularySize = args.vocabularySize | |
| self.kvHeads = (0..<args.hiddenLayers).map {_ in args.kvHeads} | |
| self.model = Qwen3ModelInner(args) | |
| if !args.tieWordEmbeddings { | |
| self._lmHead.wrappedValue = Linear(args.hiddenSize, args.vocabularySize, bias: false) | |
| } | |
| } | |
| public func callAsFunction(_ inputs: MLXArray, cache: [KVCache]? = nil) -> MLXArray { | |
| var out = model(inputs, cache: cache) | |
| if let lmHead { | |
| out = lmHead(out) | |
| } else { | |
| out = model.embedTokens.asLinear(out) | |
| } | |
| return out | |
| } | |
| public func sanitize(weights: [String: MLXArray]) -> [String: MLXArray] { | |
| var weights = weights | |
| if configuration.tieWordEmbeddings { | |
| weights["lm_head.weight"] = nil | |
| } | |
| return weights | |
| } | |
| public static func fromPretrained(_ model_repo: String = "Qwen/Qwen3-0.6") async throws -> Qwen3Model { | |
| let client = HubClient.default | |
| let snapshotDir = FileManager.default.temporaryDirectory | |
| let progress = Progress(totalUnitCount: 0) | |
| Task { | |
| for await value in progress.publisher(for: \.fractionCompleted).values { | |
| print("Snapshot download progress: \(value * 100)%") | |
| } | |
| } | |
| guard let repoID = Repo.ID(rawValue: model_repo) else { | |
| throw NSError(domain: "Qwen3Model", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid repository ID: \(model_repo)"]) | |
| } | |
| let modelDir = try await client.downloadSnapshot( | |
| of: repoID, | |
| kind: .model, | |
| to: snapshotDir, | |
| revision: "main", | |
| progressHandler: { progress in | |
| // Accurate progress per file | |
| print("\(progress.completedUnitCount)/\(progress.totalUnitCount) files") | |
| }) | |
| let configPath = snapshotDir.appendingPathComponent("config.json") | |
| let configData = try Data(contentsOf: configPath) | |
| let config = try JSONDecoder().decode(Qwen3Configuration.self, from: configData) | |
| let perLayerQuantization = config.perLayerQuantization | |
| let model = Qwen3Model(config) | |
| // Load weights from safetensors | |
| let weights = try loadWeights(from: modelDir) | |
| let sanitizedWeights = model.sanitize(weights: weights) | |
| // Quantize if needed | |
| if perLayerQuantization != nil { | |
| print("Applying quantizaiton from config...") | |
| if let perLayerQuant = perLayerQuantization { | |
| print(" Per-layer: \(perLayerQuant)") | |
| } | |
| quantize(model: model) { path, module in | |
| // Only quantize if scales exist for this layer | |
| if weights["\(path).scales"] != nil { | |
| return perLayerQuantization?.quantization(layer: path)?.asTuple | |
| } else { | |
| return nil | |
| } | |
| } | |
| } | |
| try model.update(parameters: ModuleParameters.unflattened(sanitizedWeights), verify: [.all]) | |
| return model | |
| } | |
| } | |
| func loadWeights(from directory: URL) throws -> [String: MLXArray] { | |
| let fileManager = FileManager.default | |
| let files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) | |
| let safetensorFiles = files.filter { $0.pathExtension == "safetensors" } | |
| var weights: [String: MLXArray] = [:] | |
| for file in safetensorFiles { | |
| let fileWeights = try MLX.loadArrays(url: file) | |
| weights.merge(fileWeights) { _, new in new } | |
| } | |
| return weights | |
| } | |
| // Main entry point | |
| Task { | |
| do { | |
| let modelRepo = "mlx-community/Qwen3-0.6B-4bit" | |
| // let modelRepo = "Qwen/Qwen3-0.6b" | |
| let model = try await Qwen3Model.fromPretrained(modelRepo) | |
| let configuration = model.configuration | |
| let eosTokenId = configuration.eosTokenId | |
| print("Model loaded successfully!") | |
| let tokenizer = try await AutoTokenizer.from(pretrained: modelRepo) | |
| let tokens = tokenizer.encode(text: "<|im_start|>user\nHi there!<|im_end|>\n<|im_start|>assistant\n<think>") | |
| var inputIds = MLXArray(tokens).expandedDimensions(axis: 0) | |
| print(inputIds) | |
| print("Tokenizer loaded successfully!") | |
| let cache = (0..<configuration.hiddenLayers).map { _ in RotatingKVCache(maxSize: 2048, keep: 4)} | |
| print("Cache initialized") | |
| var output: MLXArray! | |
| var nextToken: MLXArray! | |
| // MARK: Generate | |
| for i in 0..<500 { | |
| output = model(inputIds, cache: cache as [any KVCache]) | |
| // Get logits for the last token in the sequence: shape [batch, vocab_size] | |
| let lastTokenLogits = output[0..., -1, 0...] | |
| // Get the token with highest probability: shape [batch] | |
| nextToken = argMax(lastTokenLogits, axis: -1) | |
| // Expand dimensions to match inputIds shape [batch, 1] | |
| nextToken = nextToken.expandedDimensions(axis: 1) | |
| // Eval y | |
| eval(nextToken!) | |
| if nextToken.squeezed().item(Int.self) == eosTokenId { | |
| break | |
| } | |
| // Decode the token | |
| let decodedText = tokenizer.decode(tokens: [nextToken.squeezed().item(Int.self)]) | |
| print(decodedText, terminator: "") | |
| // Concatenate along sequence dimension | |
| inputIds = concatenated([inputIds, nextToken], axis: 1) | |
| if i != 4 { | |
| GPU.clearCache() | |
| } | |
| } | |
| print() // Final newline | |
| print("DONE !") | |
| } catch { | |
| print("Failed to load model: \(error)") | |
| } | |
| } | |
| // Keep the program running to allow async tasks to complete | |
| RunLoop.main.run() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment