Metal 框架之同步 CPU 与 GPU 工作
概述
通过本示例,你将了解如何管理数据依赖,并避免 CPU 和 GPU 之间的处理器等待。
本示例渲染连续的三角形,这些三角形沿着正弦波顺序排列。每一帧都会更新三角形顶点的位置,然后渲染新图像。这些动态更新的数据会产生一种运动错觉,三角形似乎沿着正弦波移动。
该示例将三角形顶点存储在 CPU 和 GPU 共享的缓冲区中。 CPU 将数据写入缓冲区,GPU 读取它。
数据依赖和处理器等待
资源共享造成处理器之间的数据依赖性; CPU 必须在 GPU 读取资源之前完成对资源的写入。 如果 GPU 在 CPU 写入资源之前读取资源,则 GPU 会读取未定义的资源数据。 如果在 CPU 写入资源时 GPU 读取资源,GPU 会读取不正确的资源数据。这些数据依赖性会在 CPU 和 GPU 之间造成处理器等待; 每个处理器在开始自己的工作之前必须等待另一个处理器完成它的工作。
不过由于 CPU 和 GPU 是独立的处理器,因此可以通过使用一个资源的多个实例使它们同时工作。 每帧中,必须为着色器提供相同的参数,但这并不意味着需要引用相同的资源对象。相反,可以创建一个资源的多个实例池,并在每次渲染帧时使用不同的实例。如下图所示,CPU 可以将位置数据写入第 n+1 帧使用的缓冲区,同时 GPU 从第 n 帧使用的缓冲区中读取位置数据。 通过使用缓冲区的多个实例,不断渲染帧的情况下,CPU 和 GPU 就可以连续工作并避免停顿。
用 CPU 初始化数据
自定义一个结构体 AAPLVertex 来表示每个顶点,包含位置和颜色属性:
typedef struct { vector_float2 position; vector_float4 color; } AAPLVertex;复制代码
自定义一个 AAPLTriangle 类,该类提供一个获取三角形接口,该三角形由 3 个顶点组成:
+(const AAPLVertex *)vertices { const float TriangleSize = 64; static const AAPLVertex triangleVertices[] = { // Pixel Positions, RGBA colors. { { -0.5*TriangleSize, -0.5*TriangleSize }, { 1, 1, 1, 1 } }, { { 0.0*TriangleSize, +0.5*TriangleSize }, { 1, 1, 1, 1 } }, { { +0.5*TriangleSize, -0.5*TriangleSize }, { 1, 1, 1, 1 } } }; return triangleVertices; }复制代码
用位置和颜色初始化多个三角形顶点,并将它们存储在三角形数组 (_triangles
) 中:
NSMutableArray *triangles = [[NSMutableArray alloc] initWithCapacity:NumTriangles]; // Initialize each triangle. for(NSUInteger t = 0; t < NumTriangles; t++) { vector_float2 trianglePosition; // Determine the starting position of the triangle in a horizontal line. trianglePosition.x = ((-((float)NumTriangles) / 2.0) + t) * horizontalSpacing; trianglePosition.y = 0.0; // Create the triangle, set its properties, and add it to the array. AAPLTriangle * triangle = [AAPLTriangle new]; triangle.position = trianglePosition; triangle.color = Colors[t % NumColors]; [triangles addObject:triangle]; } _triangles = triangles;复制代码
分配数据存储
计算三角形顶点的总存储大小。 App 渲染了 50 个三角形,每个三角形有3个顶点,共150个顶点,每个顶点是 AAPLVertex 结构的大小:
const NSUInteger triangleVertexCount = [AAPLTriangle vertexCount]; _totalVertexCount = triangleVertexCount * _triangles.count; const NSUInteger triangleVertexBufferSize = _totalVertexCount * sizeof(AAPLVertex);复制代码
初始化多个缓冲区以存储顶点数据的多个副本。 对于每个缓冲区,分配恰好足够的内存来存储 150 个顶点:
for(NSUInteger bufferIndex = 0; bufferIndex < MaxFramesInFlight; bufferIndex++) { _*vertexBuffers[bufferIndex] = [* _device newBufferWithLength:triangleVertexBufferSize options:MTLResourceStorageModeShared]; _*vertexBuffers[bufferIndex].label = [NSString stringWithFormat:@"Vertex Buffer* #%lu", (unsigned long)bufferIndex]; }复制代码
初始化时,_vertexBuffers
数组中缓冲区实例的内容为空。
使用 CPU 更新数据
每一帧中,在 draw(in:) 渲染循环开始时,使用 CPU 更新 updateState 方法中一个缓冲区实例的内容:
// Vertex data for the current triangles. AAPLVertex *currentTriangleVertices = _vertexBuffers[_currentBuffer].contents; // Update each triangle. for(NSUInteger triangle = 0; triangle < NumTriangles; triangle++) { vector_float2 trianglePosition = _triangles[triangle].position; // Displace the y-position of the triangle using a sine wave. trianglePosition.y = (sin(trianglePosition.x/waveMagnitude + _wavePosition) * waveMagnitude); // Update the position of the triangle. _triangles[triangle].position = trianglePosition; // Update the vertices of the current vertex buffer with the triangle's new position. for(NSUInteger vertex = 0; vertex < triangleVertexCount; vertex++) { NSUInteger currentVertex = vertex + (triangle * triangleVertexCount); currentTriangleVertices[currentVertex].position = triangleVertices[vertex].position + _triangles[triangle].position; currentTriangleVertices[currentVertex].color = _triangles[triangle].color; } }复制代码
更新缓冲区实例后,在同一帧余下的时间内,不能使用 CPU 访问其数据。
注释: 在提交命令缓冲区(引用一个缓冲区实例)之前,必须完成缓冲区实例的 CPU 所有写入。 否则,GPU 可能会在 CPU 仍在写入缓冲区实例时开始读取缓冲区实例。复制代码
编码 GPU 命令
接下来,对渲染通道中引用缓冲区实例的命令进行编码:
[renderEncoder setVertexBuffer:_vertexBuffers[_currentBuffer] offset:0 atIndex:AAPLVertexInputIndexVertices]; // Set the viewport size. [renderEncoder setVertexBytes:&_viewportSize length:sizeof(_viewportSize) atIndex:AAPLVertexInputIndexViewportSize]; // Draw the triangle vertices. [renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:_totalVertexCount];复制代码
提交和执行 GPU 命令
在渲染循环结束时,调用命令缓冲区的 commit() 方法将工作提交给 GPU:
[commandBuffer commit];复制代码
GPU 从 RasterizerData 顶点着色器中的顶点缓冲区读取数据,将缓冲区实例作为输入参数:
vertex RasterizerData vertexShader(const uint vertexID [[ vertex_id ]], const device AAPLVertex *vertices [[ buffer(AAPLVertexInputIndexVertices) ]], constant vector_uint2 *viewportSizePointer [[ buffer(AAPLVertexInputIndexViewportSize) ]])复制代码
复用多个缓冲区实例
当两个处理器都完成了它们的工作时,一个完整的帧的工作就完成了。对于每一帧,执行以下步骤:
将数据写入缓冲区实例。
对引用缓冲区实例的命令进行编码。
提交包含编码命令的命令缓冲区。
从缓冲区实例读取数据。
当一帧的工作完成时,CPU 和 GPU 不再需要该帧中使用的缓冲区实例。 然而,丢弃一个使用过的缓冲区实例并为每一帧创建一个新的实例是昂贵且浪费的。 相反,如下所示,
设置 App 中的缓冲区实例 (_vertexBuffers
)为可以循环使用的先进先出(FIFO)队列,这样便可以额重复使用该队列。 队列中缓冲区实例的最大数量由 MaxFramesInFlight 的值定义,设置为 3:
static const NSUInteger MaxFramesInFlight = 3;复制代码
每一帧中,在渲染循环开始时,更新 _vertexBuffer
队列中的下一个缓冲区实例。 您按顺序循环遍历队列,每帧仅更新一个缓冲区实例; 在每三帧结束时,将返回到队列的开头:
// Iterate through the Metal buffers, and cycle back to the first when you've written to the last. _*currentBuffer = (* _currentBuffer + 1) % MaxFramesInFlight; // Update buffer data. [self updateState];复制代码
注释: Core Animation 提供优化的可显示资源,通常称为可绘制资源,供你渲染内容并将其显示在屏幕上。 Drawable 是高效但昂贵的系统资源,因此 Core Animation 限制了可以在 App 中同时使用的 drawable 的数量。 默认限制为 3,但可以使用 maximumDrawableCount 属性将其设置为 2(2 和 3 是支持的值)。 由于可绘制对象的最大数量为 3,因此此示例创建了 3 个缓冲区实例。 不需要创建比可用的最大可绘制数量更多的缓冲区实例。复制代码
管理 CPU 和 GPU 的工作速率
当你有多个缓冲区实例时,你可以让 CPU 用一个实例开始第 n+1 帧的工作,而 GPU 用另一个实例完成第 n 帧的工作。此实现通过使 CPU 和 GPU 同时工作来提高 App 的效率。但是,需要管理 App 的工作速率,以免超出可用缓冲区实例的数量。
要管理 App 的工作速率,需要使用信号量等待全帧完成,以防 CPU 的工作速度比 GPU 快得多。信号量是一个非 Metal 对象,用于控制对跨多个处理器(或线程)共享的资源的访问。信号量有一个关联的计数值,可以递减或递增该值,表示处理器是已开始还是已完成对资源的访问。在 App 中,信号量控制 CPU 和 GPU 对缓冲区实例的访问。使用 MaxFramesInFlight 的计数值初始化信号量,以匹配缓冲区实例的数量。此值表示 App 在任何给定时间最多可以同时处理 3 帧:
_inFlightSemaphore = dispatch_semaphore_create(MaxFramesInFlight);复制代码
在渲染循环开始时,将信号量的计数值减 1,表明已准备好处理新帧。 当计数值低于 0,信号量会使 CPU 等待,直到增加该值:
dispatch_semaphore_wait(_inFlightSemaphore, DISPATCH_TIME_FOREVER);复制代码
在渲染循环结束时,注册一个命令缓冲区完成处理回调。 当 GPU 完成命令缓冲区的执行时,它会调用此回调,并将信号量的计数值增加 1。这表明已完成给定帧的所有工作,可以重用该帧中使用的缓冲区实例:
__block dispatch_semaphore_t block_semaphore = _inFlightSemaphore; [commandBuffer addCompletedHandler:^(id<MTLCommandBuffer> buffer) { dispatch_semaphore_signal(block_semaphore); }];复制代码
addCompletedHandler(_ :) 方法注册了一个代码块,在 GPU 完成执行相关命令缓冲区后立即调用该代码块。 由于每帧仅使用一个命令缓冲区,因此收到完成回调表示 GPU 已完成该帧。
设置缓冲区的可变性
App 在单个线程上执行所有每帧渲染设置。 首先,使用 CPU 将数据写入缓冲区实例。 之后,编码缓冲区实例的渲染命令。 最后,提交一个命令缓冲区供 GPU 执行。 由于这些任务在单个线程上始终按此顺序执行,因此 App 保证,在对引用缓冲区实例的命令进行编码之前,完成将数据写入缓冲区实例。
此顺序允许将缓冲区实例标记为不可变的。配置渲染管道描述符时,将缓冲区实例索引处的顶点缓冲区的 mutability 属性设置为 MTLMutability.immutable:
pipelineStateDescriptor.vertexBuffers[AAPLVertexInputIndexVertices].mutability = MTLMutabilityImmutable;复制代码
Metal 可以优化不可变缓冲区的性能,但不能优化可变缓冲区的性能。为获得最佳性能,请尽可能使用不可变缓冲区。
总结
本文阐述了造成 CPU 与 GPU 之间的数据依赖的原因,是由于资源共享造成的。介绍如何通过使用资源的多个实例,来避免 CPU 和 GPU 工作之间的等待。
作者:__sky
链接:https://juejin.cn/post/7032900431198191629