iOS 图像处理OpenGL+MetaliOS Developer

Metal 系列教程(3)- 性能优化点

2017-09-21  本文已影响450人  DannyLau

Demo 地址已更新

https://github.com/Danny1451/MetalLutFilter

Metal 性能优化点

终于写到第三期了,这一期主要内容在于如何优化 Metal 的渲染性能,这部分内容在研究的时候几乎没有任何可查阅的中文资料。

渲染的一般流程

在 GPU 中的工作流程就是把顶点数据传入顶点着色器,opengl / metal 会装配成图元,变成3D物体,就像第一张图那样中的场景,近截面和远截面中间这个部分就叫做视口,显示的图形就是这部分中的物体,超出的物体会被忽略掉,然后经过投影,归一化等操作(矩阵变换)将图形显示到屏幕上,经过投影变换,然后经过光栅化转变成像素图形,再经过片段着色器给像素染色,最后的测试会决定你同一个位置的物体到底哪一个可以显示在屏幕上以及颜色的混合。

简单就是如下:

执行顶点着色器 —— 组装图元 —— 光栅化图元 —— 执行片段着色器 —— 写入帧缓冲区 —— 显示到屏幕上。

CPU 配置顶点信息 -> GPU 绘制顶点 -> 组装成图元 三角 四边形 线 点 -> 光栅化到像素 -> 片段着色器 纹理 模板 透明度 深度 -> 绘制到屏幕

对象空间 - 世界空间 - 摄像头空间 - 裁剪空间 - 归一化坐标系空间 - 屏幕空间



场景快速搭建

这边以一个获取摄像头画面并实时添加滤镜的例子来介绍在使用 Metal 时,来介绍一些值得注意和可以优化的地方。

这篇文章着重在优化的内容,所以在关于获取图像和添加滤镜方面不会做过多的介绍,下面就简单的过一下。

优化点

初始化时机

下面是能在渲染之前进行的初始化内容

这边需要仔细讲一下的是 Shader 相关的,包括 Shader 和 MTLLibrary,前面的文章有提到过,在 Metal 中 shader 可以在 app 编译的时候编译的,也可以在运行时编译,而在 OpenGL ES 中,Shader 都是运行时编译的,Metal 可以把这一部分的时间减少掉,**所以没有特殊的需求务必把 shader 的编译放在 app 编译时。
Metal System Trace 中可以通过 Shader Compilation 来查看这一部分的损耗:

并且 PipelineState 的构建是耗时操作,一旦构建之后也不会有太多的改动,建议把这 PipelineState 的初始化也放到和 MTLDevice / MTLCommandQueue 相同时机

同样的 Sampler 的构建也是可以放在初始化的时候进行

剩下的都是在每一次渲染进行初始化的

资源重用

最终提交的到 GPU 的资源 MTLResource,都是以如下两种种格式

在 Metal 中,MTLResource 是 CPU 和 GPU 是共享的数据,意味着可以避免数据在 CPU 和 GPU 之间来回拷贝的损耗,这个是由 storageMode 来确定的,默认情况下 MTLStorageModeShared ,CPG 和 GPU 之间共享,一般情况下不要修改。

从 CPU 处理的对象,如 UIImage / NSData 转换到 MTLTexture 都是有损耗的,所以尽量避免创建新的资源对象,对象能复用就复用。

在渲染视频界面的过程中,我们用到的 Buffer 只有代表正方形的四个顶点,这个是不会改变的,所以我们把顶点 buffer 的初始化,移动到应用初始化中。剩下的就只有两个 Texture,来自摄像头的 Texture ,这是肯定每次渲染都是新的,没办法处理。另一个是 LUT 滤镜的 Texture,因为其特殊性,固定大小每次切换滤镜时候其实只是每个像素的改变,所以没必要每次切换滤镜的时候进行 Texture 的重新创建。可以通过 Texture 的
*- (void)replaceRegion:(MTLRegion)region mipmapLevel:(NSUInteger)level withBytes:(const void )pixelBytes bytesPerRow:(NSUInteger)bytesPerRow; 来通过 CGContext 重新替换滤镜,可以节省 CPU 的占用率,下图分别是重新创建和替换的 CPU 占用率,重新创建高达 70%,而替换只有 40 %左右。

在默写情况下,我们可能会重复操作同一个 Buffer 或者 Texture,然后根据其更新再来刷新界面,这时候就会存在一个问题,就是在我们刷新界面的时候,CPU 是无法去修改资源,必须等界面刷新完之后才能进行资源的更新,在渲染负责界面的时候,很容易发生 CPU 在等 GPU 的情况,这种时候便会造成掉帧的情况。苹果官方推荐的是一个叫做 Trible - Buffering 的方式来避免 CPU 的空等,其实就是使用 3 个资源和 GCD 信号量来控制并发,实现的效果如图:

优化之前

优化之后

就是有 Buffer1,Buffer2,Buffer3 三个 Buffer构成一个循环,不新建额外的 Buffer,当 3 个用完之后,开始修改第一个进行第一个的复用,通过在 CommandBuffer 的 Complete Handler 修改 GCD 的信号量来通知是否完成 Buffer 的渲染。

相关代码参考链接:
https://developer.apple.com/library/ios/samplecode/MetalUniformStreaming

其实在做滤镜处理的时候,也可以进行优化,在处理图像的时候,可以用 in-place 的方法来做滤镜添加,而不用再重新构造新的 Texture。

[self.filiter encodeToCommandBuffer:buffer
                         inPlaceTexture:&sourceTexture
                  fallbackCopyAllocator:nil];

渲染界面

在渲染流程的最后,我们会指定展示的界面:
- (void)presentDrawable:(id<MTLDrawable>)drawable
通常情况下我们会使用 MTKView 作为展示的界面,其实最终用的也是 MTKView 中的 CAMetalLayer。

            id<CAMetalDrawable> drawable = [metaLayer nextDrawable];
            [buffer presentDrawable:drawable];

一般是通过 layer 的 nextDrawable 方法来获取,但是要注意的是,这个方法是个阻塞方法,当目标没有空余的 Drawable 的时候,你的线程就会阻塞在这里。
当你的 Metal Performance Trace 上有 CPU 无故空闲了一大段的时候,应该检查一下是不是这个原因导致的,平时写的时候注意越晚获取 Drawable 越好

在我们这个例子中,其实实时滤镜视频对人眼来说 30 帧就够了,而 MTKView 默认是 60 帧,这里可以把 MTKView 的刷新率调整到 30 。

[self.metalView setPreferredFramesPerSecond:30];

但是这样子其实还是在 MetalView 的 - (void)drawInMTKView:(nonnull MTKView *)view; 方法中进行渲染,我之前的做法会在获取摄像头 Texture 的代理中,不断的获取新的 Texture,然后更新本地的 Texture 触发刷新,大概如下:

    
    if (self.videoTexture != nil) {
    //渲染 
    ....
    }

}


#pragma video delegate

- (void)didVideoProvide:(VideoProvider *)provide withLoadTexture:(id<MTLTexture>)texture{
    
    //更新 texture    
    self.videoTexture = texture;
    
}

上面的这样的流程其实会存在问题,当 videoTexture 刷新快了,或者渲染处理慢了之后就会导致帧混乱和掉帧,而且 videoTexture 刷新慢了,也会导致无用的渲染流程。
后来我关闭了 MTKView 的自动刷新,通过 videoTexture 的更新来触发 MTKView 的刷新。

关闭自动刷新

[self.metalView setPaused:YES];

手动触发

    
    if (self.videoTexture != nil) {
    //渲染
    }

}


#pragma video delegate

- (void)didVideoProvide:(VideoProvider *)provide withLoadTexture:(id<MTLTexture>)texture{
    
   //触发
    [self.metalView draw];
}

过多的 Encoder

在 Metal 的渲染过程中,通常我们会在一个 CommandBuff 上进行多次 Encoder 操作,但是每次 Encoder 对 Texture 的读写都会有损耗,所以要尽可能地把重复工作的 Encoder 进行合并。

合并之后

目前,我们现在一共只有两个 Encoder ,一个负责图像滤镜 ComputeEncoder,一个负责渲染 RenderEncoder。为了追求优化的极限,这里我尝试着对两个进行了合并制作了新的 shader ,讲道理着两个不应该合并的,因为负责的功能是不同的。
把原先的 fragment 的 shader 进行了修改,增加了一个 lut 的输入源和配置参数。

fragment half4 mps_filter_fragment(
                                   ColoredVertex vert [[stage_in]],
                            constant RenderImageSaturationParams *params [[buffer(0)]],
                            texture2d<half> sourceTexture [[texture(0)]],
                            texture2d<half> lutTexture [[texture(1)]]
                            )
{
    float width = sourceTexture.get_width();
    float height = sourceTexture.get_height();
    uint2 gridPos = uint2(vert.texCoords.x * width ,vert.texCoords.y * height);
    
    half4 color = sourceTexture.read(gridPos);
    
    
    float blueColor = color.b * 63.0;
    
    int2 quad1;
    quad1.y = floor(floor(blueColor) / 8.0);
    quad1.x = floor(blueColor) - (quad1.y * 8.0);
    
    int2 quad2;
    
    quad2.y = floor(ceil(blueColor) / 8.0);
    quad2.x = ceil(blueColor) - (quad2.y * 8.0);
    
    half2 texPos1;
    texPos1.x = (quad1.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.r);
    texPos1.y = (quad1.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.g);
    
    half2 texPos2;
    texPos2.x = (quad2.x * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.r);
    texPos2.y = (quad2.y * 0.125) + 0.5/512.0 + ((0.125 - 1.0/512.0) * color.g);
    
    
    half4 newColor1 = lutTexture.read(uint2(texPos1.x * 512,texPos1.y * 512));
    half4 newColor2 = lutTexture.read(uint2(texPos2.x * 512,texPos2.y * 512));
    
    half4 newColor = mix(newColor1, newColor2, half(fract(blueColor)));
    
    
    half4 finalColor = mix(color, half4(newColor.rgb, color.w), half(params->saturation));
    
    
    uint2 destCoords = gridPos + params->clipOrigin;
    
    
    uint2 transformCoords =  uint2(destCoords.x, destCoords.y);
    
    //transform coords for y
    if (params->changeCoord){
        transformCoords = uint2(destCoords.x , height - destCoords.y);
    }
    //transform color for r&b
    half4 realColor = finalColor;
    if (params->changeColor){
        realColor = half4(finalColor.bgra);
    }
    
    if(checkPointInRectRender(transformCoords,params->clipOrigin,params->clipSize))
    {
        return realColor;
        
    }else{
        
        return color;
    }
    
    
};

通过下面的方法传入

[encoder setVertexBuffer:vertexBuffer offset:0 atIndex:0];
            [encoder setFragmentTexture:sourceTexture atIndex:0];
            [encoder setFragmentTexture:self.filiter.lutTexture atIndex:1];
            [encoder setFragmentSamplerState:self.samplerState atIndex:0];
            [encoder setFragmentBytes:&params length:sizeof(params) atIndex:0];

Encoder 的并行

Metal 设计的本身就是线程安全的,所以完全可以在不同线程上 Encoder 同一个 CommandBuffer。将不同的 Encoder 分布在不同的线程上进行,可以大大提高 Metal 的性能。
在我的例子中因为相对比较简单,所以并不涉及到该优化,这里引用 wwdc 中的例子做个介绍,其中介绍了两种方式。

每个 Thread 都用不同的 Encoder 和配置

id <MTLCommandBuffer> commandBuffer1 = [commandQueue commandBuffer];
id <MTLCommandBuffer> commandBuffer2 = [commandQueue commandBuffer];
// 初始化操作
// 顺序的提交到 CommandQueue 中
[commandBuffer1 enqueue];
[commandBuffer2 enqueue];
// 创建每个线程的 Encoder 
id <MTLRenderCommandEncoder> pass1RCE =
   [commandBuffer1 renderCommandEncoderWithDescriptor:renderPass1Desc];
id <MTLRenderCommandEncoder> pass2RCE =
   [commandBuffer2 renderCommandEncoderWithDescriptor:renderPass2Desc];
// 每个线程各自 encode ,并提交
[pass1RCE draw...];        [pass2RCE draw...];
[pass1RCE endEncoding];    [pass2RCE endEncoding];
[commandBuffer1 commit];   [commandBuffer2 commit];

效果如下

每个 Thread 共用一个 Encoder,在不同线程 encode

id <MTLCommandBuffer> commandBuffer = [commandQueue commandBuffer];
// 初始化
// 创建 Parallel Encoder
id <MTLParallelRenderCommandEncoder> parallelRCE =
   [commandBuffer parallelRenderCommandEncoderWithDescriptor:renderPassDesc];
// 按 GPU 提交顺序创建子 Encoder e
id <MTLRenderCommandEncoder> rCE1 = [parallelRCE renderCommandEncoder];
id <MTLRenderCommandEncoder> rCE2 = [parallelRCE renderCommandEncoder];
id <MTLRenderCommandEncoder> rCE3 = [parallelRCE renderCommandEncoder];
// 再各自的线程 encode 
[rCE1 draw...];        [rCE2 draw...];        [rCE3 draw...];
[rCE1 endEncoding];    [rCE2 endEncoding];    [rCE3 endEncoding];
// 所有的子 Encoder 必须要 Parallel Encoder 停止之前停止
[parallelRCE endEncoding];
[commandBuffer commit];

效果如下

Some more things

在针对这个例子做优化时,还有几个点可以进行优化,但并不是通用的,这里我列出来可以作为参考。

- (void)systemDrawableRender:(id<MTLTexture>) texture{
    @autoreleasepool {
        
        id<MTLCommandBuffer> buffer = [_queue commandBuffer];
        
        CAMetalLayer *metaLayer = (CAMetalLayer*)self.metalView.layer;
        
        id<CAMetalDrawable> drawable = [metaLayer nextDrawable];
        
        id<MTLTexture> resultTexture = drawable.texture;
        
        
        [self.filiter encodeToCommandBuffer:buffer
                                  sourceTexture:texture
                             destinationTexture:resultTexture];
        
       
        [buffer presentDrawable:drawable];
        [buffer commit];
        
    }
}

结果

下面是优化前后的对比图

优化之前:


GPU 平均耗时在 1.3 filter + 2.6 render + 0.2 = 4ms

CPU 平均使用率在 18 左右 峰值 在 30 %

优化之后:


CPU 的使用率为 10% 峰值为 20 %

GPU 的平均耗时为 1.36ms

总结

最后我们总结一下整体优化的流程

参考

GPU-Accelerated Image Processing
WWDC 2015 Metal Performance Optimization Techniques
Metal Best Practices Guide

上一篇 下一篇

猜你喜欢

热点阅读