Skip to content

Commit f3ce8a3

Browse files
committed
Add SyncInputHandler protocol and InputContext for engine extensibility
Introduces a composable input preparation layer for inference engines: - SyncInputHandler protocol: prepares named NDArray inputs each step - InputContext: carries tokens, position, batch size, sliding window - InputContext.dynamic() for sequential/pipelined engines - InputContext.static() for static-shape (ANE) engines - TokenInputHandler: standard token ID input with batch size caching - CompositeInputHandler: wraps a base handler with extra inputs (RoPE, PLE) - InputCoverage.verify(): fail-fast check at engine init - PipelinedTokenInputHandler: MTLBuffer-based for pipelined engine Also hardens NDArray helpers with stride-aware fill/read: - fillNDArray(count:using:) now checks contiguity and falls back to stride-aware indexing for 4D+ tensors with GPU alignment padding - readNDArray checks contiguity similarly - TokenInputHandler.prepare() validates non-empty batch
1 parent 2ee0327 commit f3ce8a3

6 files changed

Lines changed: 417 additions & 41 deletions

File tree

Package.resolved

Lines changed: 19 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Asynchronous token input handler for the Pipelined engine (MTLBuffer-based).
2+
//
3+
// Copyright 2026 Apple Inc.
4+
//
5+
// Use of this source code is governed by a BSD-3-clause license that can
6+
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
7+
8+
import CoreAI
9+
import CoreAIShared
10+
import Metal
11+
12+
/// Input handler for the pipelined engine. Owns the token and position MTLBuffers,
13+
/// handles buffer rotation, and the prefill/decode source split.
14+
///
15+
/// `decodeOutputBuffers` is shared with the engine (GPU sampler writes next token
16+
/// there; this handler reads the previous step's token during decode).
17+
struct PipelinedTokenInputHandler {
18+
let inputIdsName: String
19+
let positionIdsName: String
20+
let inputIdsDescriptor: NDArrayDescriptor
21+
let positionIdsDescriptor: NDArrayDescriptor
22+
23+
let inputTokensBuffer: MTLBuffer
24+
let cachePositionBuffers: [MTLBuffer]
25+
let decodeOutputBuffers: [MTLBuffer]
26+
let pipelineDepth: Int
27+
28+
/// Build async input values for one encode step.
29+
///
30+
/// - Prefill (`!tokens.isEmpty`): writes tokens to `inputTokensBuffer` at their
31+
/// natural position (disjoint from prior chunks still in-flight on GPU).
32+
/// - Decode (`tokens.isEmpty`): reads from previous step's `decodeOutputBuffer`.
33+
func prepare(
34+
tokens: some Collection<Int32>,
35+
processedTokenCount: Int,
36+
step: Int
37+
) throws -> [String: InferenceFunction.AsyncValue] {
38+
let queryLength = tokens.isEmpty ? 1 : tokens.count
39+
40+
// Write tokens (prefill only — decode reads from GPU sampler output)
41+
if !tokens.isEmpty {
42+
let dst = inputTokensBuffer.contents()
43+
.assumingMemoryBound(to: Int32.self)
44+
.advanced(by: processedTokenCount)
45+
let tokenArray = Array(tokens)
46+
tokenArray.withUnsafeBufferPointer { src in
47+
memcpy(dst, src.baseAddress!, tokenArray.count * MemoryLayout<Int32>.size)
48+
}
49+
}
50+
51+
// Token input
52+
let tokenShape = [1, queryLength]
53+
let tokenStrides = try resolvedStrides(descriptor: inputIdsDescriptor, shape: tokenShape)
54+
let tokenValue: InferenceFunction.AsyncValue
55+
if tokens.isEmpty {
56+
tokenValue = unsafe InferenceFunction.AsyncValue(
57+
unsafeBuffer: decodeOutputBuffers[(step + pipelineDepth - 1) % pipelineDepth],
58+
byteOffset: 0, scalarType: .int32, shape: tokenShape, strides: tokenStrides)
59+
} else {
60+
tokenValue = unsafe InferenceFunction.AsyncValue(
61+
unsafeBuffer: inputTokensBuffer,
62+
byteOffset: processedTokenCount * MemoryLayout<Int32>.size,
63+
scalarType: .int32, shape: tokenShape, strides: tokenStrides)
64+
}
65+
66+
// Position input (rotating buffer)
67+
let posLength = processedTokenCount + queryLength
68+
let posShape = [1, posLength]
69+
let posStrides = try resolvedStrides(descriptor: positionIdsDescriptor, shape: posShape)
70+
let posValue = unsafe InferenceFunction.AsyncValue(
71+
unsafeBuffer: cachePositionBuffers[step % pipelineDepth],
72+
byteOffset: 0, scalarType: .int32, shape: posShape, strides: posStrides)
73+
74+
return [
75+
inputIdsName: tokenValue,
76+
positionIdsName: posValue,
77+
]
78+
}
79+
80+
/// Prepare inputs for warmup (always prefill mode, writes dummy tokens).
81+
func prepareWarmup(
82+
shape: Int,
83+
processedTokenCount: Int,
84+
step: Int
85+
) throws -> [String: InferenceFunction.AsyncValue] {
86+
let ptr = inputTokensBuffer.contents().assumingMemoryBound(to: Int32.self)
87+
for i in 0..<shape { ptr[i] = 1 }
88+
89+
let tokenShape = [1, shape]
90+
let tokenStrides = try resolvedStrides(descriptor: inputIdsDescriptor, shape: tokenShape)
91+
let tokenValue = unsafe InferenceFunction.AsyncValue(
92+
unsafeBuffer: inputTokensBuffer, byteOffset: 0,
93+
scalarType: .int32, shape: tokenShape, strides: tokenStrides)
94+
95+
let posLength = processedTokenCount + shape
96+
let posShape = [1, posLength]
97+
let posStrides = try resolvedStrides(descriptor: positionIdsDescriptor, shape: posShape)
98+
let posValue = unsafe InferenceFunction.AsyncValue(
99+
unsafeBuffer: cachePositionBuffers[step % pipelineDepth],
100+
byteOffset: 0, scalarType: .int32, shape: posShape, strides: posStrides)
101+
102+
return [
103+
inputIdsName: tokenValue,
104+
positionIdsName: posValue,
105+
]
106+
}
107+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Synchronous token input handler for Sequential and StaticShape engines.
2+
//
3+
// Copyright 2026 Apple Inc.
4+
//
5+
// Use of this source code is governed by a BSD-3-clause license that can
6+
// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause
7+
8+
import CoreAI
9+
import CoreAIShared
10+
11+
/// Standard input handler for text LLMs: `input_ids` (Int32) + `position_ids` (Int32).
12+
///
13+
/// Pre-allocates the `input_ids` NDArray and reuses it when batch size is unchanged.
14+
public struct TokenInputHandler: SyncInputHandler {
15+
public let inputNames: [String]
16+
17+
private let inputIdsName: String
18+
private let positionIdsName: String
19+
private let inputIdsDescriptor: NDArrayDescriptor
20+
private let positionIdsDescriptor: NDArrayDescriptor
21+
22+
private var inputIdsArray: NDArray
23+
private var cachedBatchSize: Int
24+
25+
public init(
26+
inputIdsName: String,
27+
positionIdsName: String,
28+
inputIdsDescriptor: NDArrayDescriptor,
29+
positionIdsDescriptor: NDArrayDescriptor
30+
) {
31+
self.inputIdsName = inputIdsName
32+
self.positionIdsName = positionIdsName
33+
self.inputIdsDescriptor = inputIdsDescriptor
34+
self.positionIdsDescriptor = positionIdsDescriptor
35+
self.inputNames = [inputIdsName, positionIdsName]
36+
37+
let initDesc = inputIdsDescriptor.resolvingDynamicDimensions([1, 1])
38+
self.inputIdsArray = NDArray(descriptor: initDesc)
39+
self.cachedBatchSize = 1
40+
}
41+
42+
public mutating func prepare(_ context: InputContext) async throws -> [String: NDArray] {
43+
let tokens = context.tokens
44+
let batchSize = tokens.count
45+
precondition(batchSize > 0, "TokenInputHandler: empty token batch")
46+
47+
if cachedBatchSize != batchSize {
48+
let resolved = inputIdsDescriptor.resolvingDynamicDimensions([1, batchSize])
49+
inputIdsArray = NDArray(descriptor: resolved)
50+
cachedBatchSize = batchSize
51+
}
52+
fillNDArray(&inputIdsArray, as: Int32.self, with: tokens)
53+
54+
let totalPositions = context.processedTokenCount + batchSize
55+
let resolvedPosDesc = positionIdsDescriptor.resolvingDynamicDimensions([1, totalPositions])
56+
var positionIds = NDArray(descriptor: resolvedPosDesc)
57+
fillNDArray(&positionIds, as: Int32.self, count: totalPositions) { Int32($0) }
58+
59+
return [
60+
inputIdsName: inputIdsArray,
61+
positionIdsName: positionIds,
62+
]
63+
}
64+
}
65+
66+
/// Wraps a base input handler and appends model-specific extra inputs.
67+
///
68+
/// Use for any input that needs per-step computation beyond standard token/position IDs.
69+
public struct CompositeInputHandler<Base: SyncInputHandler>: SyncInputHandler {
70+
public var inputNames: [String] {
71+
base.inputNames + extras.map(\.name)
72+
}
73+
74+
private var base: Base
75+
private let extras: [ExtraInput]
76+
77+
public struct ExtraInput: Sendable {
78+
public let name: String
79+
public let prepare: @Sendable (InputContext) throws -> NDArray
80+
81+
public init(name: String, prepare: @Sendable @escaping (InputContext) throws -> NDArray) {
82+
self.name = name
83+
self.prepare = prepare
84+
}
85+
}
86+
87+
public init(base: Base, extras: [ExtraInput]) {
88+
self.base = base
89+
self.extras = extras
90+
}
91+
92+
public mutating func prepare(_ context: InputContext) async throws -> [String: NDArray] {
93+
var inputs = try await base.prepare(context)
94+
for extra in extras {
95+
inputs[extra.name] = try extra.prepare(context)
96+
}
97+
return inputs
98+
}
99+
}

0 commit comments

Comments
 (0)