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

Metal 性能优化点

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

渲染的通常流程

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

简单就是以下:github

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

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

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


场景快速搭建

Demo 地址 多线程

github.com/Danny1451/M…并发

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

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

  • 经过 AVFoundation 获得摄像头的内容。
    初始化 AVSession ,实现 AVCaptureVideoDataOutputSampleBufferDelegate 的代理,
    -(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection 中能够得到 SampleBuffer ,经过下面转换可以转换到 MTLTexture 。

    注意 :这里的 SampleBuffer 必定要及时释放,否则会致使画面只有十几帧!

    ```
    -(void)captureOutput:(AVCaptureOutput )captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection )connection{

    @autoreleasepool {

CFRetain(sampleBuffer);

    connection.videoOrientation = [self avOrientationForDeviceOrientation:[UIDevice currentDevice].orientation];

    CVMetalTextureCacheRef cameraRef = _cache;

    CVImageBufferRef ref = CMSampleBufferGetImageBuffer(sampleBuffer);
    CFRetain(ref);


    CVMetalTextureRef textureRef;
    NSInteger textureWidth = CVPixelBufferGetWidthOfPlane(ref, 0);
    NSInteger textureHeigth = CVPixelBufferGetHeightOfPlane(ref, 0);


    // cost to much time
    CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                              cameraRef,
                                              ref,
                                              NULL,
                                              MTLPixelFormatBGRA8Unorm,
                                              textureWidth,
                                              textureHeigth,
                                              0,
                                              &textureRef);


    id<MTLTexture> metalTexture = CVMetalTextureGetTexture(textureRef);

    [self.delegate didVideoProvide:self withLoadTexture:metalTexture];


    //释放对应对象
    CFRelease(ref);
    CFRelease(sampleBuffer);
    CFRelease(textureRef);



    }

}复制代码
- 将获得的 Texture 在经过代理,或者 block 通知给咱们的界面。
- 将上一章的滤镜相关代码封装成一个 MPSUnaryImageKernel 对象。
    >MPSUnaryImageKernel 是由 **MetalPerformanceShaders** 提供的基础滤镜接口。表明着输入源只有一个图像,对图像进行处理。一样的还有 **MPSBinaryImageKernel** 表明着两个输入源。 MPS 默认提供了不少图像滤镜,如 MPSImageGaussianBlur,MPSImageHistogram 等等。 
    MPSUnaryImageKernel 提供以下两个接口,分别表明替代原先 texture 和输出到新 texture 的方法:
    `- encodeToCommandBuffer:inPlaceTexture:fallbackCopyAllocator:`
    `- encodeToCommandBuffer:sourceTexture:destinationTexture:`
    这边新建一个 MPSImageLut 类继承 MPSUnaryImageKernel,同时实现上面的两个接口:
    具体的实现参照上一篇文章中的 lut 实现复制代码
- (void)encodeToCommandBuffer:(id<MTLCommandBuffer>)commandBuffer
            sourceTexture:(id<MTLTexture>)sourceTexture
       destinationTexture:(id<MTLTexture>)destinationTexture{

ImageSaturationParameters params;
params.clipOriginX = floor(self.filiterRect.origin.x);
params.clipOriginY = floor(self.filiterRect.origin.y);
params.clipSizeX = floor(self.filiterRect.size.width);
params.clipSizeY = floor(self.filiterRect.size.height);

params.saturation = self.saturation;
params.changeColor = self.needColorTrans;
params.changeCoord = self.needCoordTrans;


id<MTLComputeCommandEncoder> encoder = [commandBuffer computeCommandEncoder];
[encoder pushDebugGroup:@"filter"];
[encoder setLabel:@"filiter encoder"];

[encoder setComputePipelineState:self.computeState];
[encoder setTexture:sourceTexture atIndex:0];
[encoder setTexture:destinationTexture atIndex:1];

if (self.lutTexture == nil) {
    NSLog(@"lut == nil");
    [encoder setTexture:sourceTexture atIndex:2];
}else{
    [encoder setTexture:self.lutTexture atIndex:2];
}

[encoder setSamplerState:self.samplerState atIndex:0];

[encoder setBytes:&params length:sizeof(params) atIndex:0];

NSUInteger wid = self.computeState.threadExecutionWidth;
NSUInteger hei = self.computeState.maxTotalThreadsPerThreadgroup / wid;

MTLSize threadsPerGrid = {(sourceTexture.width + wid - 1) / wid,(sourceTexture.height + hei - 1) / hei,1};
MTLSize threadsPerGroup = {wid, hei, 1};


[encoder dispatchThreadgroups:threadsPerGrid
        threadsPerThreadgroup:threadsPerGroup];

[encoder popDebugGroup];
[encoder endEncoding];


}

// 替换原先 texture
- (BOOL)encodeToCommandBuffer:(id<MTLCommandBuffer>)commandBuffer
           inPlaceTexture:(__strong id<MTLTexture>  _Nonnull *)texture
    fallbackCopyAllocator:(MPSCopyAllocator)copyAllocator{

if (copyAllocator == nil) {
    return false;
}

id<MTLTexture> source = *texture;

id<MTLTexture> targetTexture = copyAllocator(self,commandBuffer,source);

[self encodeToCommandBuffer:commandBuffer sourceTexture:source destinationTexture:targetTexture];

*texture = targetTexture;

return YES;
}
```复制代码
  • 根据手指触摸屏幕的位置,给 Texture 添加合适位置的滤镜,获得新的 Texture 。
  • 将新的 Texture 交个渲染流程,渲染到最后的界面上。

优化点

初始化时机

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

  • MTLDevice
  • MTLCommandQueue
  • MTLLibrary
  • PipelineState 用于配置对应的 shader 着色器
  • Sampler 取样器
  • Shader

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

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

一样的 Sampler 的构建也是能够放在初始化的时候进行

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

  • CommandBuffer
  • CommandEncoder

资源重用

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

  • MTLBuffer
  • MTLTexture
    顾名思义 Buffer 能够用来传递一些未格式化的简单,如顶点信息等,Texture 用来传递图像信息。

在 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 的渲染。

相关代码参考连接:
developer.apple.com/library/ios…

其实在作滤镜处理的时候,也能够进行优化,在处理图像的时候,能够用 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 触发刷新,大概以下:

```- (void)drawInMTKView:(MTKView *)view{

if (self.videoTexture != nil) {
//渲染 
....
}复制代码

}

#pragma video delegate

  • (void)didVideoProvide:(VideoProvider *)provide withLoadTexture:(id)texture{

    //更新 texture
    self.videoTexture = texture;

}

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

关闭自动刷新复制代码

[self.metalView setPaused:YES];

手动触发


```- (void)drawInMTKView:(MTKView *)view{

    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 和配置

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

效果以下

![](http://oslhwiets.bkt.clouddn.com/p21.png?-qn)

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


```// 每一个 Parallel Encoder 只用一个 CommandBuffer
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

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

  • 滤镜的输出对象调整
    以前优化过的滤镜是经过 in-place 的方式来修改,最后渲染到 MTKView 中。后来发现其实 MTKView 自己就提供一个 Texture 供渲染,直接写入这个 Texture 就能够了。
- (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];

    }
}复制代码
  • shader 的计算优化。在 Metal Performance Trace 中可以看到不一样 shader 的计算时间,在这个例子中能够优化的就是滤镜的那个 compute 的 shader,把其中的计算简化,尽可能作整数运算并减小运算的次数,把计算的次数减小,方法是配置 threadgroup 时候,须要高度和宽度进行计算,可使用输出和输入中那个较小那个尺寸进行计算,一样在 shader 中编码的时候,也要注意当前输入的是哪个尺寸,避免读写到超出尺寸的像素。

结果

下面是优化先后的对比图

优化以前:


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

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

优化以后:


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

GPU 的平均耗时为 1.36ms

总结

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

  • 能提早初始化的内容,不要放到渲染时在作
  • 能减小对象内存的建立和拷贝就减小
  • 能复用的对象,不要重复建立
  • 能越晚获取 Drawable 就晚点获取
  • 能合并的 encoder 尽可能合并
  • 多线程 encode 来减轻 CPU 压力

参考

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

相关文章
相关标签/搜索