Skip to content

Commit f1a8bf3

Browse files
committed
Add cancel() and isBusy to InferenceEngine protocol
1 parent d83f0b0 commit f1a8bf3

7 files changed

Lines changed: 172 additions & 3 deletions

File tree

swift/Sources/CoreAILanguageModels/InferenceEngines/CoreAIPipelinedEngine.swift

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ final class CoreAIPipelinedEngine: InferenceEngine, Sendable {
3939

4040
nonisolated(unsafe) private var engine: EngineImpl
4141
private let engineInUse = Atomic<Bool>(false)
42+
private let generationTask = Mutex<Task<Void, Never>?>(nil)
4243
let config: ModelConfig
4344

4445
init(
@@ -104,7 +105,7 @@ final class CoreAIPipelinedEngine: InferenceEngine, Sendable {
104105
let stopReasonStore = StopReasonStore()
105106
let (base, outputContinuation) =
106107
AsyncThrowingStream<InferenceOutput, any Error>.makeStream()
107-
Task {
108+
let task = Task {
108109
self.acquireEngine()
109110
defer { self.releaseEngine() }
110111
do {
@@ -139,15 +140,28 @@ final class CoreAIPipelinedEngine: InferenceEngine, Sendable {
139140
outputContinuation.finish(throwing: error)
140141
}
141142
}
143+
generationTask.withLock { $0 = task }
142144
return GenerationSequence(base: base, stopReasonStore: stopReasonStore)
143145
}
144146

147+
var isBusy: Bool { engineInUse.load(ordering: .acquiring) }
148+
149+
func cancel() async {
150+
let task = generationTask.withLock { v in
151+
v?.cancel()
152+
return v
153+
}
154+
await task?.value
155+
await engine.computeStream.currentWorkCompleted()
156+
generationTask.withLock { $0 = nil }
157+
}
158+
145159
/// Wait for any in-flight generate() Task to return the engine.
146160
private func drain() {
147161
var attempts = 0
148162
while engineInUse.load(ordering: .acquiring) {
149163
attempts += 1
150-
if attempts > 5000 {
164+
if attempts > Self.drainTimeoutIterations {
151165
fatalError("Engine not returned after drain() — tokenSequence Task stuck?")
152166
}
153167
Thread.sleep(forTimeInterval: 0.001)

swift/Sources/CoreAILanguageModels/InferenceEngines/CoreAISequentialEngine.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public final class CoreAISequentialEngine: InferenceEngine, @unchecked Sendable
7373

7474
// Track in-flight generation for drain (same pattern as pipelined engine)
7575
private let generating = Mutex(false)
76+
private let cancelRequested = Mutex(false)
7677

7778
// MARK: - Init
7879

@@ -347,12 +348,31 @@ public final class CoreAISequentialEngine: InferenceEngine, @unchecked Sendable
347348

348349
// MARK: - Lifecycle
349350

351+
public var isBusy: Bool { generating.withLock { $0 } }
352+
353+
public func cancel() async {
354+
cancelRequested.withLock { $0 = true }
355+
var attempts = 0
356+
while generating.withLock({ $0 }) {
357+
attempts += 1
358+
if attempts > Self.drainTimeoutIterations {
359+
CLILogger.log(
360+
"WARNING: Sequential engine cancel() drain timeout after \(Self.drainTimeoutIterations) iterations",
361+
component: "CoreAISequential"
362+
)
363+
break
364+
}
365+
await Task.yield()
366+
}
367+
cancelRequested.withLock { $0 = false }
368+
}
369+
350370
/// Wait for any in-flight generate() Task to finish.
351371
private func drain() {
352372
var attempts = 0
353373
while generating.withLock({ $0 }) {
354374
attempts += 1
355-
if attempts > 5000 {
375+
if attempts > Self.drainTimeoutIterations {
356376
fatalError("Sequential engine drain() timeout — generation Task stuck?")
357377
}
358378
Thread.sleep(forTimeInterval: 0.001)
@@ -550,6 +570,12 @@ extension CoreAISequentialEngine.GenerationSequence {
550570
do {
551571
try Task.checkCancellation()
552572

573+
if engine.cancelRequested.withLock({ $0 }) {
574+
stopReasonStore.set(.cancelled)
575+
finishAndRelease()
576+
return nil
577+
}
578+
553579
guard engine.processedTokenCount < inputTokens.count else {
554580
throw InferenceRuntimeError.invalidState("No new tokens to process")
555581
}

swift/Sources/CoreAILanguageModels/InferenceEngines/CoreAIStaticShapeEngine.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import CoreAI
77
import CoreAIShared
88
import Foundation
9+
import Synchronization
910

1011
/// Static-shape inference engine using Core AI models.
1112
public final class StaticShapeEngine: InferenceEngine, @unchecked Sendable {
@@ -47,6 +48,10 @@ public final class StaticShapeEngine: InferenceEngine, @unchecked Sendable {
4748
// Number of tokens already processed in the current sequence.
4849
private var processedTokenCount: Int = 0
4950

51+
// Track in-flight generation for drain/cancel
52+
private let generating = Mutex(false)
53+
private let cancelRequested = Mutex(false)
54+
5055
// MARK: - Initialization
5156

5257
public init(configuration: ModelConfig, preparedModel: PreparedModel) async throws {
@@ -531,7 +536,39 @@ public final class StaticShapeEngine: InferenceEngine, @unchecked Sendable {
531536

532537
// MARK: - Lifecycle
533538

539+
public var isBusy: Bool { generating.withLock { $0 } }
540+
541+
public func cancel() async {
542+
cancelRequested.withLock { $0 = true }
543+
var attempts = 0
544+
while generating.withLock({ $0 }) {
545+
attempts += 1
546+
if attempts > Self.drainTimeoutIterations {
547+
CLILogger.log(
548+
"WARNING: StaticShape engine cancel() drain timeout after \(Self.drainTimeoutIterations) iterations",
549+
component: "StaticShape"
550+
)
551+
break
552+
}
553+
await Task.yield()
554+
}
555+
cancelRequested.withLock { $0 = false }
556+
}
557+
558+
/// Wait for any in-flight generate() iteration to finish.
559+
private func drain() {
560+
var attempts = 0
561+
while generating.withLock({ $0 }) {
562+
attempts += 1
563+
if attempts > Self.drainTimeoutIterations {
564+
fatalError("StaticShape engine drain() timeout — generation stuck?")
565+
}
566+
Thread.sleep(forTimeInterval: 0.001)
567+
}
568+
}
569+
534570
public func reset() {
571+
drain()
535572
let resetSpan = InstrumentsProfiler.beginReset(engine: "StaticShape")
536573
processedTokenCount = 0
537574
resetSpan.end()
@@ -595,6 +632,8 @@ extension StaticShapeEngine.GenerationSequence {
595632

596633
private var inputTokens: [StaticShapeEngine.TokenId]
597634
private var step: Int = 0
635+
private var didAcquireLock: Bool = false
636+
private var finished: Bool = false
598637

599638
init(
600639
engine: StaticShapeEngine,
@@ -620,15 +659,29 @@ extension StaticShapeEngine.GenerationSequence {
620659
}
621660

622661
public mutating func next() async throws -> InferenceOutput? {
662+
if finished { return nil }
663+
664+
if !didAcquireLock {
665+
engine.generating.withLock { $0 = true }
666+
didAcquireLock = true
667+
}
668+
623669
guard step < maxTokens else {
624670
// Natural exhaustion. Don't clobber a reason a decoder set (e.g. `.eos`).
625671
stopReasonStore.setIfUnset(.maxTokens)
672+
finishAndRelease()
626673
return nil
627674
}
628675

629676
do {
630677
try Task.checkCancellation()
631678

679+
if engine.cancelRequested.withLock({ $0 }) {
680+
stopReasonStore.set(.cancelled)
681+
finishAndRelease()
682+
return nil
683+
}
684+
632685
// When forced, we still need the forward pass (for logits + KV cache update)
633686
// but skip the sampler — the next token is predetermined.
634687
let (logits, sampledToken) = try await engine.inference(
@@ -647,11 +700,22 @@ extension StaticShapeEngine.GenerationSequence {
647700
)
648701
} catch is CancellationError {
649702
stopReasonStore.set(.cancelled)
703+
finishAndRelease()
650704
throw CancellationError()
651705
} catch {
652706
stopReasonStore.set(.error)
707+
finishAndRelease()
653708
throw error
654709
}
655710
}
711+
712+
private mutating func finishAndRelease() {
713+
guard !finished else { return }
714+
finished = true
715+
if didAcquireLock {
716+
engine.generating.withLock { $0 = false }
717+
didAcquireLock = false
718+
}
719+
}
656720
}
657721
}

swift/Sources/CoreAILanguageModels/InferenceEngines/InferenceEngine.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ public protocol InferenceEngine: Sendable {
106106

107107
// MARK: - Lifecycle
108108

109+
/// Whether the engine is currently running a generation.
110+
var isBusy: Bool { get }
111+
112+
/// Cancel in-flight generation and wait for the engine to become idle.
113+
///
114+
/// Best-effort: logs a warning if the drain times out but does not trap.
115+
func cancel() async
116+
109117
/// Reset internal state including KV cache.
110118
func reset() async throws
111119

@@ -156,6 +164,19 @@ extension InferenceConfiguration {
156164

157165
// MARK: - Default Implementations
158166

167+
extension InferenceEngine {
168+
/// Maximum iterations for drain loops before timing out.
169+
/// - `cancel()` logs a warning and breaks (best-effort).
170+
/// - `reset()` traps (hard lifecycle boundary).
171+
static var drainTimeoutIterations: Int { 5000 }
172+
173+
/// Default: engine is not busy.
174+
public var isBusy: Bool { false }
175+
176+
/// Default no-op cancel.
177+
public func cancel() async {}
178+
}
179+
159180
extension InferenceEngine {
160181
/// Default: supportsLogits is false. Engines that can return per-step
161182
/// logits (sequential, static-shape) override this to true.

swift/Sources/CoreAILanguageModels/LanguageModel/CoreAILanguageModel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ public struct CoreAILanguageModel: LanguageModel {
290290
let maxTokens = request.generationOptions.maximumResponseTokens ?? 512
291291

292292
// Reset engine state for new generation
293+
await engine.cancel()
293294
try await engine.reset()
294295

295296
// FoundationModels now threads entry identity itself based on event

swift/Tests/LanguageModelsTests/TestUtilities.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,10 @@ class MockEngine: InferenceEngine, @unchecked Sendable {
158158
}
159159
}
160160

161+
var isBusy: Bool { false }
162+
163+
func cancel() async {}
164+
161165
func reset() async throws {
162166
resetCalled = true
163167
inferenceCallCount = 0

swift/Tests/LanguageModelsTests/UnifiedGenerationAPITests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,42 @@ struct GenerateMultiCallTests {
288288
#expect(count == 0)
289289
}
290290
}
291+
292+
// MARK: - Cancel API Tests
293+
294+
@Suite("CancelAPI")
295+
struct CancelAPITests {
296+
@Test("MockEngine isBusy returns false by default")
297+
func mockEngineNotBusy() {
298+
let engine = MockEngine(tokens: [1, 2, 3])
299+
#expect(engine.isBusy == false)
300+
}
301+
302+
@Test("MockEngine cancel() is a no-op")
303+
func mockEngineCancelNoOp() async {
304+
let engine = MockEngine(tokens: [1, 2, 3])
305+
await engine.cancel()
306+
#expect(engine.isBusy == false)
307+
}
308+
309+
@Test("drainTimeoutIterations default value")
310+
func drainTimeoutDefault() {
311+
#expect(MockEngine.drainTimeoutIterations == 5000)
312+
}
313+
314+
@Test("cancel() before generate does not crash")
315+
func cancelBeforeGenerate() async {
316+
let engine = MockEngine(tokens: [1, 2, 3])
317+
await engine.cancel()
318+
// Should be able to generate normally after cancel
319+
var tokens: [Int32] = []
320+
for try await output in try engine.generate(
321+
with: [1],
322+
samplingConfiguration: .greedy,
323+
inferenceOptions: InferenceOptions(maxTokens: 3)
324+
) {
325+
tokens.append(output.tokenId)
326+
}
327+
#expect(tokens.count == 3)
328+
}
329+
}

0 commit comments

Comments
 (0)