【问题标题】:C vs assembler vs NEON performanceC vs 汇编程序 vs NEON 性能
【发布时间】:2012-07-15 12:41:25
【问题描述】:

我正在开发一个进行实时图像处理的 iPhone 应用程序。其管道中最早的步骤之一是将 BGRA 图像转换为灰度。我尝试了几种不同的方法,计时结果的差异远比我想象的要大。首先我尝试使用 C。我通过添加 B+2*G+R /4 来近似转换为亮度

void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();

uchar *pOut = imByte.data;
for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{
    unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
    pOut[0] = sumA / 4;
    unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
    pOut[1] = sumB / 4;
    unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
    pOut[2] = sumC / 4;
    unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
    pOut[3] = sumD / 4;
    pOut +=4;
}       
}

此代码需要 55 毫秒来转换 352x288 图像。然后我发现了一些本质上做同样事情的汇编代码

void BGRA_To_Byte(Image<BGRA> &imBGRA, Image<byte> &imByte)
{
uchar *pIn = (uchar*) imBGRA.data;
uchar *pLimit = pIn + imBGRA.MemSize();

unsigned int *pOut = (unsigned int*) imByte.data;

for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{
  register unsigned int nBGRA1 asm("r4");
  register unsigned int nBGRA2 asm("r5");
  unsigned int nZero=0;
  unsigned int nSum1;
  unsigned int nSum2;
  unsigned int nPacked1;
  asm volatile(
           
               "ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #0]       \n"   // Load in two BGRA words
               "usad8 %[nSum1], %[nBGRA1], %[nZero]  \n"  // Add R+G+B+A 
               "usad8 %[nSum2], %[nBGRA2], %[nZero]  \n"  // Add R+G+B+A 
               "uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8    \n"   // Add G again
               "uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8    \n"   // Add G again
               "mov %[nPacked1], %[nSum1], LSR #2 \n"    // Init packed word   
               "mov %[nSum2], %[nSum2], LSR #2 \n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum2], LSL #8 \n"   // Add to packed word                 

               "ldrd %[nBGRA1], %[nBGRA2], [ %[pIn], #8]       \n"   // Load in two more BGRA words
               "usad8 %[nSum1], %[nBGRA1], %[nZero]  \n"  // Add R+G+B+A 
               "usad8 %[nSum2], %[nBGRA2], %[nZero]  \n"  // Add R+G+B+A 
               "uxtab %[nSum1], %[nSum1], %[nBGRA1], ROR #8    \n"   // Add G again
               "uxtab %[nSum2], %[nSum2], %[nBGRA2], ROR #8    \n"   // Add G again
               "mov %[nSum1], %[nSum1], LSR #2 \n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum1], LSL #16 \n"   // Add to packed word
               "mov %[nSum2], %[nSum2], LSR #2 \n"   // Div by four
               "add %[nPacked1], %[nPacked1], %[nSum2], LSL #24 \n"   // Add to packed word                 
              
               ///////////
               ////////////
               
               : [pIn]"+r" (pIn), 
         [nBGRA1]"+r"(nBGRA1),
         [nBGRA2]"+r"(nBGRA2),
         [nZero]"+r"(nZero),
         [nSum1]"+r"(nSum1),
         [nSum2]"+r"(nSum2),
         [nPacked1]"+r"(nPacked1)
               :
               : "cc"  );
  *pOut = nPacked1;
  pOut++;
 }
 }

此功能在 12 毫秒内转换相同的图像,几乎快 5 倍!我以前没有用汇编程序编程过,但我认为对于这样一个简单的操作,它不会比 C 快得多。受到这次成功的启发,我继续搜索并发现了一个 NEON 转换示例here

void greyScaleNEON(uchar* output_data, uchar* input_data, int tot_pixels)
{
__asm__ volatile("lsr          %2, %2, #3      \n"
                 "# build the three constants: \n"
                 "mov         r4, #28          \n" // Blue channel multiplier
                 "mov         r5, #151         \n" // Green channel multiplier
                 "mov         r6, #77          \n" // Red channel multiplier
                 "vdup.8      d4, r4           \n"
                 "vdup.8      d5, r5           \n"
                 "vdup.8      d6, r6           \n"
                 "0:                           \n"
                 "# load 8 pixels:             \n"
                 "vld4.8      {d0-d3}, [%1]!   \n"
                 "# do the weight average:     \n"
                 "vmull.u8    q7, d0, d4       \n"
                 "vmlal.u8    q7, d1, d5       \n"
                 "vmlal.u8    q7, d2, d6       \n"
                 "# shift and store:           \n"
                 "vshrn.u16   d7, q7, #8       \n" // Divide q3 by 256 and store in the d7
                 "vst1.8      {d7}, [%0]!      \n"
                 "subs        %2, %2, #1       \n" // Decrement iteration count
                 "bne         0b            \n" // Repeat unil iteration count is not zero
                 :
                 :  "r"(output_data),           
                 "r"(input_data),           
                 "r"(tot_pixels)        
                 : "r4", "r5", "r6"
                 );
}

计时结果令人难以置信。它在 1 毫秒内转换相同的图像。比汇编程序快 12 倍,比 C 快 55 倍。我不知道这样的性能提升是可能的。鉴于此,我有几个问题。首先,我在 C 代码中做错了什么吗?我仍然很难相信它是如此缓慢。其次,如果这些结果完全准确,我可以期望在哪些情况下看到这些收益?你可以想象我对让管道的其他部分运行速度提高 55 倍的前景感到多么兴奋。我应该学习汇编程序/NEON 并在任何需要大量时间的循环中使用它们吗?

更新 1:我已将 C 函数的汇编程序输出发布在文本文件中 http://temp-share.com/show/f3Yg87jQn 太大了,无法直接包含在此处。

使用 OpenCV 函数完成计时。

double duration = static_cast<double>(cv::getTickCount()); 
//function call 
duration = static_cast<double>(cv::getTickCount())-duration;
duration /= cv::getTickFrequency();
//duration should now be elapsed time in ms

结果

我测试了几个建议的改进。首先,按照 Viktor 的建议,我对内部循环进行了重新排序,以将所有获取放在首位。然后内部循环看起来像。

for(; pIn < pLimit; pIn+=16)   // Does four pixels at a time
{     
  //Jul 16, 2012 MR: Read and writes collected
  sumA = pIn[0] + 2 * pIn[1] + pIn[2];
  sumB = pIn[4] + 2 * pIn[5] + pIn[6];
  sumC = pIn[8] + 2 * pIn[9] + pIn[10];
  sumD = pIn[12] + 2 * pIn[13] + pIn[14];
  pOut +=4;
  pOut[0] = sumA / 4;
  pOut[1] = sumB / 4;
  pOut[2] = sumC / 4;
  pOut[3] = sumD / 4;
}

此更改将处理时间缩短至 53 毫秒,提高了 2 毫秒。接下来,按照 Victor 的建议,我将函数更改为 fetch as uint。然后内部循环看起来像

unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
uchar* out = temp.data;

for(; in_int < end; in_int+=4)   // Does four pixels at a time
{
    unsigned int pixelA = in_int[0];
    unsigned int pixelB = in_int[1];
    unsigned int pixelC = in_int[2];
    unsigned int pixelD = in_int[3];
        
    uchar* byteA = (uchar*)&pixelA;
    uchar* byteB = (uchar*)&pixelB;
    uchar* byteC = (uchar*)&pixelC;
    uchar* byteD = (uchar*)&pixelD;         
        
    unsigned int sumA = byteA[0] + 2 * byteA[1] + byteA[2];
    unsigned int sumB = byteB[0] + 2 * byteB[1] + byteB[2];
    unsigned int sumC = byteC[0] + 2 * byteC[1] + byteC[2];
    unsigned int sumD = byteD[0] + 2 * byteD[1] + byteD[2];

    out[0] = sumA / 4;
    out[1] = sumB / 4;
    out[2] = sumC / 4;
    out[3] = sumD / 4;
    out +=4;
    }

这种修改产生了显着的效果,将处理时间减少到 14 毫秒,减少了 39 毫秒 (75%)。最后一个结果非常接近 11ms 的汇编器性能。 rob 建议的最终优化是包含 __restrict 关键字。我在每个指针声明前面添加了它,更改了以下几行

__restrict unsigned int* in_int = (unsigned int*) original.data;
unsigned int* end = (unsigned int*) in_int + out_length;
__restrict uchar* out = temp.data;  
...
__restrict uchar* byteA = (uchar*)&pixelA;
__restrict uchar* byteB = (uchar*)&pixelB;
__restrict uchar* byteC = (uchar*)&pixelC;
__restrict uchar* byteD = (uchar*)&pixelD;  
...     

这些更改对处理时间没有明显影响。感谢大家的帮助,以后我会更加关注内存管理。

【问题讨论】:

  • 你能发布编译器制作的程序集吗?
  • Err.. 如果我错了,请纠正我,但 NEON 在单独的 DSP 阵列硬件上运行?如果是这样,也许这可以解释为什么它会快一点?
  • 相同的硬件,相同的芯片。它是一个指令集扩展,就像 SSE 或 MMX。
  • @harold 我去获取编译器生成的汇编器,但它大得离谱。我在 xcode 4.3.2 中,并在助手编辑器中查看程序集。我在 C 中的函数长 23 行,没有包含,但生成的汇编程序长 9029 行。知道为什么会这样吗?我让编译器最大限度地优化 C 代码。
  • 如果您真的想要快速处理视频,我可以建议您使用 OpenGL ES 2.0 片段着色器吗?与 NEON 优化例程相比,GPU 在处理简单的并行操作方面甚至更快。我围绕这个编写了一个开源框架:github.com/BradLarson/GPUImage,我已经看到 iOS GPU 上的图像处理与 CPU 绑定的 C 函数相比,速度提高了 100 倍以上。我已经复制了一些 OpenCV 的功能,我的目标是尽可能多地提供这些功能。

标签: iphone c image-processing assembly neon


【解决方案1】:

这里有一个关于NEON“成功”的一些原因的解释:http://hilbert-space.de/?p=22

尝试使用“-S -O3”开关编译您的 C 代码,以查看 GCC 编译器的优化输出。

恕我直言,成功的关键是两个程序集版本都采用了优化的读/写模式。 NEON/MMX/其他矢量引擎也支持饱和(将结果钳位到 0..255,而无需使用“无符号整数”)。

在循环中查看这些行:

unsigned int sumA = pIn[0] + 2 * pIn[1] + pIn[2];
pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];
pOut[1] = sumB / 4;
unsigned int sumC = pIn[8] + 2 * pIn[9] + pIn[10];
pOut[2] = sumC / 4;
unsigned int sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut[3] = sumD / 4;
pOut +=4;

读取和写入确实是混合的。循环的循环稍微好一点的版本是

// and the pIn reads can be combined into a single 4-byte fetch
sumA = pIn[0] + 2 * pIn[1] + pIn[2];
sumB = pIn[4] + 2 * pIn[5] + pIn[6];
sumC = pIn[8] + 2 * pIn[9] + pIn[10];
sumD = pIn[12] + 2 * pIn[13] + pIn[14];
pOut +=4;
pOut[0] = sumA / 4;
pOut[1] = sumB / 4;
pOut[2] = sumC / 4;
pOut[3] = sumD / 4;

请记住,这里的“unsigned in sumA”行实际上可能意味着 alloca() 调用(在堆栈上分配),因此您在临时 var 分配上浪费了很多周期(函数调用 4次)。

此外,pIn[i] 索引仅从内存中提取单字节。更好的方法是读取 int 然后提取单个字节。为了使事情更快,使用“unsgined int*”读取 4 个字节(pIn[i * 4 + 0]、pIn[i * 4 + 1]、pIn[i * 4 + 2]、pIn[i * 4 + 3])。

NEON 版本明显更胜一筹:线条

             "# load 8 pixels:             \n"
             "vld4.8      {d0-d3}, [%1]!   \n"

             "#save everything in one shot   \n"
             "vst1.8      {d7}, [%0]!      \n"

节省大部分时间用于内存访问。

【讨论】:

  • 换句话说 - 糟糕的代码不可能很快 - 如果你想用 C 编写快速的程序,你真的需要了解底层的汇编程序,否则你会被搞砸......
  • C 是一个跨平台的汇编程序 :) 。我不认为组装是绝对需要的,但是了解事情的工作方式以及瓶颈可能在哪里肯定会大有帮助。而且这里的内存访问几乎总是比算术慢。
  • 另外,应该强调的是,NEON 操作适用于向量,因此您通常仅从其 SIMD 特性以及 ARMv6 和 ARMv7 处理器用于运行这些并行操作的专用硬件即可获得显着的加速。我经常看到关于 NEON 实现的 3 到 4 倍加速的报告,这是以前在标准手动调整的 ARM 组装中完成的。
  • @BradLarson:当然,我的“稍微好一点”的版本远非最佳,但最后的乘加(四行)显然可以在一条(或几条)指令中完成只涉及具有良好 SIMD 架构的寄存器。
  • @ViktorLatypov 谢谢,您的建议极大地提高了我的 C 代码的性能。我将把这些原则应用到我管道中的其他地方,然后检查哪些仍然可以从使用 NEON 中受益。我显然有很多关于内存管理和效率的知识。
【解决方案2】:

如果性能至关重要(通常是实时图像处理),您确实需要注意机器代码。正如您所发现的,使用向量指令(专为实时图像处理之类的事情而设计)尤其重要——编译器很难自动有效地使用向量指令。

在提交组装之前,您应该尝试使用compiler intrinsics。编译器内在函数并不比汇编更具可移植性,但它们应该更易于阅读和编写,并且更易于编译器使用。除了可维护性问题之外,程序集的性能问题是它有效地关闭了优化器(您确实使用了适当的编译器标志来打开它,对吗?)。也就是说:使用内联汇编,编译器无法调整寄存器分配等等,所以如果你不在汇编中编写整个内部循环,它可能仍然没有它应该有的效率。

但是,您仍然可以很好地利用新获得的汇编专业知识——因为您现在可以检查编译器生成的汇编,并确定它是否愚蠢。如果是这样,您可以调整 C 代码(如果编译器无法处理,可能手动执行一些 pipelining),重新编译它,查看汇编输出以查看编译器现在是否正在执行您想要的操作,然后进行基准测试,看看它是否真的运行得更快......

如果您已尝试上述方法,但仍无法促使编译器执行正确的操作,请继续在汇编中编写您的内部循环(再次检查结果是否实际上更快)。由于上述原因,请务必获取整个内部循环,包括循环分支。

最后,正如其他人所提到的,花一些时间来尝试找出“正确的事情”是什么。学习机器架构的另一个好处是,它可以让您对事物的工作方式有一个心智模型——因此您将有更好的机会了解如何组合高效的代码。

【讨论】:

  • 谢谢,我将在检查管道的其余部分以寻找潜在的优化时使用您的程序。
【解决方案3】:

Viktor Latypov 的回答有很多很好的信息,但我想再指出一件事:在您原来的 C 函数中,编译器无法判断 pInpOut 指向不重叠的内存区域.现在看看这些行:

pOut[0] = sumA / 4;
unsigned int sumB = pIn[4] + 2 * pIn[5] + pIn[6];

编译器必须假定pOut[0] 可能与pIn[4]pIn[5]pIn[6](或任何其他pIn[x])相同。所以它基本上不能重新排序循环中的任何代码。

您可以通过声明__restrict 来告诉编译器pInpOut 不重叠:

__restrict uchar *pIn = (uchar*) imBGRA.data;
__restrict uchar *pOut = imByte.data;

这可能会加快你原来的 C 版本的速度。

【讨论】:

  • 正如问题更新中解释的那样,我尝试添加限制关键字,但没有产生太大效果。我是否正确使用它?如果我知道我的指针不重叠,那么总是使用它是个好主意吗?
  • 添加restrict 的目的是让编译器重新排序加载和存储。你已经手动完成了,所以添加 restrict 并没有让编译器做任何新的事情。当分析器告诉我需要使函数运行得更快时,我只使用restrict。我不只是在我的代码上撒上它。
【解决方案4】:

这是性能和可维护性之间的一种折腾。通常,快速加载应用程序和功能对用户来说非常好,但需要权衡取舍。现在您的应用程序很难维护,而且速度提升可能是没有根据的。如果您的应用程序的用户抱怨它感觉很慢,那么这些优化是值得付出努力并且缺乏可维护性的,但如果它来自您需要加速您的应用程序,那么您不应该在优化中走这么远。如果您在应用程序启动时进行这些图像转换,那么速度并不是关键,但如果您在应用程序运行时不断地进行它们(并且做了很多),那么它们更有意义。仅优化用户花费时间并实际体验到速度变慢的应用部分。

还查看程序集,他们不使用除法,而只使用乘法,因此请查看您的 C 代码。另一个例子是它优化了你的乘法 2 到两个加法。这又可能是另一个技巧,因为 iPhone 应用程序上的乘法可能比加法慢。

【讨论】:

  • 如果编译器没有优化这些微不足道的除法,那它就是个白痴,作者应该会感到难过。
  • 这是一个公平的观点,那么我们又没有 OP 的 C 代码的汇编输出,所以这只是我的一个假设。
  • 也许调试与发布版本也可能是一个问题。编译器不会在调试会话期间修改 asm volatile - 但它很可能会影响 C 代码。
  • 我确保编译是每次测试的发布模式。此外,该应用程序执行实时纹理跟踪,运行速度越快,跟踪就越稳健,因此优化至关重要。
  • 整洁的帖子;问题是:客户是否注意到 54ms 增量?如果你有很多照片,那么我想是的。另一方面,C 代码更易于阅读。另一方面,C 代码实际上并没有那么容易阅读。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-03-17
  • 2014-01-03
  • 2015-05-10
  • 1970-01-01
  • 2013-12-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多