Skip to content

Commit 1677713

Browse files
authored
Add bind(into:) for zero-copy state binding with loop-based insert (#156)
State handlers are now classes (AnyObject) that own their NDArrays at refcount 1. bind(into:) inserts all states into MutableViews in a loop using stdlib _overrideLifetime to express disjoint element access. No COW, no switch on state count, no write-back needed. FixedMTLBufferState also gains bind(into:) for AsyncMutableViews, eliminating the 3x duplicated switch blocks in the pipelined engine. Engine call sites: runWithStates (sequential) and encodeWithStates (pipelined) are now thin wrappers around bind + function.run/encode.
1 parent 82a0e5e commit 1677713

9 files changed

Lines changed: 261 additions & 303 deletions

File tree

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ let package = Package(
5757
swiftSettings: [
5858
.define("CXGRAMMAR_IMPORT"),
5959
.enableUpcomingFeature("MemberImportVisibility"),
60+
.enableExperimentalFeature("Lifetimes"),
6061
],
6162
linkerSettings: [
6263
.linkedLibrary("c++")
@@ -225,6 +226,9 @@ let package = Package(
225226
resources: [
226227
.copy("Resources/MinimalTokenizer")
227228
],
229+
swiftSettings: [
230+
.enableExperimentalFeature("Lifetimes"),
231+
],
228232
linkerSettings: [
229233
.linkedLibrary("c++")
230234
]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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 Metal
8+
9+
/// Encode an inference step with KV cache states, optional additional MTLBuffer
10+
/// states, and logits output.
11+
func encodeWithStates(
12+
function: InferenceFunction,
13+
inputs: [String: InferenceFunction.AsyncValue],
14+
keyState: inout InferenceFunction.AsyncMutableValue,
15+
keyCacheName: String,
16+
valState: inout InferenceFunction.AsyncMutableValue,
17+
valueCacheName: String,
18+
additionalStates: FixedMTLBufferState?,
19+
logitsBuffer: MTLBuffer,
20+
logitsName: String,
21+
logitsShape: [Int],
22+
logitsStrides: [Int],
23+
computeStream: ComputeStream
24+
) throws {
25+
var asyncStates = InferenceFunction.AsyncMutableViews()
26+
asyncStates.insert(&keyState, for: keyCacheName)
27+
asyncStates.insert(&valState, for: valueCacheName)
28+
additionalStates?.bind(into: &asyncStates)
29+
30+
var logitsOutput = unsafe InferenceFunction.AsyncMutableValue(
31+
unsafeBuffer: logitsBuffer, byteOffset: 0,
32+
scalarType: .float16, shape: logitsShape, strides: logitsStrides)
33+
var asyncOutputs = InferenceFunction.AsyncMutableViews()
34+
asyncOutputs.insert(&logitsOutput, for: logitsName)
35+
let _ = try function.encode(
36+
inputs: inputs, states: consume asyncStates,
37+
outputViews: consume asyncOutputs, to: computeStream)
38+
}

swift/Sources/CoreAILanguageModels/Handlers/StateHandler+MTLBuffer.swift

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import CoreAI
77
import CoreAIShared
88
import Metal
99

10-
/// Fixed-size MTLBuffer state for non-truncatable persistent states.
10+
/// Fixed-size MTLBuffer state for non-truncatable persistent states (pipelined engine).
1111
/// Allocated once at init, zero-initialized, never grows.
12-
public struct FixedMTLBufferState {
12+
public final class FixedMTLBufferState {
1313
public let stateNames: [String]
1414
public var stateCount: Int { bindings.count }
1515

@@ -29,7 +29,8 @@ public struct FixedMTLBufferState {
2929
let resolved = desc.resolvingDynamicDimensions(desc.shape)
3030
let strides = resolved.preferredStrides
3131
let byteCount = resolved.minimumByteCount
32-
guard let buffer = device.makeBuffer(length: max(byteCount, 64), options: .storageModeShared) else {
32+
guard let buffer = device.makeBuffer(length: max(byteCount, 64), options: .storageModeShared)
33+
else {
3334
throw InferenceRuntimeError.bufferAllocationFailed("\(name) (\(byteCount) bytes)")
3435
}
3536
memset(buffer.contents(), 0, buffer.length)
@@ -39,17 +40,21 @@ public struct FixedMTLBufferState {
3940
self.stateNames = states.map(\.name)
4041
}
4142

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] }
43+
/// Insert all managed states into async mutable views. MTLBuffer is a reference
44+
/// type (no COW). Uses _overrideLifetime for disjoint element access in the loop.
45+
@_lifetime(views: borrow self)
46+
public func bind(into views: inout InferenceFunction.AsyncMutableViews) {
47+
for binding in bindings {
48+
var value = unsafe InferenceFunction.AsyncMutableValue(
49+
unsafeBuffer: binding.buffer, byteOffset: 0,
50+
scalarType: binding.scalarType, shape: binding.shape, strides: binding.strides)
51+
views.insert(&value, for: binding.name)
52+
views = unsafe _overrideLifetime(consume views, borrowing: self)
53+
}
4954
}
5055

5156
/// Zero all state buffers. Caller must ensure no in-flight GPU work references these.
52-
public mutating func reset() {
57+
public func reset() {
5358
for (_, buffer, _, _, _) in bindings {
5459
memset(buffer.contents(), 0, buffer.length)
5560
}

swift/Sources/CoreAILanguageModels/Handlers/StateHandler+NDArray.swift

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,56 +11,63 @@ import Darwin
1111

1212
/// Fixed-size state for non-truncatable persistent states.
1313
/// Allocated at full size on init, zero-initialized. No capacity management needed.
14-
public struct FixedNDArrayState: SyncStateHandler {
14+
public final class FixedNDArrayState: SyncStateHandler {
1515
public let stateNames: [String]
1616
public let supportsTruncation: Bool = false
1717
public let currentCapacity: Int = .max
1818
public var stateCount: Int { arrays.count }
1919

20-
private var arrays: [(name: String, array: NDArray)]
20+
private var arrays: [String: NDArray]
2121

2222
public init(states: [(name: String, descriptor: NDArrayDescriptor)]) {
23-
var arrays: [(String, NDArray)] = []
23+
var arrays: [String: NDArray] = [:]
2424
for (name, desc) in states {
2525
var array = NDArray(descriptor: desc)
2626
zeroFillNDArray(&array)
27-
arrays.append((name, array))
27+
arrays[name] = array
2828
}
2929
self.arrays = arrays
3030
self.stateNames = states.map(\.name)
3131
}
3232

33-
public mutating func ensureCapacity(forContextLength contextLength: Int) throws -> Bool {
33+
public func ensureCapacity(forContextLength contextLength: Int) throws -> Bool {
3434
false
3535
}
3636

3737
public subscript(stateIndex index: Int) -> (name: String, array: NDArray) {
38-
get { arrays[index] }
39-
set { arrays[index] = newValue }
38+
get { (stateNames[index], arrays[stateNames[index]]!) }
39+
set { arrays[stateNames[index]] = newValue.array }
4040
}
4141

42-
public mutating func reset() {
43-
for i in arrays.indices {
44-
zeroFillNDArray(&arrays[i].array)
42+
@_lifetime(views: borrow self)
43+
public func bind(into views: inout InferenceFunction.MutableViews) {
44+
for name in stateNames {
45+
let view = _overrideLifetime(arrays[name]!.mutableRawView(), borrowing: Void())
46+
views.insert(view, for: name)
4547
}
4648
}
4749

48-
public mutating func truncate(to tokenCount: Int) {
50+
public func reset() {
51+
for name in stateNames {
52+
zeroFillNDArray(&arrays[name]!)
53+
}
54+
}
55+
56+
public func truncate(to tokenCount: Int) {
4957
preconditionFailure("truncate(to:) called on non-truncatable FixedNDArrayState")
5058
}
5159
}
5260

5361
// MARK: - Growing NDArray State
5462

55-
/// Dynamically-growing KV cache state. Starts small and doubles capacity
56-
/// when more context is needed.
57-
public struct GrowingNDArrayState: SyncStateHandler {
63+
/// Dynamically-growing KV cache state. Starts small and doubles capacity.
64+
public final class GrowingNDArrayState: SyncStateHandler {
5865
public let stateNames: [String]
5966
public let supportsTruncation: Bool = true
6067
public private(set) var currentCapacity: Int
6168
public var stateCount: Int { arrays.count }
6269

63-
private var arrays: [(name: String, array: NDArray)]
70+
private var arrays: [String: NDArray]
6471
private let descriptors: [NDArrayDescriptor]
6572
private let maxCapacity: Int
6673
private let sequenceDimIndex: Int
@@ -80,16 +87,16 @@ public struct GrowingNDArrayState: SyncStateHandler {
8087
let capacity = min(initialCapacity, maxCapacity)
8188
self.currentCapacity = capacity
8289

83-
var arrays: [(String, NDArray)] = []
90+
var arrays: [String: NDArray] = [:]
8491
for (name, desc) in states {
8592
let resolved = desc.resolvingDynamicDimensions(
8693
desc.shape.map { $0 < 0 ? capacity : $0 })
87-
arrays.append((name, NDArray(descriptor: resolved)))
94+
arrays[name] = NDArray(descriptor: resolved)
8895
}
8996
self.arrays = arrays
9097
}
9198

92-
public mutating func ensureCapacity(forContextLength contextLength: Int) throws -> Bool {
99+
public func ensureCapacity(forContextLength contextLength: Int) throws -> Bool {
93100
guard contextLength > currentCapacity else { return false }
94101
guard contextLength <= maxCapacity else {
95102
throw InferenceRuntimeError.invalidState(
@@ -101,38 +108,41 @@ public struct GrowingNDArrayState: SyncStateHandler {
101108
newCapacity = min(newCapacity * 2, maxCapacity)
102109
}
103110

104-
for i in arrays.indices {
111+
for (i, name) in stateNames.enumerated() {
105112
let desc = descriptors[i]
106113
let newShape = desc.shape.map { $0 < 0 ? newCapacity : $0 }
107114
let resolvedDesc = desc.resolvingDynamicDimensions(newShape)
108115
var newArray = NDArray(descriptor: resolvedDesc)
109-
// Force backing allocation before copy
110116
_ = newArray.mutableRawView()
111-
112-
copyCache(from: arrays[i].array, to: &newArray, sequenceDim: sequenceDimIndex)
113-
arrays[i].array = newArray
117+
copyCache(from: arrays[name]!, to: &newArray, sequenceDim: sequenceDimIndex)
118+
arrays[name] = newArray
114119
}
115120

116121
currentCapacity = newCapacity
117122
return true
118123
}
119124

120125
public subscript(stateIndex index: Int) -> (name: String, array: NDArray) {
121-
get { arrays[index] }
122-
set { arrays[index] = newValue }
126+
get { (stateNames[index], arrays[stateNames[index]]!) }
127+
set { arrays[stateNames[index]] = newValue.array }
123128
}
124129

125-
public mutating func reset() {
126-
for i in arrays.indices {
127-
zeroFillNDArray(&arrays[i].array)
130+
@_lifetime(views: borrow self)
131+
public func bind(into views: inout InferenceFunction.MutableViews) {
132+
for name in stateNames {
133+
let view = _overrideLifetime(arrays[name]!.mutableRawView(), borrowing: Void())
134+
views.insert(view, for: name)
128135
}
129136
}
130137

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.
138+
public func reset() {
139+
for name in stateNames {
140+
zeroFillNDArray(&arrays[name]!)
141+
}
134142
}
135143

144+
public func truncate(to tokenCount: Int) {}
145+
136146
// MARK: - Private
137147

138148
private func copyCache(from source: NDArray, to destination: inout NDArray, sequenceDim: Int) {
@@ -175,7 +185,6 @@ public struct GrowingNDArrayState: SyncStateHandler {
175185

176186
// MARK: - Shared Utilities
177187

178-
/// Zero-initialize an NDArray, dispatching on scalar type.
179188
func zeroFillNDArray(_ array: inout NDArray) {
180189
let count = array.shape.reduce(1, *)
181190
switch array.scalarType {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
/// Run an inference step with combined primary + secondary states and output.
9+
/// Zero-copy: bind(into:) uses reference-backed storage and _overrideLifetime.
10+
func runWithStates(
11+
function: InferenceFunction,
12+
inputs: [String: NDArray],
13+
primary: any SyncStateHandler,
14+
secondary: FixedNDArrayState?,
15+
outputArray: inout NDArray,
16+
outputName: String
17+
) async throws {
18+
var states = InferenceFunction.MutableViews()
19+
primary.bind(into: &states)
20+
secondary?.bind(into: &states)
21+
22+
var outputViews = InferenceFunction.MutableViews()
23+
outputViews.insert(&outputArray, for: outputName)
24+
25+
_ = try await function.run(
26+
inputs: inputs,
27+
states: _unsafeEscapeMutableViews(consume states),
28+
outputViews: consume outputViews)
29+
}

swift/Sources/CoreAILanguageModels/Handlers/StateHandler.swift

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,13 @@ import CoreAI
88
/// Persistent model state that the engine carries across inference steps.
99
///
1010
/// 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.
11+
/// or a single convolution_state). The engine owns handlers and delegates all state
12+
/// lifecycle to them — allocation, growth, and reset.
1313
///
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 {
14+
/// Handlers are classes (AnyObject) so they own their NDArrays at refcount 1 —
15+
/// `bind(into:)` calls `mutableRawView()` without triggering COW. The loop uses
16+
/// `_overrideLifetime` to express disjoint element access to the compiler.
17+
public protocol SyncStateHandler: AnyObject {
2118
/// Names of the states managed by this handler.
2219
var stateNames: [String] { get }
2320

@@ -35,18 +32,32 @@ public protocol SyncStateHandler {
3532

3633
/// Ensure the state can accommodate `contextLength` tokens.
3734
/// Returns true if reallocation occurred.
38-
mutating func ensureCapacity(forContextLength contextLength: Int) throws -> Bool
35+
func ensureCapacity(forContextLength contextLength: Int) throws -> Bool
3936

40-
/// Access a state array by index for binding into MutableViews.
41-
/// The engine calls this to get name + array pairs for insert.
37+
/// Access a state array by name+index (value copy — use bind(into:) on hot paths).
4238
subscript(stateIndex index: Int) -> (name: String, array: NDArray) { get set }
4339

40+
/// Insert all managed states into `views`. Zero-copy: uses reference-backed
41+
/// storage internally, so mutableRawView() never triggers COW.
42+
@_lifetime(views: borrow self)
43+
func bind(into views: inout InferenceFunction.MutableViews)
44+
4445
/// Full reset — zero all backing storage, rewind to position 0.
45-
mutating func reset()
46+
func reset()
4647

4748
/// 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)
49+
func truncate(to tokenCount: Int)
50+
}
51+
52+
// MARK: - Lifetime Helpers
53+
54+
/// Detach lifetime dependencies from MutableViews so it can cross scope
55+
/// boundaries (closures, await). Caller must ensure inserted arrays remain valid.
56+
@inline(__always)
57+
@_unsafeNonescapableResult
58+
@_lifetime(immortal)
59+
func _unsafeEscapeMutableViews(
60+
_ views: consuming InferenceFunction.MutableViews
61+
) -> InferenceFunction.MutableViews {
62+
views
5263
}

0 commit comments

Comments
 (0)