Skip to content

Commit d4edbfb

Browse files
committed
Add GPU-based constrained sampling for pipelined engine
Enables grammar-constrained generation (JSON schema) on the pipelined engine by applying the xgrammar bitmask inside the MPSGraph sampler on GPU, eliminating the forced fallback to the slower sequential engine. - Add bitmask expansion graph to MPSGraphArgmaxSampler and MPSGraphCompositeSampler (bitwise AND + notEqual + cast, zero measurable latency overhead) - Add ConstrainedGenerationSession.fillBitmask(into:) for zero-copy write into shared MTLBuffer - Add CoreAIPipelinedEngine.generateConstrained() with semi-pipelined loop (inference overlaps bitmask computation) - Add PipelinedConstrainedDecodingStrategy for the GPU path - Route pipelined engine to GPU strategy in CoreAILanguageModel
1 parent aff0bb2 commit d4edbfb

7 files changed

Lines changed: 1113 additions & 85 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 CoreAIShared
7+
import Foundation
8+
import Tokenizers
9+
10+
/// Grammar-constrained decoding strategy using the GPU pipelined engine.
11+
///
12+
/// Unlike `ConstrainedDecodingStrategy` which forces the sequential engine path
13+
/// (CPU logits -> mask -> CPU sample), this strategy applies the xgrammar bitmask
14+
/// directly inside the MPSGraph sampler on GPU -- eliminating the ~296KB logit
15+
/// transfer and CPU-side softmax/sampling per token.
16+
///
17+
/// Only used when the engine is `CoreAIPipelinedEngine`. The routing logic in
18+
/// `CoreAILanguageModel` selects this strategy based on engine type.
19+
public struct PipelinedConstrainedDecodingStrategy: DecodingStrategy {
20+
private let jsonSchema: String
21+
private let vocabSizeOverride: Int?
22+
23+
public init(jsonSchema: String, vocabSize: Int? = nil) {
24+
self.jsonSchema = jsonSchema
25+
self.vocabSizeOverride = vocabSize
26+
}
27+
28+
// MARK: - DecodingStrategy conformance
29+
30+
public func decode(
31+
from input: Input,
32+
tokenizer: any Tokenizer,
33+
inferenceEngine: any InferenceEngine,
34+
samplingConfiguration: SamplingConfiguration,
35+
options: InferenceOptions,
36+
stopSequences: StopSequences
37+
) async throws -> AsyncThrowingStream<GenerationResult, Error> {
38+
guard let pipelinedEngine = inferenceEngine as? CoreAIPipelinedEngine else {
39+
throw InferenceRuntimeError.invalidArgument(
40+
"PipelinedConstrainedDecodingStrategy requires CoreAIPipelinedEngine")
41+
}
42+
43+
let vocabSize = vocabSizeOverride ?? ConstrainedDecodingStrategy.deriveVocabSize(from: tokenizer)
44+
guard let vocabSize else {
45+
throw InferenceRuntimeError.invalidArgument(
46+
"Cannot determine vocabulary size from tokenizer. "
47+
+ "Pass vocabSize explicitly via CoreAIRunner or LLMAsset metadata."
48+
)
49+
}
50+
51+
let singleTokenStops = stopSequences.sequences.filter { $0.count == 1 }.map { $0[0] }
52+
let stopTokenIds: [Int32]? = singleTokenStops.isEmpty ? nil : singleTokenStops
53+
54+
var session = try ConstrainedGenerationSession(
55+
jsonSchema: jsonSchema,
56+
tokenizer: tokenizer,
57+
vocabSize: vocabSize,
58+
stopTokenIds: stopTokenIds
59+
)
60+
61+
let inputTokens = try PromptUtils.maybeApplyTokenizerChatTemplate(input, tokenizer: tokenizer)
62+
.map(Int32.init)
63+
let maxTokens = options.maxTokens ?? 512
64+
65+
try await pipelinedEngine.reset()
66+
67+
// Move session into a class box before the closure boundary (session is ~Copyable)
68+
let sessionBox = ConstrainedSessionBox(session: session)
69+
70+
return AsyncThrowingStream { continuation in
71+
Task {
72+
do {
73+
try await self.runPipelinedConstrained(
74+
inputTokens: inputTokens,
75+
engine: pipelinedEngine,
76+
sessionBox: sessionBox,
77+
samplingConfiguration: samplingConfiguration,
78+
maxTokens: maxTokens,
79+
stopSequences: stopSequences,
80+
tokenizer: tokenizer,
81+
with: continuation
82+
)
83+
} catch {
84+
continuation.finish(throwing: error)
85+
}
86+
}
87+
}
88+
}
89+
90+
// MARK: - Core Loop
91+
92+
private func runPipelinedConstrained(
93+
inputTokens: [Int32],
94+
engine: CoreAIPipelinedEngine,
95+
sessionBox: ConstrainedSessionBox,
96+
samplingConfiguration: SamplingConfiguration,
97+
maxTokens: Int,
98+
stopSequences: StopSequences,
99+
tokenizer: any Tokenizer,
100+
with continuation: AsyncThrowingStream<GenerationResult, Error>.Continuation
101+
) async throws {
102+
CLILogger.log("Starting GPU pipelined constrained decoding", component: "PipelinedConstrained")
103+
104+
var generatedTokens: [Int32] = []
105+
var previousDecodedText = ""
106+
var recentTokens: [Int32] = []
107+
108+
for try await tokenId in try engine.generateConstrained(
109+
with: inputTokens,
110+
samplingConfiguration: samplingConfiguration,
111+
maxTokens: maxTokens,
112+
session: sessionBox
113+
) {
114+
// Check stop sequences
115+
recentTokens.append(tokenId)
116+
if recentTokens.count > stopSequences.maxLength {
117+
recentTokens.removeFirst()
118+
}
119+
if stopSequences.matches(recentTokens: recentTokens) { break }
120+
121+
// Decode text incrementally
122+
generatedTokens.append(tokenId)
123+
let fullDecode = tokenizer.decode(tokens: generatedTokens.map { Int($0) })
124+
125+
let common = fullDecode.commonPrefix(with: previousDecodedText)
126+
let delta = String(fullDecode.dropFirst(common.count))
127+
128+
if delta.unicodeScalars.contains(where: { $0 == "\u{FFFD}" }) {
129+
continue
130+
}
131+
132+
previousDecodedText = fullDecode
133+
134+
if !delta.isEmpty {
135+
continuation.yield(GenerationResult(text: delta, tokenId: tokenId, rawLogits: nil))
136+
}
137+
}
138+
139+
continuation.finish()
140+
}
141+
}

swift/Sources/CoreAILanguageModels/GuidedGeneration/ConstrainedGenerationSession.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,26 @@ public struct ConstrainedGenerationSession: ~Copyable {
193193
matcher.reset()
194194
allTokensBlocked = false
195195
}
196+
197+
/// Fill the bitmask directly into a caller-provided buffer (e.g., a GPU-visible MTLBuffer).
198+
///
199+
/// The caller must ensure the pointer has room for at least `(vocabularySize + 31) / 32`
200+
/// Int32 words. Returns `false` if the grammar is terminated (buffer untouched).
201+
public mutating func fillBitmask(into pointer: UnsafeMutablePointer<Int32>) -> Bool {
202+
if isTerminated { return false }
203+
204+
let hasConstraints = matcher.fillNextTokenBitmask(pointer)
205+
if !hasConstraints {
206+
allTokensBlocked = true
207+
return false
208+
}
209+
// Check for all-zeros (no tokens allowed)
210+
for i in 0..<bitmaskSize {
211+
if pointer[i] != 0 { return true }
212+
}
213+
allTokensBlocked = true
214+
return false
215+
}
196216
}
197217

198218
// MARK: - Float16 Masking

0 commit comments

Comments
 (0)