代码怎么才能跑的更快

简介: 本文分享了让代码运行更快的思路与方法,主要从调整源代码和使用硬件加速指令两方面展开。通过优化循环、减少内存引用、避免分支语句、合并读写等方式辅助编译器提升性能。同时介绍了SIMD指令(如ARM NEON)的应用,包括intrisic函数加速、去除分支及反汇编调优等技术。文章结合实际案例分析瓶颈定位与优化策略,强调计算与内存效率在不同场景下的平衡。内容适用于端侧设备资源受限情况下的性能优化,适合对代码效率有高要求的开发者参考。

概述

在写代码的时候我们都会碰到代码运行很慢的问题,代码的算力占用过高会直接导致项目难以落地,尤其是在端侧设备计算资源和内存资源都非常有限的情况下。如果计算资源相对充裕,我们开一个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适用不同场景,关于两者后面有机会再跟大家讨论一下。

来源  |  阿里云开发者公众号

作者  |  商南

相关文章
|
28天前
|
人工智能 运维 数据挖掘
一站式智能分析引擎,快速构建企业级数据分析 Agent
本文介绍了一种基于阿里云实时数仓 Hologres 和百炼大模型服务的智能数据分析解决方案。通过 Function AI 提供的 Serverless 平台,企业可快速构建从多源数据接入到业务洞察的端到端流程。方案支持实时数据分析、湖仓直连加速、智能预处理及按需付费模式,大幅降低运维成本并提升效率。同时,文章详细描述了实践部署步骤,包括专有网络配置、Hologres 实例创建、公共数据集导入及应用部署验证等环节,并提供了资源清理指南与参考链接,确保用户能够顺利实施和管理方案。
|
27天前
|
关系型数据库 MySQL 分布式数据库
Super MySQL|揭秘PolarDB全异步执行架构,高并发场景性能利器
阿里云瑶池旗下的云原生数据库PolarDB MySQL版设计了基于协程的全异步执行架构,实现鉴权、事务提交、锁等待等核心逻辑的异步化执行,这是业界首个真正意义上实现全异步执行架构的MySQL数据库产品,显著提升了PolarDB MySQL的高并发处理能力,其中通用写入性能提升超过70%,长尾延迟降低60%以上。
|
28天前
|
人工智能 安全 Java
AI Agent 的工程化被低估了
本文探讨了AI应用工程化的关键作用与实现路径,将其分为产品工程和技术工程两大部分。产品工程关注用户体验与交互设计,包括需求建模、UI/UX设计、系统提示词优化及反馈闭环构建,确保AI“能用、好用”。技术工程则聚焦系统稳定性与扩展性,涵盖架构模块化、工具调用机制、流量控制、数据管理及可观测性建设,保障AI应用“快、稳、强”。两者协同决定了AI Agent的实用性与规模化潜力,为行业提供了落地参考。
396 30
AI Agent 的工程化被低估了
|
17天前
|
人工智能 Kubernetes 调度
基于 AI 网关和 llmaz,提升 vLLM 推理服务可用性和部署易用性的实践
本文介绍了如何使用 llmaz 快速部署基于 vLLM 的大语言模型推理服务,并结合 Higress AI 网关实现流量控制、可观测性、故障转移等能力,构建稳定、高可用的大模型服务平台。
205 16
|
29天前
|
人工智能 边缘计算 Serverless
震惊!CDN都进化到可以用MCP写游戏了吗?
《2048》是一款风靡全球的数字益智小游戏,玩家通过移动和合并相同数字完成2048即为通关。传统开发需数小时甚至数月,而使用ESA MCP Server只需1分钟“0代码”即可实现网页全球部署。ESA MCP Server是开源的Model Context Protocol服务实现,连接AI模型与边缘安全加速服务。结合阿里云边缘函数(ER),支持秒级全球节点部署,降低延迟,提升响应速度。环境搭建简单,仅需配置API密钥与插件,向AI提出需求即可快速生成并部署应用。
89 10
|
21天前
|
人工智能 自然语言处理 关系型数据库
如何构建和调优高可用性的Agent?浅谈阿里云服务领域Agent构建的方法论
本文深入探讨了Agent智能体的概念、技术挑战及实际落地方法,涵盖了从狭义到广义的Agent定义、构建过程中的四大挑战(效果不稳定、规划权衡、领域知识集成、响应速度),并提出了相应的解决方案。文章结合阿里云服务领域的实践经验,总结了Agent构建与调优的完整路径,为推动Agent在To B领域的应用提供了有价值的参考。
283 18
如何构建和调优高可用性的Agent?浅谈阿里云服务领域Agent构建的方法论
|
13天前
|
人工智能 Java Docker
Spring AI Alibaba 游乐场开放!一站式体验AI 应用开发全流程
Playground 是基于 Spring AI Alibaba 框架打造的 AI 应用体验平台,集成了对话、图片生成、RAG、MCP、工具调用等功能。用户可通过前端 UI 与后端完整实现快速复刻专属 AI 应用。项目支持 Docker 部署和本地构建,提供源码供定制开发,并配备详细文档与在线体验地址,助力开发者高效上手 AI 应用开发。
267 21
|
16天前
|
数据采集 JSON API
Excel数据治理新思路:引入智能体实现自动纠错【Python+Agent】
本文介绍如何利用智能体与Python代码批量处理Excel中的脏数据,解决人工录入导致的格式混乱、逻辑错误等问题。通过构建具备数据校验、异常标记及自动修正功能的系统,将数小时的人工核查任务缩短至分钟级,大幅提升数据一致性和办公效率。
447 22
|
17天前
|
人工智能 IDE 定位技术
通义灵码 AI IDE 上线,第一时间测评体验
通义灵码 AI IDE 重磅上线,开启智能编程新纪元!无需插件,开箱即用,依托通义千问大模型,实现高效、智能的编程体验。支持 MCP 工具链,可快速调用多种服务(如12306余票查询、高德地图标注等),大幅提升开发效率。结合 Qwen3 强大的 Agent 能力,开发者可通过自然语言快速构建功能,如智能选票系统、地图可视化页面等。行间代码预测、AI 规则定制、记忆能力等功能,让 AI 更懂你的编码习惯。Lingma IDE 不仅是工具,更是开发者身边的智能助手,助力 AI 编程落地实践。立即下载体验,感受未来编程的魅力!
161 17
|
21天前
|
人工智能 监控 安全
人人都是造梦者:AI时代的创意落地指南
有好想法因为"不会技术"而只能停留在脑海里?如果技术门槛不再是阻碍,你最想实现什么?在发现好想法后,如何落地自己的AI创意?这个过程可能需要哪些东西?本文手把手教你如何让自己的创意落地。
140 15
人人都是造梦者:AI时代的创意落地指南