Skip to content

Commit 94bb170

Browse files
stikvesclaude
andcommitted
Fix pipeline race condition: rotate all buffers by pipeline depth (#46)
With pipeline depth 3, the GPU sampler output and logits buffers were shared across in-flight stages, causing stale reads (repeated tokens) under CPU contention. Introduce a shared pipelineDepth constant and rotate decodeOutputBuffers, decodeLogitsBuffers, and cachePositionBuffers so no two concurrent stages alias the same memory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 73f045f commit 94bb170

2 files changed

Lines changed: 81 additions & 37 deletions

File tree

swift/Sources/CoreAILanguageModels/InferenceEngines/CoreAIPipelinedEngine.swift

Lines changed: 80 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ private func milliseconds(since start: ContinuousClock.Instant) -> Double {
2121

2222
// MARK: - Constants
2323

24+
/// Maximum number of in-flight pipeline stages. Shared by the backpressure gate
25+
/// and all buffer rotation logic to guarantee no two concurrent stages alias
26+
/// the same memory.
27+
private let pipelineDepth = 3
2428
private let averageExpectedPromptSize = 256
2529
private let temperatureTolerance: Double = 0.001
2630

@@ -31,7 +35,7 @@ private let temperatureTolerance: Double = 0.001
3135
/// Key features:
3236
/// - Non-blocking GPU encoding via `InferenceFunction.encode`
3337
/// - GPU-direct token sampling (argmax/topK) via MPSGraph compute shaders
34-
/// - Double-buffered cache positions for CPU/GPU overlap
38+
/// - Pipeline-depth-matched buffer rotation for CPU/GPU overlap
3539
/// - Growing KV cache with pipelined expansion
3640
/// - All tensors are owned MTLBuffers — Core AI never allocates/frees them
3741
final class CoreAIPipelinedEngine: InferenceEngine, Sendable {
@@ -239,7 +243,7 @@ final class CoreAIPipelinedEngine: InferenceEngine, Sendable {
239243
history.clear()
240244
} else {
241245
// Partial reset: wait for generation to finish naturally, then rewind counter.
242-
// Do NOT cancel — cancelling corrupts the pipeline's double-buffer state.
246+
// Do NOT cancel — cancelling corrupts the pipeline's buffer rotation state.
243247
// The KV cache is valid up to processedTokenCount after natural completion.
244248
drain()
245249
await engine.computeStream.currentWorkCompleted()
@@ -282,7 +286,7 @@ final class CoreAIPipelinedEngine: InferenceEngine, Sendable {
282286
/// sampler callback drains them (~70/s); depth grows until
283287
/// `MPSCommandBufferImageCache` fails to allocate another private MTLBuffer.
284288
///
285-
/// Capacity 3 covers {logits encode + sampler commit + optional KV-cache grow};
289+
/// Capacity matches `pipelineDepth` — covers {logits encode + sampler commit + optional KV-cache grow};
286290
/// deeper queues only cost memory.
287291
///
288292
/// Class, not actor: `release()` runs synchronously from the Metal callback —
@@ -384,7 +388,9 @@ private struct EngineImpl: ~Copyable {
384388

385389
// Owned MTLBuffers
386390
var inputTokensBuffer: MTLBuffer
387-
var cachePositionBuffers: (MTLBuffer, MTLBuffer)
391+
var cachePositionBuffers: [MTLBuffer]
392+
var decodeOutputBuffers: [MTLBuffer]
393+
var decodeLogitsBuffers: [MTLBuffer]
388394

389395
// KV cache — reuses CoreAIKVCache protocol from KVCache+CoreAI.swift
390396
var kvCache: any CoreAIKVCache
@@ -401,8 +407,8 @@ private struct EngineImpl: ~Copyable {
401407
var step: Int = 0
402408

403409
// Backpressure gate — see PipelineGate doc-comment for the failure mode it prevents.
404-
// Capacity 3 covers {encode logits + sampler commit + optional KV-cache grow} in flight.
405-
let inFlightGate = PipelineGate(capacity: 3)
410+
// Capacity matches pipeline depth: {encode logits + sampler commit + optional KV-cache grow} in flight.
411+
let inFlightGate = PipelineGate(capacity: pipelineDepth)
406412

407413
// MARK: - Init
408414

@@ -474,22 +480,43 @@ private struct EngineImpl: ~Copyable {
474480
throw InferenceRuntimeError.bufferAllocationFailed("inputTokens (\(inputTokensByteCount) bytes)")
475481
}
476482

477-
// Allocate double-buffered cache positions
483+
// Allocate pipeline-depth-matched cache position buffers
478484
let cachePosSize = config.maxContextLength * posIdsDesc.scalarType.byteSize
479-
guard let cachePosBuf0 = device.makeBuffer(length: cachePosSize, options: .storageModeShared),
480-
let cachePosBuf1 = device.makeBuffer(length: cachePosSize, options: .storageModeShared)
481-
else {
482-
throw InferenceRuntimeError.bufferAllocationFailed("cachePositions (\(cachePosSize * 2) bytes)")
485+
var cachePosBuffers: [MTLBuffer] = []
486+
for _ in 0..<pipelineDepth {
487+
guard let buf = device.makeBuffer(length: cachePosSize, options: .storageModeShared) else {
488+
throw InferenceRuntimeError.bufferAllocationFailed("cachePositions (\(cachePosSize) bytes)")
489+
}
490+
cachePosBuffers.append(buf)
483491
}
484492

485493
// Pre-populate cache positions with [0, 1, ..., maxCtx-1]
486-
for buf in [cachePosBuf0, cachePosBuf1] {
494+
for buf in cachePosBuffers {
487495
let ptr = buf.contents().bindMemory(to: Int32.self, capacity: config.maxContextLength)
488496
for i in 0..<config.maxContextLength {
489497
ptr[i] = Int32(i)
490498
}
491499
}
492500

501+
// Allocate pipeline-depth-matched decode output buffers (sampler writes next token)
502+
var decodeOutBuffers: [MTLBuffer] = []
503+
for _ in 0..<pipelineDepth {
504+
guard let buf = device.makeBuffer(length: MemoryLayout<Int32>.size, options: .storageModeShared) else {
505+
throw InferenceRuntimeError.bufferAllocationFailed("decodeOutputBuffer (\(MemoryLayout<Int32>.size) bytes)")
506+
}
507+
decodeOutBuffers.append(buf)
508+
}
509+
510+
// Allocate pipeline-depth-matched decode logits buffers (inference writes logits for decode)
511+
let decodeLogitsSize = config.vocabSize * MemoryLayout<UInt16>.size
512+
var decodeLogBufs: [MTLBuffer] = []
513+
for _ in 0..<pipelineDepth {
514+
guard let buf = device.makeBuffer(length: decodeLogitsSize, options: .storageModeShared) else {
515+
throw InferenceRuntimeError.bufferAllocationFailed("decodeLogitsBuffer (\(decodeLogitsSize) bytes)")
516+
}
517+
decodeLogBufs.append(buf)
518+
}
519+
493520
// Create KV cache using factory — pass original descriptors (with -1 dynamic dims intact)
494521
// so the factory can correctly detect growing vs static support via isDynamicKVCache().
495522
let kvCacheLocal = try KVCacheFactory.make(
@@ -544,7 +571,9 @@ private struct EngineImpl: ~Copyable {
544571
self.positionIdsBaseDesc = posIdsDesc
545572
self.logitsBaseDesc = logitsDesc
546573
self.inputTokensBuffer = inputTokensBuf
547-
self.cachePositionBuffers = (cachePosBuf0, cachePosBuf1)
574+
self.cachePositionBuffers = cachePosBuffers
575+
self.decodeOutputBuffers = decodeOutBuffers
576+
self.decodeLogitsBuffers = decodeLogBufs
548577
self.kvCache = kvCacheLocal
549578
self.logits = logitsRef
550579
self.cachedSampler = nil
@@ -591,7 +620,7 @@ private struct EngineImpl: ~Copyable {
591620
///
592621
/// 1. Construct RawView/MutableRawView from MTLBuffers with current shapes
593622
/// 2. Encode to ComputeStream (non-blocking)
594-
/// 3. withMetal3Queue: encode GPU argmax/topK (writes directly to inputTokensBuffer)
623+
/// 3. withMetal3Queue: encode GPU argmax/topK (writes to rotating decodeOutputBuffers)
595624
/// 4. Callback yields token
596625
private mutating func _encodeNextStepGPU(
597626
tokens: some Collection<Int32>,
@@ -620,7 +649,7 @@ private struct EngineImpl: ~Copyable {
620649
// Prefill: write tokens at their natural position so this step's region is disjoint
621650
// from any prior chunk's region still in-flight on the GPU (encode holds a live
622651
// MTLBuffer reference; no encodeWriteOperands serialization available in Core AI).
623-
// Decode: token is already at offset 0 via GPU-direct argmax write — no CPU write needed.
652+
// Decode: token is in the previous step's decodeOutputBuffer — no CPU write needed.
624653
let tokenByteOffset = processedTokenCount * MemoryLayout<Int32>.size
625654
if !tokens.isEmpty {
626655
let ptr = inputTokensBuffer.contents().bindMemory(
@@ -630,20 +659,33 @@ private struct EngineImpl: ~Copyable {
630659
}
631660
}
632661

633-
// Select cache position buffer for this step (double-buffered)
634-
let cachePosBuffer = step % 2 == 0 ? cachePositionBuffers.0 : cachePositionBuffers.1
662+
// Select cache position buffer for this step (pipeline-depth-matched rotation)
663+
let cachePosBuffer = cachePositionBuffers[step % pipelineDepth]
635664
let posLength = processedTokenCount + queryLength
636665

637666
// Build Inputs as AsyncValue (from MTLBuffers)
638667
let tokenShape = [1, queryLength]
639668
let tokenStrides = try resolvedStrides(descriptor: inputIdsBaseDesc, shape: tokenShape)
640-
let tokenValue = unsafe InferenceFunction.AsyncValue(
641-
unsafeBuffer: inputTokensBuffer,
642-
byteOffset: tokens.isEmpty ? 0 : tokenByteOffset,
643-
scalarType: .int32,
644-
shape: tokenShape,
645-
strides: tokenStrides
646-
)
669+
let tokenValue: InferenceFunction.AsyncValue
670+
if tokens.isEmpty {
671+
// Decode: read input token from previous step's decode output buffer
672+
tokenValue = unsafe InferenceFunction.AsyncValue(
673+
unsafeBuffer: decodeOutputBuffers[(step + pipelineDepth - 1) % pipelineDepth],
674+
byteOffset: 0,
675+
scalarType: .int32,
676+
shape: tokenShape,
677+
strides: tokenStrides
678+
)
679+
} else {
680+
// Prefill: read from inputTokensBuffer at natural position
681+
tokenValue = unsafe InferenceFunction.AsyncValue(
682+
unsafeBuffer: inputTokensBuffer,
683+
byteOffset: tokenByteOffset,
684+
scalarType: .int32,
685+
shape: tokenShape,
686+
strides: tokenStrides
687+
)
688+
}
647689
let posShape = [1, posLength]
648690
let posStrides = try resolvedStrides(descriptor: positionIdsBaseDesc, shape: posShape)
649691
let posValue = unsafe InferenceFunction.AsyncValue(
@@ -686,11 +728,12 @@ private struct EngineImpl: ~Copyable {
686728
asyncStates.insert(&valState, for: valueCacheName)
687729

688730
// Build Output as AsyncMutableValue (logits)
689-
let logitsBuffer = logits.metalBuffer
731+
// Decode uses per-step rotating buffer; prefill uses the shared growing buffer.
732+
let logitsOutputBuffer = tokens.isEmpty ? decodeLogitsBuffers[step % pipelineDepth] : logits.metalBuffer
690733
let logitsShape = [1, queryLength, vocabSize]
691734
let logitsStrides = try resolvedStrides(descriptor: logitsBaseDesc, shape: logitsShape)
692735
var logitsOutput = unsafe InferenceFunction.AsyncMutableValue(
693-
unsafeBuffer: logitsBuffer,
736+
unsafeBuffer: logitsOutputBuffer,
694737
byteOffset: 0,
695738
scalarType: .float16,
696739
shape: logitsShape,
@@ -719,7 +762,8 @@ private struct EngineImpl: ~Copyable {
719762

720763
// GPU sampling via Metal queue
721764
let localGPUSampler = gpuSampler
722-
let outputBuffer = inputTokensBuffer
765+
let outputBuffer = decodeOutputBuffers[step % pipelineDepth]
766+
let samplerLogitsBuffer = tokens.isEmpty ? decodeLogitsBuffers[step % pipelineDepth] : logits.metalBuffer
723767
let logitsOffset = (actualTokenCount - 1) * vocabSize * MemoryLayout<UInt16>.size
724768
let samplerStrategy = gpuSampler is MPSGraphArgmaxSampler ? "GPU-argmax" : "GPU-composite"
725769
let samplerTemperature = cachedSamplerTemperature ?? 0.0
@@ -745,7 +789,7 @@ private struct EngineImpl: ~Copyable {
745789
if queryLength == 1 {
746790
localGPUSampler.encode(
747791
to: queue,
748-
logitsBuffer: logitsBuffer,
792+
logitsBuffer: samplerLogitsBuffer,
749793
logitsOffset: logitsOffset,
750794
outputBuffer: outputBuffer,
751795
outputOffset: 0,
@@ -754,7 +798,7 @@ private struct EngineImpl: ~Copyable {
754798
} else {
755799
localGPUSampler.encodeWithSlice(
756800
to: queue,
757-
logitsBuffer: logitsBuffer,
801+
logitsBuffer: samplerLogitsBuffer,
758802
queryLength: actualTokenCount,
759803
outputBuffer: outputBuffer,
760804
outputOffset: 0,
@@ -957,7 +1001,7 @@ private struct EngineImpl: ~Copyable {
9571001
ptr[processedTokenCount + i] = token
9581002
}
9591003

960-
let cachePosBuffer = step % 2 == 0 ? cachePositionBuffers.0 : cachePositionBuffers.1
1004+
let cachePosBuffer = cachePositionBuffers[step % pipelineDepth]
9611005
let posLength = processedTokenCount + queryLength
9621006

9631007
// Build async values and encode
@@ -1064,7 +1108,7 @@ private struct EngineImpl: ~Copyable {
10641108
let ptr = inputTokensBuffer.contents().bindMemory(to: Int32.self, capacity: shape)
10651109
for i in 0..<shape { ptr[i] = 1 }
10661110

1067-
let cachePosBuffer = step % 2 == 0 ? cachePositionBuffers.0 : cachePositionBuffers.1
1111+
let cachePosBuffer = cachePositionBuffers[step % pipelineDepth]
10681112
let posLength = processedTokenCount + shape
10691113

10701114
let tShape = [1, shape]
@@ -1113,18 +1157,18 @@ private struct EngineImpl: ~Copyable {
11131157
to: computeStream
11141158
)
11151159

1116-
// Warm up argmax kernel
1117-
let logitsBuffer = logits.metalBuffer
1118-
let outputBuffer = inputTokensBuffer
1160+
// Warm up argmax kernel using pipeline-matched decode buffers
1161+
let warmupLogitsBuffer = decodeLogitsBuffers[step % pipelineDepth]
1162+
let warmupOutputBuffer = decodeOutputBuffers[step % pipelineDepth]
11191163
let logitsOffset = (shape - 1) * vocabSize * MemoryLayout<UInt16>.size
11201164

11211165
do {
11221166
let queue = pipelineQueue
11231167
warmupSampler.encode(
11241168
to: queue,
1125-
logitsBuffer: logitsBuffer,
1169+
logitsBuffer: warmupLogitsBuffer,
11261170
logitsOffset: logitsOffset,
1127-
outputBuffer: outputBuffer,
1171+
outputBuffer: warmupOutputBuffer,
11281172
outputOffset: 0,
11291173
completion: { _ in }
11301174
)

swift/Sources/CoreAILanguageModels/LanguageModel/CoreAILanguageModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Tokenizers
1717
///
1818
/// ## Engine Selection
1919
/// The engine type is determined by `EngineFactory` based on model structure:
20-
/// - **Pipelined**: GPU-accelerated with double buffering (fastest for GPU models)
20+
/// - **Pipelined**: GPU-accelerated with pipeline-depth-matched buffering (fastest for GPU models)
2121
/// - **Sequential**: CPU-based synchronous execution (fallback)
2222
/// - **Static-shape**: Neural Engine optimized for chunked static models
2323
///

0 commit comments

Comments
 (0)