Skip to content

Commit f837d96

Browse files
stikvessukru tikves
authored andcommitted
Add state handlers for hybrid model support (>2 states)
Both engines previously hardcoded exactly 2 states (key_cache + value_cache). Models with sliding window caches, recurrent states, or other persistent states beyond the KV pair would fail at load. StateHandler protocol + implementations: - GrowingNDArrayState: dynamic KV cache with capacity doubling - FixedNDArrayState: static states (sliding caches, conv/recurrent) - FixedMTLBufferState: MTLBuffer states for pipelined engine - StateHandlerFactory: shape-based classification with metadata override Engine changes: - Sequential: uses StateHandlerFactory, supports 2-4 states - Pipelined: additionalStates?.insertAll(into:) at all encode sites - Both: hasNonTruncatableStates guard forces full reset for hybrid models - LanguageConfig: optional "states" field for explicit classification
1 parent aa3bbf6 commit f837d96

8 files changed

Lines changed: 760 additions & 140 deletions

File tree

swift/Sources/CoreAILanguageModels/Bundle/LanguageConfig.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,26 @@ public struct LanguageConfig: Codable, Sendable, Equatable {
2525
/// Vision-specific configuration. Nil for text-only language models.
2626
public let vision: VisionConfig?
2727

28+
/// Explicit state classification. Nil = use shape-based heuristic.
29+
/// Keys are state names from the model descriptor, values are StateKind.
30+
public let states: [String: StateKind]?
31+
2832
public init(
2933
tokenizer: String,
3034
vocabSize: Int,
3135
maxContextLength: Int,
3236
embeddedTokenizer: Bool = true,
3337
functionMap: FunctionMap? = nil,
34-
vision: VisionConfig? = nil
38+
vision: VisionConfig? = nil,
39+
states: [String: StateKind]? = nil
3540
) {
3641
self.tokenizer = tokenizer
3742
self.vocabSize = vocabSize
3843
self.maxContextLength = maxContextLength
3944
self.embeddedTokenizer = embeddedTokenizer
4045
self.functionMap = functionMap
4146
self.vision = vision
47+
self.states = states
4248
}
4349

4450
enum CodingKeys: String, CodingKey {
@@ -48,6 +54,7 @@ public struct LanguageConfig: Codable, Sendable, Equatable {
4854
case embeddedTokenizer = "embedded_tokenizer"
4955
case functionMap = "function_map"
5056
case vision
57+
case states
5158
}
5259

5360
public init(from decoder: Swift.Decoder) throws {
@@ -58,6 +65,7 @@ public struct LanguageConfig: Codable, Sendable, Equatable {
5865
self.embeddedTokenizer = try c.decodeIfPresent(Bool.self, forKey: .embeddedTokenizer) ?? true
5966
self.functionMap = try c.decodeIfPresent(FunctionMap.self, forKey: .functionMap)
6067
self.vision = try c.decodeIfPresent(VisionConfig.self, forKey: .vision)
68+
self.states = try c.decodeIfPresent([String: StateKind].self, forKey: .states)
6169
}
6270

6371
// MARK: - Additional Stop Tokens
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2026 Apple Inc.
2+
//
3+
// Use of this source code is governed by a BSD-3-clause license that can
4+
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
5+
6+
import CoreAI
7+
import CoreAIShared
8+
import Metal
9+
10+
/// Fixed-size MTLBuffer state for non-truncatable persistent states.
11+
/// Allocated once at init, zero-initialized, never grows.
12+
public struct FixedMTLBufferState {
13+
public let stateNames: [String]
14+
public var stateCount: Int { bindings.count }
15+
16+
private var bindings:
17+
[(name: String, buffer: MTLBuffer, scalarType: NDArray.ScalarType, shape: [Int], strides: [Int])]
18+
19+
public init(
20+
states: [(name: String, descriptor: NDArrayDescriptor)],
21+
device: MTLDevice
22+
) throws {
23+
var bindings: [(String, MTLBuffer, NDArray.ScalarType, [Int], [Int])] = []
24+
for (name, desc) in states {
25+
guard !desc.shape.contains(where: { $0 < 0 }) else {
26+
throw InferenceRuntimeError.invalidOutputType(
27+
"FixedMTLBufferState '\(name)' has dynamic shape \(desc.shape)")
28+
}
29+
let resolved = desc.resolvingDynamicDimensions(desc.shape)
30+
let strides = resolved.preferredStrides
31+
let byteCount = resolved.minimumByteCount
32+
guard let buffer = device.makeBuffer(length: max(byteCount, 64), options: .storageModeShared) else {
33+
throw InferenceRuntimeError.bufferAllocationFailed("\(name) (\(byteCount) bytes)")
34+
}
35+
memset(buffer.contents(), 0, buffer.length)
36+
bindings.append((name, buffer, desc.scalarType, desc.shape, strides))
37+
}
38+
self.bindings = bindings
39+
self.stateNames = states.map(\.name)
40+
}
41+
42+
/// Access a state binding by index for AsyncMutableValue construction.
43+
/// The engine builds AsyncMutableValue views from these in the same scope as encode().
44+
/// Note: MTLBuffer is a reference type — the returned buffer is shared, not copied.
45+
public subscript(stateIndex index: Int) -> (
46+
name: String, buffer: MTLBuffer, scalarType: NDArray.ScalarType, shape: [Int], strides: [Int]
47+
) {
48+
get { bindings[index] }
49+
}
50+
51+
/// Insert all states into AsyncMutableViews for pipelined encoding.
52+
public func insertAll(into views: inout InferenceFunction.AsyncMutableViews) {
53+
for (name, buffer, scalarType, shape, strides) in bindings {
54+
var value = unsafe InferenceFunction.AsyncMutableValue(
55+
unsafeBuffer: buffer, byteOffset: 0,
56+
scalarType: scalarType, shape: shape, strides: strides)
57+
views.insert(&value, for: name)
58+
}
59+
}
60+
61+
/// Zero all state buffers. Caller must ensure no in-flight GPU work references these.
62+
public mutating func reset() {
63+
for (_, buffer, _, _, _) in bindings {
64+
memset(buffer.contents(), 0, buffer.length)
65+
}
66+
}
67+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// Copyright 2026 Apple Inc.
2+
//
3+
// Use of this source code is governed by a BSD-3-clause license that can
4+
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
5+
6+
import CoreAI
7+
import CoreAIShared
8+
import Darwin
9+
10+
// MARK: - Fixed NDArray State
11+
12+
/// Fixed-size state for non-truncatable persistent states.
13+
/// Allocated at full size on init, zero-initialized. No capacity management needed.
14+
public struct FixedNDArrayState: SyncStateHandler {
15+
public let stateNames: [String]
16+
public let supportsTruncation: Bool = false
17+
public let currentCapacity: Int = .max
18+
public var stateCount: Int { arrays.count }
19+
20+
private var arrays: [(name: String, array: NDArray)]
21+
22+
public init(states: [(name: String, descriptor: NDArrayDescriptor)]) {
23+
var arrays: [(String, NDArray)] = []
24+
for (name, desc) in states {
25+
var array = NDArray(descriptor: desc)
26+
zeroFillNDArray(&array)
27+
arrays.append((name, array))
28+
}
29+
self.arrays = arrays
30+
self.stateNames = states.map(\.name)
31+
}
32+
33+
public mutating func ensureCapacity(forContextLength contextLength: Int) throws -> Bool {
34+
false
35+
}
36+
37+
public subscript(stateIndex index: Int) -> (name: String, array: NDArray) {
38+
get { arrays[index] }
39+
set { arrays[index] = newValue }
40+
}
41+
42+
public mutating func reset() {
43+
for i in arrays.indices {
44+
zeroFillNDArray(&arrays[i].array)
45+
}
46+
}
47+
48+
public mutating func truncate(to tokenCount: Int) {
49+
preconditionFailure("truncate(to:) called on non-truncatable FixedNDArrayState")
50+
}
51+
}
52+
53+
// MARK: - Growing NDArray State
54+
55+
/// Dynamically-growing KV cache state. Starts small and doubles capacity
56+
/// when more context is needed.
57+
public struct GrowingNDArrayState: SyncStateHandler {
58+
public let stateNames: [String]
59+
public let supportsTruncation: Bool = true
60+
public private(set) var currentCapacity: Int
61+
public var stateCount: Int { arrays.count }
62+
63+
private var arrays: [(name: String, array: NDArray)]
64+
private let descriptors: [NDArrayDescriptor]
65+
private let maxCapacity: Int
66+
private let sequenceDimIndex: Int
67+
68+
public init(
69+
states: [(name: String, descriptor: NDArrayDescriptor)],
70+
initialCapacity: Int,
71+
maxCapacity: Int
72+
) {
73+
self.maxCapacity = maxCapacity
74+
self.descriptors = states.map(\.descriptor)
75+
self.stateNames = states.map(\.name)
76+
77+
let firstDesc = states[0].descriptor
78+
self.sequenceDimIndex = firstDesc.shape.firstIndex(where: { $0 < 0 }) ?? max(0, firstDesc.shape.count - 2)
79+
80+
let capacity = min(initialCapacity, maxCapacity)
81+
self.currentCapacity = capacity
82+
83+
var arrays: [(String, NDArray)] = []
84+
for (name, desc) in states {
85+
let resolved = desc.resolvingDynamicDimensions(
86+
desc.shape.map { $0 < 0 ? capacity : $0 })
87+
arrays.append((name, NDArray(descriptor: resolved)))
88+
}
89+
self.arrays = arrays
90+
}
91+
92+
public mutating func ensureCapacity(forContextLength contextLength: Int) throws -> Bool {
93+
guard contextLength > currentCapacity else { return false }
94+
guard contextLength <= maxCapacity else {
95+
throw InferenceRuntimeError.invalidState(
96+
"Context length \(contextLength) exceeds maximum \(maxCapacity)")
97+
}
98+
99+
var newCapacity = max(currentCapacity, 1)
100+
while newCapacity < contextLength {
101+
newCapacity = min(newCapacity * 2, maxCapacity)
102+
}
103+
104+
for i in arrays.indices {
105+
let desc = descriptors[i]
106+
let newShape = desc.shape.map { $0 < 0 ? newCapacity : $0 }
107+
let resolvedDesc = desc.resolvingDynamicDimensions(newShape)
108+
var newArray = NDArray(descriptor: resolvedDesc)
109+
// Force backing allocation before copy
110+
_ = newArray.mutableRawView()
111+
112+
copyCache(from: arrays[i].array, to: &newArray, sequenceDim: sequenceDimIndex)
113+
arrays[i].array = newArray
114+
}
115+
116+
currentCapacity = newCapacity
117+
return true
118+
}
119+
120+
public subscript(stateIndex index: Int) -> (name: String, array: NDArray) {
121+
get { arrays[index] }
122+
set { arrays[index] = newValue }
123+
}
124+
125+
public mutating func reset() {
126+
for i in arrays.indices {
127+
zeroFillNDArray(&arrays[i].array)
128+
}
129+
}
130+
131+
public mutating func truncate(to tokenCount: Int) {
132+
// KV cache truncation is a no-op on the backing storage.
133+
// The causal mask hides positions beyond processedTokenCount.
134+
}
135+
136+
// MARK: - Private
137+
138+
private func copyCache(from source: NDArray, to destination: inout NDArray, sequenceDim: Int) {
139+
let srcShape = source.shape
140+
let dstShape = destination.shape
141+
guard let headDim = srcShape.last else { return }
142+
143+
let numBlocks = srcShape[..<sequenceDim].reduce(1, *)
144+
let oldSeqLen = srcShape[sequenceDim]
145+
let copyElements = oldSeqLen * headDim
146+
let srcBlockStride = srcShape[sequenceDim...].reduce(1, *)
147+
let dstBlockStride = dstShape[sequenceDim...].reduce(1, *)
148+
149+
switch source.scalarType {
150+
case .float16, .bfloat16:
151+
source.view(as: Float16.self).withUnsafePointer { srcPtr, _, _ in
152+
var dstView = destination.mutableView(as: Float16.self)
153+
dstView.withUnsafeMutablePointer { dstPtr, _, _ in
154+
for block in 0..<numBlocks {
155+
dstPtr.advanced(by: block * dstBlockStride).update(
156+
from: srcPtr.advanced(by: block * srcBlockStride), count: copyElements)
157+
}
158+
}
159+
}
160+
case .float32:
161+
source.view(as: Float.self).withUnsafePointer { srcPtr, _, _ in
162+
var dstView = destination.mutableView(as: Float.self)
163+
dstView.withUnsafeMutablePointer { dstPtr, _, _ in
164+
for block in 0..<numBlocks {
165+
dstPtr.advanced(by: block * dstBlockStride).update(
166+
from: srcPtr.advanced(by: block * srcBlockStride), count: copyElements)
167+
}
168+
}
169+
}
170+
default:
171+
preconditionFailure("Unsupported scalar type for state copy: \(source.scalarType)")
172+
}
173+
}
174+
}
175+
176+
// MARK: - Shared Utilities
177+
178+
/// Zero-initialize an NDArray, dispatching on scalar type.
179+
fileprivate func zeroFillNDArray(_ array: inout NDArray) {
180+
let count = array.shape.reduce(1, *)
181+
switch array.scalarType {
182+
case .float16, .bfloat16:
183+
var view = array.mutableView(as: Float16.self)
184+
view.withUnsafeMutablePointer { ptr, _, _ in
185+
memset(ptr, 0, count * MemoryLayout<Float16>.size)
186+
}
187+
case .float32:
188+
var view = array.mutableView(as: Float.self)
189+
view.withUnsafeMutablePointer { ptr, _, _ in
190+
memset(ptr, 0, count * MemoryLayout<Float>.size)
191+
}
192+
default:
193+
preconditionFailure("Unsupported scalar type for state: \(array.scalarType)")
194+
}
195+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright 2026 Apple Inc.
2+
//
3+
// Use of this source code is governed by a BSD-3-clause license that can
4+
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
5+
6+
import CoreAI
7+
8+
/// Persistent model state that the engine carries across inference steps.
9+
///
10+
/// Each handler manages one or more named state tensors (e.g., key_cache + value_cache,
11+
/// or a single convolution_state). The engine owns an array of handlers and delegates
12+
/// all state lifecycle to them — allocation, growth, and reset.
13+
///
14+
/// ## MutableViews Lifetime Constraint
15+
///
16+
/// `InferenceFunction.MutableViews.insert` creates a lifetime dependency on each `inout`
17+
/// variable. This means state binding CANNOT be abstracted into a method that takes
18+
/// `inout MutableViews` — the inserts must happen in the same scope as `function.run()`.
19+
/// Handlers therefore expose their arrays directly via subscript for the engine to bind.
20+
public protocol SyncStateHandler {
21+
/// Names of the states managed by this handler.
22+
var stateNames: [String] { get }
23+
24+
/// Number of state arrays managed.
25+
var stateCount: Int { get }
26+
27+
/// Current capacity in the sequence/context dimension.
28+
/// For fixed-size states this equals max capacity.
29+
var currentCapacity: Int { get }
30+
31+
/// Whether this state supports in-place truncation (cursor rewind).
32+
/// KV cache: true (causal mask hides positions beyond the cursor).
33+
/// Recurrent/conv: false (no independent token axis).
34+
var supportsTruncation: Bool { get }
35+
36+
/// Ensure the state can accommodate `contextLength` tokens.
37+
/// Returns true if reallocation occurred.
38+
mutating func ensureCapacity(forContextLength contextLength: Int) throws -> Bool
39+
40+
/// Access a state array by index for binding into MutableViews.
41+
/// The engine calls this to get name + array pairs for insert.
42+
subscript(stateIndex index: Int) -> (name: String, array: NDArray) { get set }
43+
44+
/// Full reset — zero all backing storage, rewind to position 0.
45+
mutating func reset()
46+
47+
/// Truncate to a given token position.
48+
/// Only valid when `supportsTruncation == true`. For KV cache, this is a no-op
49+
/// on the backing storage (causal mask handles visibility); the engine just
50+
/// updates its processedTokenCount.
51+
mutating func truncate(to tokenCount: Int)
52+
}

0 commit comments

Comments
 (0)