除了進行圖形渲染,我們還可以利用GPU硬件特點止毕,將一些在CPU上執(zhí)行起來很耗時的計算任務分配給GPU來完成(一些特定的計算任務炫欺,在GPU上快的真不是一點半點)。GPGPU Programming(General-purpose GPU Programming)的概念由來已久掰读,但在使用OpenGL與GPU打交道時,我們只能用比較隱蔽的方式來實踐叭莫,比如將我們想執(zhí)行的計算任務嵌入到圖形渲染管線當中蹈集。但有了Metal,我們就不需要這么拐彎抹角了雇初。Metal提供了專門的計算管線拢肆,讓我們可以用更加直接,易讀的代碼調度GPU來執(zhí)行計算任務靖诗。接下來用一個簡單的例子(調整圖片的飽和度)來一起學習一下郭怪,如何使用Metal做計算。
Metal中的一些基本概念
在開始代碼開發(fā)之前刊橘,我們首先對Metal中的基本類和概念進行下簡單的回顧鄙才。包括:
- MTLDevice
- MTLCommandQueue
- MTLCommandBuffer
- MTLCommandEncoder
- MTLCommand
- MTLComputePipelineState & MTLLibrary & MTLFunction
乍一看,涉及到的概念比較多促绵,但實際開發(fā)起來攒庵,這些類的串聯(lián)方式還是很直觀的,下面來一張圖來整理一下
在初始化階段败晴,我們需要獲得一個MTLDevice實例(可以理解它是GPU的操作接口)叙甸,然后由Device創(chuàng)建一個MTLCommandQueue(所有像GPU發(fā)送的指令都需要首先放到CommandQueue當中)。另外位衩,需要創(chuàng)建一個MTLLibrary對象(我的理解就是這里包含了編譯好的shader方法),然后由Library獲得用來描述具體計算任務的MTLFunction對象熔萧,再用Function對象來創(chuàng)建一個MTLComputePipelineState(類似渲染管線一樣的東西糖驴,我們稱為計算管線吧)。
在運行階段佛致,我們首先需要使用CommandQueue創(chuàng)建一個CommandBuffer出來贮缕,然后用CommandBuffer創(chuàng)建一個CommandEncoder,用來向CommandBuffer中寫入指令俺榆。指令寫入完成之后感昼,調用CammandBuffer的commit方法,提交計算任務給GPU罐脊。
Talk is cheap
下面開始貼代碼
guard let device = MTLCreateSystemDefaultDevice() else {
return nil
}
guard let commandQueue = device.makeCommandQueue() else {
return nil
}
guard let library = device.makeDefaultLibrary() else {
return nil
}
guard let kernelFunction = library.makeFunction(name: "adjust_saturation") else {
return nil
}
let computePipelineState: MTLComputePipelineState
do {
computePipelineState = try device.makeComputePipelineState(function: kernelFunction)
} catch let _ {
return nil
}
在這段代碼里定嗓,依次創(chuàng)建了MTLDevice蜕琴,MTLCommandQueue,MTLLibrary宵溅,MTLFunction凌简,MTLComputePipelineState等對象。
在創(chuàng)建MTLFunction實例的時用到的 adjust_saturation 是定義在.metal文件中的shader方法恃逻,方法內容如下:
kernel void adjust_saturation(texture2d<float, access::read> inTexture[[texture(0)]],
texture2d<float, access::write> outTexture[[texture(1)]],
constant float* saturation [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]) {
float4 inColor = inTexture.read(gid);
float value = dot(inColor.rgb, float3(0.299, 0.587, 0.114));
float4 grayColor(value, value, value, 1.0);
float4 outColor = mix(grayColor, inColor, *saturation);
outTexture.write(outColor, gid);
}
這個方法的參數(shù)有兩張texture(一張用來做輸入雏搂,另外一張做輸出),一個float類型的參數(shù)寇损,作為飽和度計算參數(shù)以及標記為 [[thread_position_in_grid]]的gid參數(shù)凸郑,暫時認為gid標記了本次計算在整個計算任務當中的id。
關于kernel方法內部的實現(xiàn)矛市,這里就不多講了芙沥,大致上是使用輸入紋理中一個像素點的RGB值計算出它的灰度值,再根據(jù)saturation參數(shù)按一定比例混合彩色值與灰度值尘盼,輸出一個飽和度修改后的結果憨愉,寫入輸出紋理當中。
接下來是執(zhí)行計算相關的代碼
// prepare input texture
let cmImage = cmImageFromUIImage(uiImage: image) // 自定義方法卿捎,從UIImage對象加載圖片數(shù)據(jù)
let textureDescriptor = MTLTextureDescriptor()
textureDescriptor.width = cmImage.width
textureDescriptor.height = cmImage.height
textureDescriptor.pixelFormat = MTLPixelFormat.bgra8Unorm
textureDescriptor.usage = .shaderRead
let inTexture = device.makeTexture(descriptor: textureDescriptor)!
let region = MTLRegion(origin: MTLOrigin(x: 0, y: 0, z: 0), size: MTLSize(width: cmImage.width, height: cmImage.height, depth: 1))
inTexture.replace(region: region, mipmapLevel: 0, withBytes: NSData(data: cmImage.data!).bytes, bytesPerRow: cmImage.width * 4)
// prepare output texture
let outTextureDescriptor = MTLTextureDescriptor()
outTextureDescriptor.width = cmImage.width
outTextureDescriptor.height = cmImage.height
outTextureDescriptor.pixelFormat = MTLPixelFormat.bgra8Unorm
outTextureDescriptor.usage = MTLTextureUsage.shaderWrite
let outTexture = device.makeTexture(descriptor: outTextureDescriptor)!
guard let commandBuffer = commandQueue.makeCommandBuffer() else {
return nil
}
guard let commandEncorder = commandBuffer.makeComputeCommandEncoder() else {
return nil
}
commandEncorder.setComputePipelineState(computePipelineState)
commandEncorder.setTexture(inTexture, index: 0)
commandEncorder.setTexture(outTexture, index: 1)
var saturation: float_t = 0.1
commandEncorder.setBytes(&saturation, length: MemoryLayout<float_t>.size, index: 0)
let width = cmImage.width
let height = cmImage.height
let groupSize = 16
let groupCountWidth = (width + groupSize) / groupSize - 1
let groupCountHeight = (height + groupSize) / groupSize - 1
commandEncorder.dispatchThreadgroups(MTLSize(width: groupCountWidth, height: groupCountHeight, depth: 1), threadsPerThreadgroup: MTLSize(width: groupSize, height: groupSize, depth: 1))
commandEncorder.endEncoding()
commandBuffer.commit()
首先準備好兩個MTLTexture對象配紫,用來做計算的輸入和輸出。
然后創(chuàng)建CommandBuffer和CommandEncoder對象午阵,用CommandEncoder對象配置計算管線躺孝,配置kernel方法的輸入(inTexture, outTexture, saturation 等)。
最后通過dispatchThreadgroups方法底桂,將計算任務分發(fā)到GPU植袍。 這里引入了Metal Compute中的另外的三個概念:
- thread
- thread group
- grid size
首先,關于grid size
A compute pass must specify the number of times to execute a kernel function. This number corresponds to the grid size, which is defined in terms of threads and threadgroups.
即籽懦,grid size定義了一次GPU的compute pass里于个,shader方法需要執(zhí)行的總次數(shù)。grid size使用MTLSize數(shù)據(jù)結構來定義暮顺,包含三個分量厅篓,在本例當中,grid size為(imageWidth, imageHeight, 1)捶码。同時羽氮,根據(jù)文檔的描述,我們不會直接去設置grid size惫恼,而是通過設置thread group size和thread group counts的方式來間接設置grid size鸟蜡。
關于 thread group size / thread group count
A threadgroup is a 3D group of threads that are executed concurrently by a kernel function.
thread group size定義了一次有多少計算被并行執(zhí)行兽泄。thread group size的最大值和GPU硬件有關询吴,在本例當中我們使用(16验懊, 16,1),即一次有256個計算任務被并行執(zhí)行。 根據(jù)圖片的分辨,我們可以計算得到thread group count旬蟋。
最后,我們可以在GPU計算完成后革娄,從outTexture獲得計算結果倾贰,再轉換成UIImage對象。
commandBuffer.waitUntilCompleted()
// create image from out texture
let imageBytes = UnsafeMutablePointer<UInt8>.allocate(capacity: cmImage.width * cmImage.height * 4)
outTexture.getBytes(imageBytes, bytesPerRow: cmImage.width * 4, from: region, mipmapLevel: 0)
let context = CGContext(data: imageBytes, width: cmImage.width, height: cmImage.height, bitsPerComponent: 8, bytesPerRow: cmImage.width * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
let cgImage = context.makeImage()!
return UIImage(cgImage: cgImage, scale: 1.0, orientation: UIImageOrientation.downMirrored)
UIImage --> MTLTexture
示例代碼中拦惋,使用了一個自定義的方法從UIImage對象中獲取像素數(shù)據(jù)匆浙,下面把相關代碼貼出來,僅供參考
class CMImage: NSObject {
var width: Int = 0
var height: Int = 0
var data: Data?
}
func cmImageFromUIImage(uiImage: UIImage) -> CMImage {
let image = CMImage()
image.width = Int(uiImage.size.width)
image.height = Int(uiImage.size.height)
let bytes = UnsafeMutablePointer<UInt8>.allocate(capacity: image.width * image.height * 4)
let context = CGContext(data: bytes, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: image.width * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
context?.translateBy(x: 0, y: uiImage.size.height)
context?.scaleBy(x: 1, y: -1)
context?.draw(uiImage.cgImage!, in: CGRect(x: 0, y: 0, width: uiImage.size.width, height: uiImage.size.height))
image.data = Data(bytes: bytes, count: image.width * image.height * 4)
return image
}
寫在最后
為了圖方便厕妖,在本例中首尼,將Init Phase和Compute Pass相關的代碼都塞入了一個方法當中, 但根據(jù)蘋果的最佳實踐文檔言秸,Device软能, Library,CommandQueue举畸,ComputePipeline等對象應當僅在App的初始化過程中創(chuàng)建一次查排,而不是每次執(zhí)行計算都重復創(chuàng)建。
以上僅能算作Metal計算方面的Hello World抄沮,后面還有很多的內容值得我們去深入學習跋核,感興趣的朋友們一起加油吧!