代码怎么才能跑的更快(代码能跑什么意思)

代码怎么才能跑的更快(代码能跑什么意思)

编码文章call10242025-07-18 13:40:463A+A-

概述

在写代码的时候我们都会碰到代码运行很慢的问题,代码的算力占用过高会直接导致项目难以落地,尤其是在端侧设备计算资源和内存资源都非常有限的情况下。如果计算资源相对充裕,我们开一个O3让编译器去优化,通常会得到两倍以上的加速效果。如果代码写的让编译更容易去理解,编译器就有更大自由度去优化,这样通常会得到更好的加速效果。作者之前在ARM/DSP/GPU做过单一硬件的加速以及多硬件的异构加速,图像算法和音频算法都有所涉猎,因此略有心得。在本文作者跟大家分享下让代码跑的更快的一些思路和手段,是基于以往经验的一些个人拙见,欢迎大家来一起讨论讨论。文章整体会比较宽泛,是以通用处理器的视角来看如何加速。作者用C比较多,所以这里会以C语言来做一些示例。


调整我们的源代码

这里我们介绍如何不使用硬件指令,只通过调整源代码辅助编译器来让代码跑的更快。

1. 降低循环开销,提高循环的并行度,更充分的利用流水线来提升性能

现在不管是ARM还是x86处理器,基本上都是超标量处理器。硬件资源例如MAC和ALU会有多个。超标量流水线也很常见,通常一个周期可以发射多条指令。对循环的优化通常可以做循环展开,我们做循环展开的目的就是为了能够充分使用这些硬件资源,填满pipeline,提升硬件资源的利用率。

示例循环

int acc = 0;
for(int i = 0;i< 1000; i++)
{
    acc += data[i];
}

初次展开(存在依赖)

int acc = 0;
for(int i = 0; i < 1000 / 4; i += 4)
{
  acc += data[i];
  acc += data[i + 1];
  acc += data[i + 2];
  acc += data[i + 3];
}

去除依赖

int accRes = 0;
int acc[4] = {0};
for(int i = 0; i < 1000 / 4; i+=4)
{
  acc[0] += data[i];
  acc[1] += data[i + 1];
  acc[2] += data[i + 2];
  acc[3] += data[i + 3];
}
accRes = acc[0] + acc[1] + acc[2] + acc[3];

需要注意的是的如果循环本身比较简单,开启O3有的交叉编译器会主动做循环展开。特别是一些dsp平台的交叉编译器,有的时候让循环尽量简单,让编译器去做比我们自己去做要好。所以当你循环展开没有效果,可能是编译器已经帮你做了。

2. 去除内存引用

避免不必要的内存访问,尽量让数据待在寄存器里,我们直接从寄存器读写数据,最后再写出。

这里每次循环都要访问内存

for(int i = 0; i < 1000; i += 2)
{
  arrA[100] += data[i];
  arrB[50] += data[i + 1];
}

去除内存引用

int a = 0;
int b = 0;
for(int i = 0; i < 1000; i += 2)
{
  a += data[i];
  b += data[i + 1];
}
arrA[100] += a;
arrB[50] += b;

3.避免分支语句

现在的处理器基本都支持分支预测,分支预测失败会导致流水线清空重排,带来性能损失,在循环内部应尽量避免分支判断。

循环内部有分支

for(int i = 0; i < 1000; i++)
{
  if(a > b)
  {
    code......
  }
  else
  {
    code.....
  }
}

去除分支判断

if(a > b)
{
  for(int i = 0; i < 1000; i++)
  {
    code......
  }
}
else
{
  for(int i = 0; i < 1000; i++)
  {
    code......
  }
}

同样我们也可以采取分段循环的方式来避免内部循环边界判断。

每次循环都要进行边界判断

for(int i = 0; i < 1000; i++)
{
  if(i < 3)
  {
    code....
  }
  else if(i > 500)
  {
    code....
  }
  else
  {
    code....
  }
}

分段循环

for(int i = 0; i < 3; i++)
{
   code.... 
}
for(int i = 3; i <500; i++)
{
   code.... 
}
for(int i = 500; i < 1000; i++)
{
   code.... 
}

4.循环读写合并

读写合并可以去除一些内存访问的开销,对于功耗和性能提升都有利

冗余的内存读写

int tmp[1000]
for(int i = 0; i < 1000;i++)
{
  code....
  tmp[i] = data0[i] * data1[i];
}
for(int i = 0; i < 1000; i++)
{
  out[i] = tmp[i] + data2[i];  
}

合并内存读写

for(int i = 0; i < 1000; i++)
{
  code....
  out[i] = data0[i] * data1[i] + data2[i];
  code....
}

5. 避免跳点访问

计算机内存是多级存储结构如下图所示:

L1 cache 未命中时(cache miss), 会一级一级的往下找直到在磁盘上找到数据,访存期间CPU需要等待数据的到来,仿存的代价比较大。cache的缓存策略基于局部性原则(空间局部性和时间局部性)。在ARM处理器中从DDR搬运数据到cache是以缓存行为单位进行的,缓存行大小一般为64byte, 同时有硬件预取机制,会预取多个缓存行到cache。跳点访问并不符合局部性原则,跳的点数少影响不大,跳的点数超出预取的点数就会导致大量的cache miss。我们对数据进行重排有利于降低cache miss。矩阵乘法分块优化就是一个经典案例。这里不做过多介绍有机会后面再补充cache相关内容和矩阵乘法的加速思路。

ARM处理器通常为冯诺依曼架构,不区分指令和数据内存,但是dsp常见为哈佛架构指令和数据的存储是分开的。有些dsp的sram既可以配置为cache也可以配置为存储代码和数据的内存,但是增加cache的配置是很重要的,作者之前就碰到过dsp上没配置cache,代码运行慢了四倍。

6. 避免不必要的memset和memcpy

小内存的几百个点的memset和memcpy不会对性能造成显著影响,这种小内存在音频算法中比较常见。音频算法本身内存占用很小,大点也就十几M。但是图像算法不一样,一个四通道的raw图就已经有十几M了,这种情况下去掉memset和一些memcpy对于提升代码运行速度很有帮助。

逐点赋值,memset没有必要

memset(img, 0, 1080 * 1140 * sizeof(int16))
for(int i = 0; i < 1080; i++)
{
   for(int j = 0; j < 1140; j++)
   {
     img[i][j] = img0[i][j] * gain;
   }
}

去除memset

for(int i = 0; i < 1080; i++)
{
   for(int j = 0; j < 1140; j++)
   {
     img[i][j] = img0[i][j] * gain;
   }
}

7. 查表优化

有些计算结果可以提前算好存在表里,后续直接查表,不用每次都去计算。

每次循环都要做一次指数运算

//假设data这个buffer的值的范围为0-255;
for(int i = 0; i < 1000; i++)
{
  res[i] = exp(data[i]) * gain[i];
}

提前计算好结果存在表里

for(int i = 0; i < 255; i++)
{
  table[i] = exp(i);
}
for(int i = 0; i < 1000; i++)
{
  res[i] = table[data[i]] * gain[i];
}

8. 其他方法

篇幅有限逐个介绍,恐怕要写很长,要是大家感兴趣后面再更。这里简单列一下想到的点:

1.图像多个滤波的共享pipeline读写优化,去掉一些中间图的存储。

2.结构体内部元素地址对齐。

3.除法改乘法,精度要求不高的场景可用牛顿下降法逼近除法。

4.浮点运算改为定点运算。浮点改定点我们需要把注意力放在数据位宽的优化上。

5.有的编译器提供了软件预取接口,可以尝试软件预取,提升cache 命中率。

6.使用编译器提供的pragma语法告诉编译器关键信息,辅助编译器优化。

7.去除指针别名,也即是使用关键字告诉编译器两个指针不会指向同一个buffer, 辅助编译器优化,一般在dsp平台常用的比较多。

待续......


使用硬件加速指令

多媒体算法以及神经网络对计算性能的要求很高,现在的处理器大多都有一些硬件拓展支持SIMD加速,ARM上面是NEON/MVE,x86上面是SSE/AVX,DSP例如hexagon提供的HVX。一般处理器都会或多或少的带有一些SIMD指令支持,例如TI和HIFI这种DSP处理器,虽然寄存器长度相对于前面的比较小但是也都有SIMD指令支持。下图简单展示了单指令单数据(SISD),单指令多数据(SIMD),单指令多线程(SIMT)的区别,其中SIMT是GPU处理器的加速思路。

单指令单数据也就是一条指令处理单个数据,单指令多数据是一条指令可以处理多个数据,单指令多线程是指每个线程执行同样的指令可以同时处理多个数据。其中SIMT的并行处理能力要比SIMD强很多,通常SIMD所使用的寄存器长度有限,例如ARMv9架构 SVE指令集使用的寄存器长度最大为2048byte,高通HVX指令集使用寄存器长度为1024byte。相比之下GPU的线程数是非常多的,例如RTX 3090有82个SM,每个SM支持1536个线程,理论最大并发线程数约为82×1536≈12.6万。SIMD加速指令这里就以常见的ARM NEON为例,其他的都很类似,只是指令集有所差异。

1.使用SIMD的intrisic函数做加速

通常交叉编译器会提供SIMD指令的intrisic函数让开发者可以以C的形式使用SIMD指令。

示例循环

int accRes = 0;
int acc[4] = {0};
for(int i = 0; i < 1000 / 4; i += 4)
{
  acc[0] += data[i];
  acc[1] += data[i + 1];
  acc[2] += data[i + 2];
  acc[3] += data[i + 3];
}
accRes = acc[0] + acc[1] + acc[2] + acc[3];

使用NEON指令

int32x4_t vRes = vdup_n_s32(0);
for(int i = 0; i < 1000 / 4; i += 4)
{
   int32x4_t vData = vld1q_s32(data + i);
   vRes = vaddq_s32(vRes, Vtemp);
}
int accRes = vaddvq_s32(vRes);

2.SIMD指令避免分支语句

有些时候内存分支无法避免,使用SIMD指令可以去除内部的分支。

示例分支

for(int i = 0; i < 1000; i++)
{
   if(data[i] > 0)
   {
     data[i] += 5;
   }
  else
   {
     data[i] += 10;
   }
}

NEON指令去除分支

for(int i = 0; i < 1000 / 4; i+= 4)
{
  int32x4_t vData = vld1q_s32(dataIn + i);
  int32x4_t v0 = vaddq_s32(vData, vdupq_n_s32(5));
  int32x4_t v1 = vaddq_s32(vData, vdupq_n_s32(10));
  uint32x4_t vMask = vcgtq_s32(vData, vdupq_n_s32(0));
  int32x4_t vDst = vbslq_s32(vMask, v0, v1);
  vst1q_s32(dataOut + i, vDst);
}

3.反汇编精调代码

不依赖于编译器,直接写汇编代码对于代码性能提升是最高效的。但是汇编也是最难调试的,一旦出了bug,查起来非常耗时。通常不会直接去写汇编代码,但是我们需要读懂汇编代码,有助于调试代码性能。编译器会提供一些反汇编工具导出汇编代码,通常可以通过这种工具来修改我们的C代码以辅助编译器生成更好的汇编代码。下面以我之前碰到的调试的一个案例做介绍。

示例汇编

...........
fadd v1.4s, v1.4s, v12.4s
fmul v1.4s, v1.4s, v10.4s 
fmla v1.4s, v4.4s, v5.4s///////
stur q1, [x2,#-16]//////此处存在寄存器依赖
ldr s3, [x8,x0, lsl #2] 
ld2 {v4.4s, v5.4s}, [x4] 
ldr q21, [x6],#32
dup v3.4s, v3. s[0] 
ldr s19, [x12,x0, lsl #2] 
fmul v20.4s, v4.4s, v4.4s
ldr s1, [x7,x1, lsl #2]
fmul v2.4s, v5.4s, v5.4s
lsl x1, x1, #3
fmul v13.4s, v8.4s, v4.4s
fmul v12.4s, v8.4s, v5.4s
fsub v3.4s, v3.4s, v21.4s
ldr s16, [x9,x1]
ldr s17, [x1l,x1]
mov x1, x5
fadd v2.4s, v2.4s, v20.4s
add x5, x5, #0x40
ld2 {v4.4s, v5.4s}, [x1], #32
fmul v3.4s, v3.4s, v19.s[0]
fmul v2.4s, v2.4s, v10.4s 
fmul v17.4s, v4.4s, v17.s[0]
fadd v2 1.4s, v5.4s, v4.4s
fmul v4.4s, v5.4s, v16. s[0]
fmla v2.4s, v3.4s, v18.4s
fmul v1.4s, v21.4s, v1.s[0]
fadd v16.4s, v4.4s, v17.4s 
str q2, [x2],#32////////
ldr s2, [x7,x0, lsl#2]//此处存在寄存器依赖
ld2 {v18.4s, v19.4s}, [x1]
lsl x0, x0, #3
fadd v1.4s, v1.4s, v17.4s
ldr s5, [x1l,x0]
fadd v20.4s, v19.4s, v18.4s
ldr s3, [x9,x0]
fsub v1.4s, v1.4s, v4.4s
fmul v5.4s, v18.4s, v5. s[0]
fmul v3.4s, v19.4s, v3. s[0]
fmul v2.4s, v20.4s, v2. s[0]
fadd v17.4s, v3.4s, v5.4s
fadd v2.4s, v2.4s, v5.4s
fadd v5.4s, v17.4s, v12.4s
fsub v2.4s, v2.4s, v3.4s 
fadd v3.4s, v1 6.4s, v6.4s
fadd v4.4s, v2.4s, v13.4s
fadd v2.4s, v1.4s, v7.4s
st2 {v4.4s, v5.4s}, [x4]
st2 {v2.4s, v3.4s}, [x21]
add x21, x21, #0x40b. ne  42ac30
..............

作者定位到这两条存储指令对应的neon instrisic代码,调整了两条存储指令的位置。最终去除了这个寄存器依赖,由于这个指令在最内层循环,所以得到的加速提升比较明显。

调整存储指令后的汇编

.........
fadd v3.4s, v3.4s, v23.4s
fadd v1.4s, v1.4s, v24.4s
fadd v4.4s, v9.4s, v22.4s
fadd v5.4s, v16.4s, v7.4s
fmul v3.4s, v3.4s, v10.4s
fmul v1.4s, v1.4s, v10.4s
st2 {v4.4s, v5.4s}, [x2]
fmla v3.4s, v18.4s, v19.4s
fadd v4.4s, v2.4s, v17.4s
fadd v5.4s, v12.4s, v6.4s
fmla v1.4s, v21.4s, v20.4s
stur q3, [x0, #-32]/////去除了寄存器依赖
st2 {v4.4s, v5.4s}, [x21]
add x21, x21, #0x40
stur q1, [x0, #-40]//////去除了寄存器依赖
.............

此外一些dsp编译器提供了一些用于性能分析的工具,例如hifi的编译器可以直接分析每一条汇编指令的cycle,ADI-2156x DSP的编译器可以在汇编代码中提供循环的硬件资源和内存访问开销信息, 高通hexagon dsp可以在汇编代码中提供各种PMU事件,这些都可以帮助我们更清晰的看到瓶颈在那条指令,哪些运算存在资源依赖。

4.其他方法

1.对于ARM处理器使用64位neon 指令效率更高。

2.使用乘加融合指令,一般这种指令会导致一些一致性的差异,所以会默认关闭,需要权衡一下误差。

3.避免通用寄存器和向量寄存器之间的数据拷贝。


结尾

从前面的内容我们可以看到“容易理解的代码跑的不快,而跑的快的代码不容易理解”。这是因为我们项目开发的时候为了便于调试,易于理解,写的代码有很多冗余的计算和内存,这必然会导致代码跑的比较慢。对于多核处理器,可以多核并行,如果线程创建和内存申请比较频繁可以使用池化技术,例如线程池和内存池。除了优化手段,分析瓶颈在哪也很重要,dsp平台都会有profile工具分析各种性能事件,包括接口cycle消耗,cahce 命中率,分支预测情况。ARM上用perf比较多,火焰图可以快速帮助我们看到瓶颈在哪,trace可以帮助我们看核的负载情况。如果处理的数据量小我们应该关注计算瓶颈,如果处理的数据量大我们还需要关注内存读写瓶颈。DSP和GPU适用不同场景,关于两者后面有机会再跟大家讨论一下。


动静分离,ESA 助力电商网站提升访问体验

本方案将为您介绍如何使用阿里云边缘安全加速 ESA 您业务进行动静态资源分离,并在边缘节点上部署边缘函数进行渲染加速,为用户带来更快、更安全的访问体验。

点击ESA 助力网站提升访问体验-阿里云技术解决方案查看详情。

点击这里复制本文地址 以上内容由文彬编程网整理呈现,请务必在转载分享时注明本文地址!如对内容有疑问,请联系我们,谢谢!
qrcode

文彬编程网 © All Rights Reserved.  蜀ICP备2024111239号-4