DispatchQueue.concurrentPerform + UnsafeRawPointer in Swift 6

We use GCD to speed up the calculation of some raw data.
Specifically, we use DispatchQueue.concurrentPerform to parallelize the initialization of a data buffer. The thread safety is guaranteed by not overlapping accessed buffer regions.

The code looks something like this:

let someData: UnsafeBufferPointer<simd_float2> = ...
let moreData: UnsafeBufferPointer<simd_float2> = ...

let capacity = 5_000_000

let computedData = [simd_float4](unsafeUninitializedCapacity: capacity) { buffer, initializedCount in
    initializedCount = capacity

    DispatchQueue.concurrentPerform(iterations: capacity) { row in
        // some expensive calculation
        let part1 = someData[row]
        let part2 = moreData[row]
        buffer[row] = simd_float4(lowHalf: part1, highHalf: part2)
    }
}

With the new Xcode, the closure passed to concurrentPerform is now Sendable. This causes some warnings and errors in the code above:

Is there a way to tell the compiler "I know what I'm doing" here? Or is there a new, safe way to operate on raw data in parallel in Swift 6?

Thanks for your help!

It turns out, if we just define the closure before passing it to concurrentPerform it works:

let closure = { (row: Int) in
    let part1 = someData[row]
    let part2 = moreData[row]
    buffer[row] = simd_float4(lowHalf: part1, highHalf: part2)
}

DispatchQueue.concurrentPerform(iterations: capacity, execute: closure)

My guess is that closure is not Sendable here, so the compiler won't complain about the buffer access. And concurrentPerform is marked with @preconcurrency, so passing a non-sendable closure is also ok. But it feels wrong...

Is there a better pattern for this kind of work?

nonisolated(unsafe)
let someData: UnsafeBufferPointer<simd_float2> = ...

nonisolated(unsafe)
let moreData: UnsafeBufferPointer<simd_float2> = ...

let capacity = 5_000_000

struct UnsafeTransfer<Value>: @unchecked Sendable {
    var value: Value
}

let computedData = [simd_float4](
    unsafeUninitializedCapacity: capacity
) { buffer, initializedCount in
    
    initializedCount = capacity
    
    DispatchQueue.concurrentPerform(iterations: capacity) { [buffer = UnsafeTransfer(value: buffer)] row in
        
        // some expensive calculation
        let part1 = someData[row]
        let part2 = moreData[row]
        buffer.value[row] = simd_float4(lowHalf: part1, highHalf: part2)
        
    }

}
2 Likes

It doesn't with Xcode 15 beta 6 version of toolchain, you still would be getting an error. The solution with GCD is to opt-out to unsafe as @tubescreamer suggested. Swift would never allow your code since it cannot reason if you accessing memory in a safe manner, and unsafe here to be explicit "compiler won't check that, but I'm sure what I do here is correct".

But you can make it safe with TaskGroup to look something like:

let computedData: [simd_float4] = await withTaskGroup(
    of: (row: Int, element: simd_float4).self
) { group in
    for i in 0..<capacity {
        group.addTask {
            let part1 = someData[row]
            let part2 = moreData[row]
            return (i, simd_float4(lowHalf: part1, highHalf: part2))
        }
    }
    // this can be probably replaced with some buffer as well to not fill unnecessarily
    var result: [simd_float4] = Array(repeating: simd_float4(), count: capacity)
    for await (row, element) in group {
        result[row] = element
    }
    return result
}

Here you won't have a potentially dangerous access, and work will still be parallelized.

1 Like

Thanks for the suggestion!

Unfortunately, we can't use task groups in this case because we have to do the work in a synchronous context (ROI calculation of a CIImageProcessorKernel).

Swift concurrency doesn't seem well-suited for parallelizing work in an otherwise synchronous task, like above. We just want to spawn a few more threads to work on the problem without creating a suspension point in the main task.

But you start asynchronous work with concurrentPerform anyway, what difference it makes if you wrap group in a Task instead? It might be even clearer what is happening in that case, since with concurrentPerform it looks like array being initialized right away, while in fact it does not. My bad, it waits.

1 Like

It looks like this proposal is trying to mitigate this kind of Swift 6 problems.

There are many aspects to Swift concurrency. Modelling data isolation in the language to prevent data races is part of it, and as you discovered it will notify about potential races even if you execute your tasks in some other way (assuming the interfaces are correctly annotated).

TaskGroup as it is today is not a replacement for DispatchQueue.concurrentPerform, which remains the recommended way to do what you're trying to do. I don't know if we plan to introduce a similar construct in the standard library one day.

2 Likes