一、在GPU上执行运算

本文Demo

环境:

mac os 10.14.5

xcode 10.3

此系列文章源自官方案例,详情至 此处

专用名词虽有汉字翻译,但会保留原有英文形式名词。

 

概述

在此示例中,会学习在所有 Metal apps 中使用到的基本要素:

a)把用 C 写的简单函数转化成 Metal Shading Language(MSL),因此可以在 GPU 上运行

b)找到 GPU

c)通过创建管道准备在 GPU 上运行 MSL 函数

d)创建 GPU 可访问的内存分配以保存数据

e)创建命令缓冲区和编码GPU命令去控制数据

f)提交缓冲区到命令队列,使GPU执行编码命令

 

1.写一个GPU函数执行计算

将两个具有相同元素个数的数组相加,结果放入第三个数组。下面计算函数用 C 编写,运行在 CPU 上。

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上执行计算,需要用 MSL 重写该方法。 MSL 是专为 GPU编程 设计的 C++ 的变种。在 Metal中,运行在 GPU 上的代码叫做shader,因历史缘故,它们第一次在3D绘制中被用来计算颜色。

示例在 add.metal 文件中定义了用 MSL编写的 shader。Xcode会编译所有的.metal 文件,生成默认的 Metal库 导入app。

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];
}

由上可知:

需要添加 kernel关键字,声明此函数是:

a)公共的GPU函数。公共函数 是app能够调用的唯一函数。公共函数不能被其他 shader函数 调用。

b)计算函数(也称计算内核)。使用线程网格执行并行计算。

 

add_arrays 函数的前三个参数有 device关键字,表明指针在 device地址空间。MSL 为内存定义几个不相交的地址空间。在 MSL中,无论何时申请指针,都需要提供关键字声明地址空间。device地址空间 声明了GPU可以读取和写入的持久内存。

之所以函数移除for 循环,因为现在在网格计算中通过多线程被调用。示例创建1D的网格线程与数组的大小匹配,因此数组中的每一个元素都通过不同的线程计算。

add_arrays 函数的第四个参数,使用MSL的 thread_position_in_grid关键字,指定使用C++属性语法。这个关键字声明 Metal 应该为每个线程计算一个独特的索引,在参数中传递。由于add_arrays 函数使用1D网格,索引当作标量整数。

 

2.发现GPU

MTLDevice对象是GPU的精简抽象,使用它与GPU建立连接。Metal 为每个GPU创建 MTLDevice对象。通过调用 MTLCreateSystemDefaultDevice 获取默认的 device对象。在MacOS中,有多个GPUs的Mac,Metal 选择其中一个作为默认 device。

id device = MTLCreateSystemDefaultDevice();

 

3.初始化Metal对象

示例自定义 MetalAddr类管理与GPU连接的对象。类初始化时创建这些对象并存储在属性中。

MetalAdder* adder = [[MetalAdder alloc] initWithDevice:device];

在 Metal 中,费时的任务可以运行一次并且保存结果,使用时方便。在性能要求高的代码中很少使用此类任务。

 

4.获取Metal功能的引用

当编译app时,Xcode会编译 add_arrays 函数到默认的 Metal库中。用 MTLLibrary和 MTLFunction对象来获取 Metal库 和 所包含函数的相关信息。想要获得add_arrays 函数的引用,需要 MTLDevice创建 MTLLibrary对象,然后通过 MTLLibrary获取函数引用。

- (instancetype) initWithDevice: (id) device
{
    self = [super init];
    if (self)
    {
        _mDevice = device;
        
        NSError* error = nil;
        
        // Load the shader files with a .metal file extension in the project

        id defaultLibrary = [_mDevice newDefaultLibrary];
        if (defaultLibrary == nil)
        {
            NSLog(@"Failed to find the default library.");
            return nil;
        }

        id addFunction = [defaultLibrary newFunctionWithName:@"add_arrays"];
        if (addFunction == nil)
        {
            NSLog(@"Failed to find the adder function.");
            return nil;
        }

 

5.准备Metal Pipeline(管道)

该函数对象是 MSL函数 的代理,不是可执行的代码。通过创建 pipeline将函数转变成可执行的代码。Pipeline(管道)指定GPU执行完成指定任务的步骤。在 Metal 中,用 pipeline state object(管道状态对象)代表一个Pipeline(管道)

示例使用计算功能,因此创建 MTLComputePipelineState 对象。

_mAddFunctionPSO = [_mDevice newComputePipelineStateWithFunction: addFunction error:&error];

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

当创建 pipeline state object 时,device对象将完成为指定GPU编译的功能。示例同步创建 pipeline state object,因为编译需要一些时间,因此避免在性能要求的代码中使用。

 

6.创建Command Queue(命令队列)

向GPU发送任务,需要命令队列。Metal 使用命令队列去安排命令。需要 MTLDevice 创建命令队列:

_mCommandQueue = [_mDevice newCommandQueue];

 

7.创建数据缓冲区以及加载数据

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

GPU可以拥有专有内存,或者与操作系统共享内存。Metal 和 操作系统内核需要执行额外的工作,将数据保存在内存中,供GPU使用。Metal 用 resource对象(MTLResource)抽象内存管理。resource(资源)是运行命令时GPU可访问的内存分配。

示例创建三个缓冲区,用随机数填充前两个。第三个是存储 add_arrays 的结果。

_mBufferA = [_mDevice newBufferWithLength:bufferSize options:MTLResourceStorageModeShared];
_mBufferB = [_mDevice newBufferWithLength:bufferSize options:MTLResourceStorageModeShared];
_mBufferResult = [_mDevice newBufferWithLength:bufferSize options:MTLResourceStorageModeShared];

[self generateRandomFloatData:_mBufferA];
[self generateRandomFloatData:_mBufferB];

示例中的资源是 MTLBuffer对象,没有预定义格式的内存分配。Metal 将每个缓冲区作为不透明的字节集合。然而,当你在 shader 中使用缓冲区指定格式。意味着 shaderapp 来回传递的任何数据格式需要一致。

分配缓冲区时,需要提供存储模式去确定它的性能参数以及CPU或GPU是否可以访问。示例使用了共享内存(MTLResourceStorageModeShared),即CPU和GPU都可访问。

要用随机数填充缓冲区,app获得缓冲区的内存指针,并且在CPU上写数据。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);
    }
}

 

8.创建 Command Buffer(命令缓冲区)

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

id commandBuffer = [_mCommandQueue commandBuffer];

 

9.创建 Command Encoder(命令编码器)

要将命令写入 命令缓冲区,可以使用 命令编码器来处理要编码的特定命令类型。

示例创建一个计算命令编码器,用于编码计算传递过程。计算传递包含执行计算管道的命令列表。每个计算命令都会让GPU创建一个在GPU上执行的线程网格。

id computeEncoder = [commandBuffer computeCommandEncoder];

要编码一个命令,需要在编译器上调用一系列方法。一些方法设置状态信息,像 pipeline state object(PSO 管道状态对象)或传递给管道的参数。做完状态改变之后,编码命令去执行管道。编译器将所有的状态改变和命令参数写入命令缓冲区。

一、在GPU上执行运算_第1张图片

 

 

10.设置管道状态和参数数据

 

设置要执行命令的管道的管道状态对象。然后为管道需要发送到 add_arrays函数的所有参数设置数据。管道提供对三个缓冲区的引用。Metal 自动为函数的缓冲区参数提供索引,以0开始。

[computeEncoder setComputePipelineState:_mAddFunctionPSO];
[computeEncoder setBuffer:_mBufferA offset:0 atIndex:0];
[computeEncoder setBuffer:_mBufferB offset:0 atIndex:1];
[computeEncoder setBuffer:_mBufferResult offset:0 atIndex:2];

也可以为参数指定偏移量。为0表示从缓冲区的开始访问数据。也可以用一个缓冲区存储多个参数,为每个参数指定偏移量。

不用为索引参数提供数据,因为 add_arrays函数 定义它的值通过GPU提供。

 

11.指定线程个数和架构

接下来,决定线程个数以及架构。Metal 可以创建1D、2D和3D的网格。因 add_arrays函数 用1D的数组,所以示例创建1D的网格(datasize x 1 x 1),索引从 0 到 datasize -1。

MTLSize gridSize = MTLSizeMake(arrayLength, 1, 1);

 

12.指定线程组大小

Metal 将网格细分成更小的网格称作 线程组。每个线程组单独计算。Metal 派发线程组在GPU上去处理不同的元素,以加快处理速度。决定为命令创建多大的线程组:

NSUInteger threadGroupSize = _mAddFunctionPSO.maxTotalThreadsPerThreadgroup;
if (threadGroupSize > arrayLength)
{
    threadGroupSize = arrayLength;
}
MTLSize threadgroupSize = MTLSizeMake(threadGroupSize, 1, 1);

示例获取 pipeline state object(管道状态对象)的可能最大的线程组,如果大于设置数据的大小则缩小它。 MaxTotalThreadsPerThreadroup 属性获得允许最大线程组,它的变化取决于创建管道状态对象的函数的复杂性。

 

13.编写计算命令以执行线程

最后,编写命令派发网格线程。

[computeEncoder dispatchThreads:gridSize
          threadsPerThreadgroup:threadgroupSize];

当GPU执行这条命令时,使用先前设置的状态和命令的参数派发线程以执行计算。

可以使用编码器执行相同的步骤,编写多个计算命令放入计算传递过程,而不执行冗余的步骤。例如,可能设置管道状态对象一次,然后为要处理的每个缓冲区集合设置参数以及编码命令。

 

14.结束计算传递过程

当没有命令添加进计算传递过程,结束编码处理进而关闭计算传递过程。

[computeEncoder endEncoding];

 

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

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

[commandBuffer commit];

命令队列创建缓冲区,因此提交总是在队列上。提交之后,Metal 准备异步执行命令并且计划缓冲区在GPU上执行。GPU执行完缓冲区的所有命令之后,Metal 标记缓冲区结束。

 

16.等待计算完成

当GPU处理命令时可以做其他任务。示例没有其他任务,所以等待直到任务完成。

[commandBuffer waitUntilCompleted];

如果要在Metal处理完所有的命令后收到通知,需要添加完成处理程序(addCompletedHandler:);或者通过 status属性获取缓冲区状态。

 

17.从缓冲区读取结果

命令缓冲区完成之后,GPU的计算结果存储在输入的缓冲区,Metal执行一些步骤之后确保cpu可以访问。在app中,可能想从缓冲区读取结果并且做一些处理(如在屏幕展示或者写入文件)。由于计算结果仅用作于创建 Metal 应用的过程,示例从输出缓冲区读取结果并且测试确保CPU和GPU计算结果相同。

- (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]);
    }
}

 


个人博客:https://blog.csdn.net/Crazy_SunShine

Github:https://github.com/cxymq

个人公众号:Flutter小同学

个人网站:http://chenhui.today/

你可能感兴趣的:(Metal)