Metal绘制中currentRenderPassDescriptor耗时8ms的原因与优化问询
问题背景
我用Objective-C手动引用计数实现了一个简单的Metal旋转三角形绘制流程,用于对接C游戏循环。C侧是无等待的无限循环,每次循环结束后立即重启,末尾手动轮询Cocoa事件(类似GLFW的处理方式)。
性能瓶颈现象
通过日志时间戳分析,发现两个关键瓶颈:
- 主要瓶颈:调用
mtk_view.currentRenderPassDescriptor的操作耗时约8ms(对应日志中Probe 1到Probe 2的间隔)。 - 次要瓶颈:偶尔出现命令缓冲区提交后到调度完成(
commit到waitUntilScheduled)的阶段耗时较长。
已尝试的操作及异常表现:
- 设置
mtk_view.enableSetNeedsDisplay = NO;试图关闭垂直同步,但仍存在类似垂直同步的帧率限制:MacBook Retina显示器上帧率约140(单帧耗时≈7ms),三星显示器上约70(单帧耗时≈14ms),且单帧耗时与获取currentRenderPassDescriptor的耗时完全一致。 - 初始化MTKView时设置
mtk_view.preferredFramesPerSecond = 10;反而获得极高帧率,但设置为60时,单帧仍耗时8ms,与预期相反。
核心疑问
- 为何获取
currentRenderPassDescriptor会耗时如此之久? - 如何优化该操作的性能?
- 是否可以缓存
currentRenderPassDescriptor的指针,仅在视图渲染通道描述符变更时更新?
绘制流程代码
- (void)commitWithMTKView:(nonnull MTKView *)mtk_view CommandQueue:(nonnull id<MTLCommandQueue>)command_queue { NSLog(@"Begin Frame"); [self updateState]; NSLog(@"Updated State"); @autoreleasepool { NSLog(@"Probe 1"); MTLRenderPassDescriptor* render_pass_descriptor = mtk_view.currentRenderPassDescriptor; NSLog(@"Probe 2"); if (render_pass_descriptor != nil) { id<MTLCommandBuffer> command_buffer = [command_queue commandBuffer]; // Command Encoding id<MTLRenderCommandEncoder> render_encoder = [command_buffer renderCommandEncoderWithDescriptor:render_pass_descriptor]; [render_encoder setRenderPipelineState:_render_pipeline_state]; [render_encoder setVertexBuffer:_vertex_buffers[_current_buffer] offset:0 atIndex:ParallelTriangleRotate_vertexIndexVertices]; [render_encoder setVertexBytes:&_aspect_ratio length:sizeof(_aspect_ratio) atIndex:ParallelTriangleRotate_vertexIndexAspectRatio]; [render_encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; [render_encoder endEncoding]; NSLog(@"Finished Encoded"); // Commit in Drawable [command_buffer presentDrawable:mtk_view.currentDrawable]; NSLog(@"Before Commit"); [command_buffer commit]; NSLog(@"After Commit"); [command_buffer waitUntilScheduled]; NSLog(@"Just Scheduled"); [command_buffer waitUntilCompleted]; NSLog(@"Completed Scheduled"); } } NSLog(@"End Frame"); }
问题分析
1. currentRenderPassDescriptor耗时的根本原因
MTKView.currentRenderPassDescriptor并非简单的属性读取,内部会执行大量同步操作:
- 检查并等待当前
drawable就绪(避免前一帧的GPU操作未完成导致资源冲突); - 根据视图当前状态(尺寸、颜色格式、深度/模板配置)重新构建渲染通道附件;
- 处理系统层面的垂直同步逻辑,即使你关闭了
enableSetNeedsDisplay,MTKView仍会为了保证drawable的正确呈现而对齐显示器刷新率。
此外,代码中调用的waitUntilCompleted会强制CPU等待GPU完成整帧渲染,导致GPU和CPU之间的流水线完全阻塞,下一次获取currentRenderPassDescriptor时必须等待前一帧的所有资源释放,进一步放大耗时。
2. 垂直同步异常的原因
enableSetNeedsDisplay = NO只是关闭了MTKView的自动重绘触发逻辑,但并未完全切断与垂直同步的绑定——currentRenderPassDescriptor的获取仍会依赖系统的显示刷新信号,确保drawable的生命周期与显示器帧周期匹配。- 设置
preferredFramesPerSecond = 10时,MTKView会降低对drawable就绪状态的检查强度,允许CPU更快地获取资源,反而让无等待的C++循环跑满;而设置为60时,MTKView试图对齐标准帧率,但无等待循环会频繁触发资源检查,导致内部阻塞。
优化方案
1. 替换currentRenderPassDescriptor为手动构建
跳过MTKView的内部逻辑,手动创建MTLRenderPassDescriptor,直接绑定当前drawable的纹理:
// 提前初始化一次基础descriptor,后续仅更新纹理 MTLRenderPassDescriptor *customDescriptor = [MTLRenderPassDescriptor renderPassDescriptor]; customDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear; customDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); customDescriptor.colorAttachments[0].storeAction = MTLStoreActionStore; // 每帧仅更新纹理 id<CAMetalDrawable> drawable = mtk_view.currentDrawable; customDescriptor.colorAttachments[0].texture = drawable.texture;
这种方式完全避免了MTKView内部的同步和检查逻辑,能大幅降低耗时。
2. 移除不必要的同步调用
删除waitUntilCompleted和waitUntilScheduled(除非你有必须同步的场景,比如资源更新),让GPU和CPU并行工作:
// 移除这两行 // [command_buffer waitUntilScheduled]; // [command_buffer waitUntilCompleted];
这是提升帧率最关键的一步,原本的同步调用会让CPU完全等待GPU,导致整个流水线停滞。
3. 彻底关闭垂直同步
除了enableSetNeedsDisplay = NO,还需要设置:
mtk_view.presentsWithTransaction = NO; mtk_view.preferredFramesPerSecond = 0; // 允许无限制帧率
同时,手动管理drawable的呈现,避免依赖MTKView的自动逻辑。
4. 调整C++循环
给无等待的C++循环添加一个微小的延迟(比如usleep(100)),或者基于时间戳做帧率控制,避免无限制抢占CPU资源,减少与MTKView的内部资源竞争。
缓存descriptor的可行性
可以缓存,但需要严格管理失效条件:
- 可缓存的内容:渲染通道的基础配置(如load/store动作、清除颜色、深度模板附件设置)这些很少变化的部分,可以提前初始化并复用。
- 必须每帧更新的内容:
colorAttachments[0].texture必须每帧更新为当前drawable的纹理,因为drawable是循环复用的,每帧的纹理可能不同。 - 失效触发时机:当MTKView的尺寸变化、颜色格式变化、深度/模板配置变化时,必须重新初始化基础descriptor。
你可以通过监听MTKView的frameSizeDidChange:事件来触发descriptor的更新,确保缓存的descriptor始终有效。
内容的提问来源于stack exchange,提问作者BENG

