2
\$\begingroup\$

In the Metal sample code and best practices guides it is advised to use a triple buffering technique to avoid access conflicts between the cpu and the gpu.

The required code to do so requires knowledge of data alignment which can be tricky to understand and involves some extra fields. I wanted to make it easier to create a triple buffer scheme for any data type so I created the following class.

It makes it simple to do this technique on any data type using only one field.

An example of how to use it is found on my github. In most cases the constructor would be passed a maxBuffers value of 3.

I suppose I am hoping that the following things would be reviewed. This is my first time working with pointers in swift so I may have made some critical errors.

  1. Is the pointer creation and management efficient?
  2. Does the code sucesfully make sure that only one chunk of memory is claimed by the cpu at one setting
  3. Is there a better way I can update the buffer? It would be nice to say BasicMB = myNewData however swift cant work that way. It would be at least nice to have a method where they don't have to initialize an object outside of the array.

Here is the code:

 class BasicMB<T>
 {
    //iOS is safe with offsets of a multiple of 16 bytes however MacOS requires multiples of 256 bytes
    #if os(iOS)
    private let idealOffset = 16
    #else
    private let idealOffset = 256
    #endif

    private var deviceBuffer:MTLBuffer


    private var bufferIndex:Int = 0
    private var bufferOffset:Int = 0
    private let maxBuffers:Int
    private let byteAllignedSize:Int
    private let arrayCount:Int

    //You can directly edit the data with .data[0] unexpected behavior will occur if you do anything else
    var data: UnsafeMutablePointer<T>
    private var type: T.Type




    //Creates a multibuffer with maxBuffers slots taking count items per slot. Pass in MTLDevice to increase efficiency
    init?(dataType: T.Type, maxBuffers: Int, count: Int, device: MTLDevice?)
    {
        assert(count > 0, "Cant have a count of 0! There would be no data")

        self.maxBuffers = maxBuffers
        self.type = dataType
        arrayCount = count

        //If given a device use it otherwise make one
        var actualDevice = device
        if (actualDevice == nil)
        {actualDevice = MTLCreateSystemDefaultDevice()}

        //Round up to the nearest idealOffset multiple
        byteAllignedSize = (((MemoryLayout<T>.size * count) + (idealOffset - 1)) / (idealOffset)) * (idealOffset)

        guard let buffer = actualDevice!.makeBuffer(length:byteAllignedSize * maxBuffers, options:[MTLResourceOptions.storageModeShared]) else { return nil }
        deviceBuffer = buffer

        data = UnsafeMutableRawPointer(deviceBuffer.contents()).bindMemory(to:type.self, capacity:count)
    }

    //After the semaphore has triggered you are free to switch the CPU writing location
    func prepareForUpdatePostSemaphore()
    {
        bufferIndex = (bufferIndex + 1) % maxBuffers
        bufferOffset = byteAllignedSize * bufferIndex

        data = UnsafeMutableRawPointer(deviceBuffer.contents() + bufferOffset).bindMemory(to:type.self, capacity:arrayCount)
    }

    //This is an acceptable way to update data in the buffer although you would be advised to use create your own class specefic to the type you are working wtih
    func update(dataIn: T, index: Int = 0)
    {
        data[index] = dataIn
    }

    //Avoid running in release builds
    func reportAllValues()
    {
        let save = bufferOffset

        var string = ""
        for i in 0..<maxBuffers
        {
            bufferOffset = byteAllignedSize * i
            data = UnsafeMutableRawPointer(deviceBuffer.contents() + bufferOffset).bindMemory(to:type.self, capacity:arrayCount)
            string += "|"
            for p in 0..<arrayCount
            {
                string += String.init(describing: data[p])
                string += " "
            }
            string += "|"
        }
        print(string)

        bufferOffset = save
        data = UnsafeMutableRawPointer(deviceBuffer.contents() + bufferOffset).bindMemory(to:type.self, capacity:arrayCount)
    }

    func buffer() -> MTLBuffer {
        return deviceBuffer
    }
    func offset() -> Int {
        return bufferOffset
    }
}
\$\endgroup\$
4
\$\begingroup\$

Your platform-dependent code

//iOS is safe with offsets of a multiple of 16 bytes however MacOS requires multiples of 256 bytes
#if os(iOS)
private let idealOffset = 16
#else
private let idealOffset = 256
#endif

assumes that macOS and iOS are the only available platforms. But MetalKit is available on at least one more platform (tvOS), and new platforms may be defined in the future.

Even it you only intend to use this class on iOS and macOS, better check explicitly for this platforms

#if os(iOS)
private let idealOffset = 16
#elseif os(macOS)
private let idealOffset = 256
#endif

This forces you to think about the right value if the code is compiled for another platform.


Your class defines two private properties with public getter methods:

class BasicMB<T> {
    private var deviceBuffer:MTLBuffer
    private var bufferOffset:Int = 0

    // ...

    func buffer() -> MTLBuffer {
        return deviceBuffer
    }
    func offset() -> Int {
        return bufferOffset
    }
}

The same can be achieved with a public-readonly/private-readwrite property:

class BasicMBx<T> {
    public private(set) var buffer: MTLBuffer
    public private(set) var offset: Int = 0

    // ...
}

Defining custom accessor methods is quite often not necessary in Swift.


This

    //If given a device use it otherwise make one
    var actualDevice = device
    if (actualDevice == nil)
    {actualDevice = MTLCreateSystemDefaultDevice()}

is better and simpler done with the nil-coalescing operator ??:

    // If given a device use it otherwise make one
    let actualDevice = device ?? MTLCreateSystemDefaultDevice()

If you add a default value to the initializer

init?(dataType: T.Type, maxBuffers: Int, count: Int, device: MTLDevice? = nil) { ... }

then it can be called without a device instead of passing nil:

guard let mb = BasicMB(dataType: Int.self, maxBuffers: 3, count: 5) else { ... } 

The conversion to UnsafeMutableRawPointer in

data = UnsafeMutableRawPointer(deviceBuffer.contents()).bindMemory(to:type.self, capacity:count)

is not needed because deviceBuffer.contents() already returns an UnsafeMutableRawPointer:

data = deviceBuffer.contents().bindMemory(to:type.self, capacity:count)

This

func update(dataIn: T, index: Int = 0)
{
    data[index] = dataIn
}

is more Swiftily done as a subscript operator:

subscript(index: Int) -> T {
    get {
        return data[index]
    }
    set {
        data[index] = newValue
    }
}

which is then used as

mb[0] = 1

A possible alternative would be to expose a view to the currently used buffer area as an UnsafeMutableBufferPointer:

var currentBuffer: UnsafeMutableBufferPointer<T> {
    return UnsafeMutableBufferPointer(start: deviceBuffer.contents().bindMemory(to:type.self, capacity:arrayCount), count: arrayCount)
}

The

//Avoid running in release builds
func reportAllValues() { ... }

is apparently meant for debugging, and that is what the CustomDebugStringConvertible protocol is for in Swift

extension BasicMB: CustomDebugStringConvertible {
    var debugDescription: String {
        return ...
    }
}

Then debugPrint(mb) prints the debug description.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.