【Metal】在GPU上执行计算(I)

下载

概述

在此示例中,您将学习所有Metal应用程序中使用的基本任务:

  • 将用C编写简单的函数转换为Metal Shading Language(MSL),以便它可以在GPU上运行
  • 找到GPU
  • 通过创建管道使MSL函数在GPU上运行
  • 创建GPU可访问的内存以保存数据
  • 创建命令缓冲区并编码GPU命令以操纵数据
  • 将缓冲区提交到命令队列以使GPU执行编码命令

编写GPU函数以执行计算

为了说明GPU编程,这个应用程序将两个数组的想应元素添加到一起,将结果写入第三个数组。清单1显示了一个在CPU上执行此计算的函数,用C语言编写。它循环遍历索引,计算循环每次一迭代的结果。

清单1 数组添加,用C编写

void add_arrays(const float* inA, 
                const float* inB,
                float* result,
                int length)
{
    for (int index = 0; index < length; index++) 
    { 
        result[index] = inA[index] + inB[index];
    }
}

每个值都是独立计算的,同时也可以安全地计算这些值。要在GPU上执行计算,您需要在Metal Shading Language(MSL)中重写此功能。MSL是为GPU编程设计的C++的变体。在Metal中,在GPU上运行的代码称为着色器(shader),因为历史上他们首先用于计算3D图形中的颜色。清单2显示了MSL中的着色器,他执行与清单1相同的计算。示例项目add.metal文件中定义了此函数。Xcode构建应用程序目标中的所有文件,并创建一个默认的Metal库,它嵌入到您的应用程序中。您将在本实例的后面看到如何加载默认库。

清单2 数组添加,用MSL编写

kernel void add_arrays(device const float* inA,
                       device const float* inB,
                       device float* result,
                       uint index [[thread_position_in_grid]])
{
    // for循环被一组线程代替,每个线程都会调用此函数
    result[index] = inA[index] + inB[index];
}

清单1清单2类似,但MSL版本有一些重要区别。仔细看看清单2

首先,该函数添加一个kernel关键字,声明该函数是:

  • 一个公共的GPU功能。公共函数是您的应用可以看到的唯一功能。其他着色器函数也无法调用公共函数。
  • 一个计算函数(也称为计算内核),其执行使用线程的网格中的并行计算。

参阅使用渲染管道渲染基元以了解用于声明公共图形函数的其他函数的关键字。

add_array函数使用device关键字声明了三个参数,该关键字表示这些指针位于地址空间中。MSL为内存定义了几个不相交的地址空间。每当在MSL中声明指针时,都必须提供一个关键字来声明其地址空间。使用device地址空间声明GPU可以读取和写入持久内存。

清单2清单1中删除了for循环,因为该函数现在将由计算网格中的多个线程调用。此示例创建一个完全匹配数组维度的一维线程网络。

要替换先前由for循环提供的索引,该函数将index使用另一个MSL关键字thread_position_in_grid获取一个新参数,该参数使用C++属性语法指定。此关键字声明Metal应为每一个线程计算唯一索引,并在此参数中传递该索引。因为add_arrays使用1D网格,索引被定义为标量整数。即使删除了循环,清单1清单2也使用相同的代码将两个数字相加。如果要将类似的代码从C或者C++转化为MSL,请以相同的网格替换循环逻辑。

寻找一个GPU

在你的应用程序中,MTLDevice对象是GPU的精简抽象,你用它来与GPU通信。Metal为每一个GPU创建了MTLDevice,你可以通过调用MTLCreateSystemDefaultDevice()获取默认的device对象。在macOS中,Mac可以有多个GPUMetal可以选择其中一个GPU作为默认值并返回该GPUdevice对象。在macOS中,Metal提供了可用于检索所有device对象的其他API,但此示例仅适用默认值。

let device = MTLCreateSystemDefaultDevice()

初始化Metal对象

Metal将其他与GPU相关的实体(如编译着色器(compiled shaders)、内存缓冲区(memory buffers)和纹理(textures))表示为对象。要创建这些特定于GPU的对象,可以调用MTLDevice的相关函数或者由MTLDevice创建的对象调用函数。由device对象直接或间接创建的所有对象仅可用于该device对象。使用多个GPU的应用程序将使用多个device对象,并为每个device创建类似的Metal对象层次结构。
示例应用程序使用自定义类MetalAdder来管理与GPU通信所需的对象。类的初始化程序创建这些对象并将它们存储在其属性中。该应用程序创建此类的实例,传入Metaldevice对象用以创建辅助对象。该MetalAdder对象保持对Metal对象的强引用,直到它完成执行。

let adder = MetalAdder(device: device)

Metal中,昂贵的初始化任务可以运行一次,保留结果并且使用成本低廉。您很少需要在性能敏感的代码中运行此类任务。

获取Metal功能的参考

初始化程序所做的第一件事是加载函数并准备它在GPU上运行。在构建应用程序时,Xcode会编译add_arrays函数并使其添加它嵌入应用程序的默认Metal库中。您可以使用MTLLibraryMTLFunction对象获取有关Metal库及其中包含的函数信息。要获取add_arrays函数的对象,请要求MTLDevice来创建默认库MTLLibrary对象,然后向库请求表示着色器函数的MTLFunction对象。

init?(device: MTLDevice) {
    super.init()
        
    mDevice = device
        
    guard let defaultLibrary = mDevice?.makeDefaultLibrary() else {
        print("Failed to find the default library.")
        return nil
    }
        
    guard let addFunction = defaultLibrary.makeFunction(name: "add_arrays") else {
        print("Failed to find adder function.")
        return nil
    }
}

准备Metal管道(Pipeline)

函数对象是MSL函数的代理,但它不是可执行代码。您可以通过创建管道(pipeline)将该函数转为可执行代码。管道指定GPU执行以完成特定任务步骤。在Metal中,管道由管道状态对象(pipeline state)表示。由于此示例使用计算功能,因此应用程序会创建一个MTLComputePipelineState对象。

do {
    try mAddFunctionPOS = mDevice?.makeComputePipelineState(function: addFunction)
} catch {
    print("Failed to created pipline state object, error: \(error.localizedDescription).")
}

计算管道运行单个计算功能,可选地在运行函数之前操纵输入数据,然后运行输出数据。

创建管道状态对象时,设备对象将完成为此特定的GPU*编译的功能。此示例同步创建管道状态对象并将其直接返回给应用程序。因为编译确实需要一段时间,所以避免在性能敏感的代码中同步创建管道状态对象。

注意
Metal到目前为止您看到的代码中返回的所有对象都将作为符合协议的对象返回。Metal使用协议来定义大多数特定于GPU的对象,以抽象到底层实现,这些类可能因不同的GPU而异。Metal使用类定义与GPU无关的对象。任何给定的Metal协议的参考文档都清楚的表明您是否可以在应用程序中实现该协议。

创建一个命令队列(Command Queue)

要将工作发送到GPU,您需要一个命令队列。Metal使用队列命令来安排命令。通过询问一个MTLDevice来创建一个命令队列。

mCommandQueue = device.makeCommandQueue()

创建数据缓冲区和加载数据

初始化基本Metal对象后,加载要执行的GPU数据。此任务的性能不太重要,但在应用程序启动的早期仍然有用。

GPU可以拥有自己的专用内存,也可以与操作系统共享内存。Metal和操作系统内核需要执行额外的工作,以便将数据存储在内存中并使数据可供GPU使用。Metal使用资源对象抽象此内存管理。(MTLResource)。资源是GPU在运行命令时可以访问的内存分配。使用MTLDevice为其GPU创建资源。

示例应用程序创建了三个缓存区,并使用随机数据填充前两个缓冲区。第三个缓冲将在add_arrays存储其结果。

mBufferA = mDevice?.makeBuffer(length: bufferSize, options: .storageModeShared)
mBufferB = mDevice?.makeBuffer(length: bufferSize, options: .storageModeShared)
mBufferResult = mDevice?.makeBuffer(length: bufferSize, options: .storageModeShared)
        
generateRandomFloatData(buffer: mBufferA)
generateRandomFloatData(buffer: mBufferB)

此示例中的资源是(MTLBuffer)对象,她们是没有预定义格式的内存分配。Metal将每个缓冲区管理为不透明的字节集合。但是,在着色器中使用缓冲区时指定格式。这意味着您的着色器和您的应用需要就来回传递的任何数据的格式达成一致。
分配缓存区时,您需要提供存储模式以确定其某些性能特征以及CPU或者GPU是否可以访问它。应用程序使用共享内存(storageModeShared),CUPGPU都可以访问它。
要使用随机数据填充缓冲区,应用程序将获取指向缓存区内存的指针,并在CPU上将数据写入其中。清单2中的add_arrays函数将参数声明为浮点数的数组,因此您以相同的格式提供缓冲区:


- (void) generateRandomFloatData:(id) buffer
{ 
    float* dataPtr = buffer.contents;
    for (unsigned long index = 0; index < arrayLength; index++)
    {
        dataPtr[index] = (float)rand()/(float)(RAND_MAX);
    }
}
/// Swift不会,所以用OC

创建一个命令缓冲区

请求命令队列创建命令缓冲区。

let commandBuffer = mCommandQueue?.makeCommandBuffer()

创建一个命令编码器

要将命令写入命令缓冲区,可以使用命令编码器来处理要编码的特定命令类型。此示例创建一个计算命令编码器,用于编码计算传递。计算传递包含执行计算管道的命令列表。每个计算命令都会使GPU创建一个在GPU上执行的线程网络。

let commandEncoder = commandBuffer?.makeComputeCommandEncoder()

要对命令进行编码,请在编码器上进行一系列方法调用。某些方法设置状态信息,如管道状态对象(PSO)或要传递给管道的参数。进行状态更改后,您将编码命令以执行管道。编码器将所有状态改变和命令参数写入命令缓冲区。

image

设置管道状态和参数数据

设置要执行命令的管道的管道状态对象。然后为管道需要发送到add_arrays函数的任何参数设置数据。对于此管道,这意味着提供对三个缓冲区的引用。Metal以参数出现在清单2中的函数声明中的顺序自动为缓冲区参数指定索引,从0开始,您使用相同的索引提供参数。

commandEncoder.setComputePipelineState(mAddFunctionPOS!)
commandEncoder.setBuffer(mBufferA, offset: 0, index: 0)
commandEncoder.setBuffer(mBufferB, offset: 0, index: 1)
commandEncoder.setBuffer(mBufferResult, offset: 0, index: 2)

您还可以为每个参数指定偏移量。偏移0意味着命令将从缓冲区来存储多个参数,为某个参数指定偏移量。
您没有为index参数指定任何数据,因为add_arrays函数将其值定义为由GPU提供。

指定线程计数和组织

接下来,确定要创建的线程数以及如何组织这些线程。Metal可以创建1D、2D和3D网络。add_array函数使用一维数组(dataSize x 1 x 1),Metal从该网格生成0到-1之间的索引。

指定线程组大小

Metal将网格细分为称为线程组的较小网格。Metal可以将线程组分派到CPU上的不同处理元素,以加快处理速度。您还需要确定为命令创建线程组的大小。

let gridSize = MTLSize(width: arrayLenght, height: 1, depth: 1)
var threadGroupSizeInt = mAddFunctionPOS?.maxTotalThreadsPerThreadgroup
assert(threadGroupSizeInt != nil, "Failed to get thread group size.")
        
if threadGroupSizeInt! > arrayLenght {
threadGroupSizeInt = arrayLenght
}
let threadGroupSize = MTLSize(width: arrayLenght, height: 1, depth: 1)

应用程序管道状态对象请求最大可能的线程组,并在该大小大于数据集时收缩它。该maxTotalThreadsPerThreadgroup属性给出线程组允许的最大线程数,这取决于用于创建管道状态对象的函数的复杂性。

编写Compute命令以执行线程

最后,编码命令以调度线程网络。

commandEncoder.dispatchThreads(gridSize, threadsPerThreadgroup: threadGroupSize)

GPU执行此命令时,它使用您先前设置的状态和命令的参数来分派线程以执行计算。

您可以使用编码器执行相同的步骤,将多个计算命令编码到计算传递中,而不执行任何冗余步骤。例如,您可以设置一次管道状态对象,然后为要处理的每个缓冲区集合设置参数并编码命令。

结束计算通行证

如果没有其他命令添加到计算传递,则结束编码过程以关闭计算传递。

commandEncoder?.endEncoding()

提交命令缓冲区以执行命令

通过将命令缓冲区提交到队列来运行命令缓冲区中的命令。

commandBuffer?.commit()

命令队列创建了命令缓冲区,因此提交缓冲区始终将其放在该队列上。提交命令缓冲区后,Metal异步准备执行命令,然后调度命令缓冲区以在GPU上执行。GPU执行命令缓冲区中的所有命令后,Metal将命令缓冲区标记为完成。

等待计算完成

GPU处理您的命令时,您的应用程序可以执行其他工作。此示例不需要执行任何其他工作,因此只需等待命令缓冲区完成。

commandBuffer?.waitUntilCompleted()

或者,要在Metal处理完所有命令时收到通知,请在命令缓冲区中添加完成处理程序(addCompletedHandler(_:)),或者通过读取其status来检查命令缓冲区的状态。

从缓冲区读取状态

命令缓冲区完成后,GPU的计算存储在输出缓冲区中,Metal执行任何必要的步骤以确保CPU可以看到他们。在真实的应用程序中,您将从缓冲区读取结果并对其执行某些操作,例如在屏幕上显示结果或将其写入文件。由于计算仅用于说明创建Metal应用程序的过程,因此示例将读取存储在输出缓冲区中的值并进行测试以确保CPUGPU计算出相同的结果。

- (void) verifyResults
{
    float* a = mBufferA.contents;
    float* b = mBufferB.contents;
    float* result = mBufferResult.contents;
    for (unsigned long index = 0; index < arrayLength; index++) 
    {
        assert(result[index] = a[index] + b[index]);
    }
}
/// Swift不会,所以用OC

你可能感兴趣的:(【Metal】在GPU上执行计算(I))