@@ -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
2428private let averageExpectedPromptSize = 256
2529private 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
3741final 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 )
0 commit comments