Search code examples
swiftmacosmetal

Synchronize work between CPU and GPU within single command buffer using MTLSharedEvent


I am trying to use MTLSharedEvent along with MTLSharedEventListener to synchronize computation between GPU and CPU, as in example provided by Apple (https://developer.apple.com/documentation/metal/synchronization/synchronizing_events_between_a_gpu_and_the_cpu). Basically what I want to achieve is have work split into 3 parts executed in order, like so:

  1. GPU computation part 1
  2. CPU computation based on results from GPU computation part 1
  3. GPU computation part 2 after CPU computation

My problem is that eventListener block is always called before command buffer is being scheduled for execution, which make my CPU task execute first in order.

To simplify the case, let’s use simple commands that fill MTLBuffer with certain values (my original use case is more complicated, as using compute encoders with custom shaders, but behaves the same):

let device = MTLCreateSystemDefaultDevice()!
let queue = device.makeCommandQueue()!
let event = device.makeSharedEvent()!
let dispatchQueue = DispatchQueue(label: "myqueue")
let eventListener = MTLSharedEventListener(dispatchQueue: dispatchQueue)

let metalBuffer = device.makeBuffer(length: 2048, options: MTLResourceOptions.storageModeShared)!

let buffer = queue.makeCommandBuffer()!

NSLog("Start - signaled value: \(event.signaledValue)")

event.notify(eventListener, atValue: 1) { event, value in
    // CPU work 
    let pointer = metalBuffer.contents().assumingMemoryBound(to: UInt8.self)
    for i in 0..<512 {
        (pointer + i).pointee = (pointer + i).pointee  + 1;
    }

    NSLog("Event notification - signaled value: \(value), buffer status: \(buffer.status.rawValue)")
    event.signaledValue = 2
}

// GPU work part 1
let encoder1 = buffer.makeBlitCommandEncoder()!
encoder1.fill(buffer: metalBuffer, range: .init(0...127), value: 22)
encoder1.endEncoding()

// signal with 1 to start CPU task 
buffer.encodeSignalEvent(event, value: 1)
// wait for value >= 2 to proceed
buffer.encodeWaitForEvent(event, value: 2)

// GPU work part 2
let encoder2 = buffer.makeBlitCommandEncoder()!
encoder2.fill(buffer: metalBuffer, range: .init(128...511), value: 255)
encoder2.endEncoding()

buffer.addScheduledHandler { buffer in
    NSLog("Buffer scheduled - signaled value: \(event.signaledValue)")
}
buffer.addCompletedHandler { buffer in
    NSLog("Buffer completed - signaled value: \(event.signaledValue)")
}

buffer.commit()
buffer.waitUntilCompleted()

Output:

2022-01-09 23:46:08.774 Sync[76882:3531755] Metal GPU Frame Capture Enabled
2022-01-09 23:46:08.805 Sync[76882:3531755] Start - signaled value: 0
2022-01-09 23:46:08.808 Sync[76882:3531764] Event notification - signaled value: 1, buffer status: 2 (Commited)
2022-01-09 23:46:08.809 Sync[76882:3531763] Buffer scheduled - signaled value: 2
2022-01-09 23:46:08.809 Sync[76882:3531763] Buffer completed - signaled value: 2

As you can see eventListener logs buffer status as .commited. What’s the matter here? Am I missing something?

System: macOS 12.0.1, Apple M1 Pro, Xcode 13.2.1


Solution

  • This is perfectly fine that command buffer is committed. In fact if it wouldn't be committed you'll never get to notify block.

    GPU and CPU runs in parallel. So when you use MTLEvent you don't stop executing CPU code (all the Swift code actually). You just tell GPU in what order to execute GPU code.

    So what's happening in your case:

    1. All your code runs in a single CPU thread without any interruption.
    2. GPU starts executing command buffer commands only when you call commit(). Before it GPU actually don't do anything. You just scheduled command to be performed on GPU but don't perform them.
    3. When GPU executes commands it checks for your MTLEvent. It performs part 1, then encodes value 1 to event, performs notify block, encodes value 2, performs second GPU block.

    But again all the actual GPU work starts only when you call commit() on command buffer. That's why buffer is already committed in notify block. Because it is performed after commit().