并行算法设计与性能优化

处理器特性

处理器的并行特性包括:

  1. 指令集并行
  2. 向量化并行
  3. 线程级并行
  4. 缓存优化

了解目标处理器的特性才能写出高质量的代码。

  • 现代处理器的运算速度远快于内存读写速度,为了减少访问数据的延迟,现代处理器主要采用了以下两种方式:
  1. 利用程序的局部性,将正在访问或者即将访问的数据放到缓存当中。
  2. 利用并行性,在一个高延时的指令阻塞的时候,执行另一个控制流。

现代乱序多核处理器与许多与代码优化相关的特性:

  1. 指令集并行:流水线,多发射,VLIW,乱序执行,分支预测
  2. 矢量化:SIMD
  3. 线程级并行
  4. 缓存层次
流水线

为了充分利用流水线的优势,一些处理器可以将一些复杂的指令划分为更小的指令以增加流水线的长度。

乱序执行
  • 编译器会通过指令重排与变量重命名来为乱序执行提供支持。
  • 乱序执行会重排指令的执行顺序,这要求处理器的发射能力大于其执行能力
指令多发射

指令发射单元一个周期内可以发射多条指令,通常指令发射单元的发射能力会超过单一硬件单元的处理能力

分支预测

当处理器遇上分支指令(如if语句)时会有两种选择:

  1. 直接执行下一条指令,如果分支是循环的判断条件,则很有可能造成流水线的中断。
  2. 选择某条分支执行,一旦选择错误,处理器就需要丢弃已经执行的结果,且重新需要从正确的分支开始。
VLIW

VLIW(very long instruction word)的并行指令基于编译时已经确定好的调度。

向量化并行

大多数编译器提供了内置函数来避免直接使用汇编指令编写向量话代码。如X86的SSE/AVX,ARM的NEON,他们都是SIMD模式。而主流的GPU采用了SIMT模式。
Intel X86提供的SSE/AVX指令对应的向量寄存器长度分别是128位、256位。
SIMT 是NVIDIA GPU和AMD GCN GPU采用的向量化方法,SIMT无需显示的编写向量化代码,只需编写一份CUDA代码就可以充分发挥处理器的全部性能,而X86的多核处理器需要使用向量指令和多线程两种风湿才能发挥全部性能。

线程级并行

线程级并行将处理器内部的并行由指令级和向量级上升到线程级,意在通过线程的并行来增加指令吞吐量,提高处理器的资源利用率。线程级并行的思想是:

  1. 使用多个线程使得多个核心同时执行多条流水线。
  2. 在单核处理器上,当某一个线程由于等待数据到达而空闲时可以立即导入其他的就绪线程来运行。在单核处理器上使用多线程可以使得核心始终处于忙碌的状态。
多核上多线程并行需要注意的问题:
  1. 线程过多导致频繁地上下文切换从而降低性能
  2. 数据竞争:多个线程同时读写同一个共享数据的时候,需要进行同步保证数据的一致性。
  3. 死锁
  4. 饿死:当一个或多个线程永远没有机会调度到处理器上执行而陷入永远的等待状态
  5. 伪共享:当多个线程读写的数据映射到同一条缓存线上得时候,如果一个线程更改了数据,那么其他线程对该数据的缓存就要被失效,如果线程频繁地更改数据,硬件就需要不停地更新缓存线。

缓存

  • X86 Haswell处理器的吞吐量是一个时钟周期內4次整形加法、2次单精度浮点乘加。从延迟上看,做一次乘法只需要三个时钟周期,一次除法也就十几个周期,而一次内存访问需要200个周期以上。
  • 处理器吞吐量与内存吞吐量的延迟差异越来越大,这称之为内存墙。现代处理器通过这几种方式来减少这种差距:
  1. 每次内存访问、读取周围的多个数据;因为这些数据随后极有可能被使用到
  2. 采用容量小但是更快的缓存访问
  3. 支持向量的访问和同时处理多个访问请求,通过大量并行访问来掩盖延迟
    数据的局部性:可以分成时间局部性和空间局部性:时间局部性是指当前被访问的数据随后有可能被访问到;空间局部性是指当前访问地址附近的地址可能随后被访问到。
缓存层次结构
  1. Intel Haswell CPU的一级缓存大小为32KB,延迟为3个周期,吞吐量为每周期64字节
  2. 二级缓存大小为256KB,延迟为11个周期,吞吐量为每周期64字节
缓存一致性
  1. 对于单核处理器,其缓存一致性需要保证某个地址上读取得到的数据一定是最近写进去的。
  2. 多核处理器上,如果多个核心缓存了同一个内存地址的数据,那么一个核心更改了某个地址的数据,其他的核心就需要对该地址数据的缓存失效。
缓存结构
  1. 缓存线:是缓存中数据交互的基本单位,主流CPU上缓存线长度是64B;主流GPU上缓存线长度是128B。
  2. 缓存组,为了减少冲突不命中的情况,通常将多个缓存线组成一组。

算法性能度量

  • 时间复杂度:只关注计算对性能的影响而不关注访存、不考虑不同指令的速度差异
  • 空间复杂度:衡量运行时需要的内存数据量,与性能优化没有太多联系
  • 实现复杂度:
    • 计算复杂度:不仅需要考虑计算、加载存储指令,还需要考虑指令的吞吐量或延迟。
    • 访存复杂度:衡量访问缓存层次的字节数量,通常不是直接加和合适由瓶颈在缓存的哪一个层次决定。
    • 指令复杂度 :分析程序生成的指令数量、类型、是否可以同时执行等。通常使用执行代码关键路径所需要的时钟周期来表示指令复杂度。指令复杂度分析关注耗时最长的循环,分析循环内部的指令数量、不同指令能否同时发射、执行等来重构代码,以便生成的指令能够更好地在处理器上执行。

Amdahl 定律

此定律提供了性能优化的上限。

性能分析工具

  1. gprof:通过在编译时插入代码来分析程序,可以输出每个函数被调用的次数、每次运行的时间与函数之间的调用关系。程序在硬件平台上实际执行时的详细信息可以通过perf或intel Vtune获得
  2. nvprof:用于分析运行的CUDA性能,不仅可以统计各个内核和CUDA函数的运行时间,还可以给出硬件计数器的值。比如SM上一共发出了多少指令,和兴一共发射和执行了多少条指令。

串行代码性能的优化

串行代码性能优化甚至比并行代码优化更为重要,一方面因为并行单独获得的加速通常有限(一般不超过处理器的核心数),而串行代码优化有时候能获得成千上万倍的提升。另一方面,单个并行控制流内部依旧是串行的,因此一些串行优化措施任然起作用。

串行代码的优化分成如下几个层次

  1. 系统级别
  2. 应用级别
  3. 算法级别
  4. 函数级别
  5. 循环级别
  6. 语句级别
  7. 指令级别
系统级别
  • 考虑限制因素是处理器的计算能力还是访存等,如果设计网络互联那么网络速度、网络负载均衡也需要考虑。大致可以分成如下因素:
    1. 网络速度、利用率、负载均衡
    2. 处理器利用率:过高的时候需要优化代码、过低的时候考虑是否阻塞
    3. 存储器带宽利用率:提高存储器访问的局部性以增加缓存利用;将数据保存在临时变量中减少存储器的读写,由于临时变量占用空间更小,硬件可以将其缓存到一级缓存中,或者编译器可以将他们分配到寄存器当中。;减少读写依赖提高流水线效率;同时读写多个数据提高系统的指令级并行能力。
    4. 阻塞处理器运算:如果处理器利用率一会高一会低,通常是IO或其他进程占用了处理器或内存带宽导致阻塞处理器的运算。
应用级别
  • 编译器的选项
  • 调用高性能库函数
  • 去掉全局变量:全局变量再多个文件的共享会阻碍编译器的优化
  • 条件编译:相比于运行时检测,条件编译生成的代码更短,因此运行效率也就越高。例如使用 #ifdef #if#else等宏定义语句。
算法级别

主要考虑算法实现的问题,例如考虑数据的组织、算法的实现策略。

  • 缓存优化:和数据的组织密切相关。
    1. 索引顺序,访问多维数组时的局部性直接与各维数据在内存中存放的先后顺序相关。在C语言中,尽量按照行访问数据。
    2. 缓存分块:当数据大小超过缓存的大小,就容易出现满不命中的情况。
    3. 软件预取:在数据被使用之前投机地将其加载到缓存中。
函数级别

函数调用时,需要将调用的参数通过寄存器或者栈来传递。函数级别的优化通常用于减少这部分的消耗。

  1. 函数调用参数:函数的参数优先通过寄存器传递,超量之后才会通过栈传递。
    当传参是大结构体的时候应该通过传递指针或者引用以减少复制与销毁时的开销。
  2. 将小的函数内联
  3. 尽量不使用全局变量,就算使用全局变量也要通过参数传递。
循环级别
  • 循环展开:展开循环不仅可以减少每次的判断数量与计算次数,更能增加处理器流水线执行的性能。通常展开小循环切内部没有判断的会有收益,展开大循环会导致寄存器溢出,展开内部有判断的循环会增加分支预测的开销。
  • 循环累积:主要和循环展开同时使用,在减少寄存器的使用量的同时保证平行度。
  • 循环合并:如果多个小循环使用的寄存器数量没有超过处理器的限制,那么合并小循环可能会带来性能的好处(增加乱序执行的能力)
  • 循环拆分:如果大循环存在寄存器溢出的情况,将其拆分成小循环可以提高寄存器的利用
语句级别

实现统一功能的不同语句在编译后的指令数量不同、指令类型也会不同。因此语句优化就是可以生成更加高效的指令。

  • 减少内存读写:添加临时变量
  • 选用尽量小的数据类型
  • 结构体对齐:声明结构体的时候,尽量大数据类型在前面,小数据在后面。一方面可以节省一些空间,另一方面可以更好地满足处理器的对齐要求。
  • 分支优化:当CPU遇到条件指令的时候,为了不中断流水线,硬件会进行分支预测,将预测的分支代码加载到流水线中,当发现预测错误的时候需要清空流水线,实际会损失的周期数通常是流水线长度的几倍。因此分支的优化非常重要。分支优化的一些方法:
    1. 尽量避免把判断放到循环里面
    2. 拆分循环:对于分支非常多的循环建议拆成多个小循环,以调高分支预测的准确率。
    3. 使用条件状态值生成掩码来移除条件分支。
指令级别
  1. 减少数据依赖
  2. 注意处理器的多发射能力:尽量能通将同时发射的指令安排在一起,这要求使用内置函数或者汇编语言编写
  3. 优化乘除法和模余
  4. 选择库函数
  5. 其他:使用const、static,少用虚函数,尽量给编译器更具体更多的信息。以便编译器做出更好地优化决定。
  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值