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

相關文章
相關標籤/搜索