![](https://i-blog.csdnimg.cn/blog_migrate/6385f08b777b5d32ac6f31c223602357.png)
在作为软件开发人员的职业生涯开始之初,大多数人认为程序的性能归结为其操作的渐进复杂性。 但是,一旦您在其中应用了最佳算法,而您的代码仍无法提供理想的性能,您应该怎么做?
以下是一些基本技术,可以帮助您从CPU内核中获得额外的收益。
循环展开
现代的CPU内核在其执行单元中具有多个功能单元,例如ALU ,Load / Store,分支。
Kaby Lake(KBL)是Intel继Skylake之后的继任者,Skylake是面向主流台式机的增强型14 nm工艺微体系结构 。en.wikichip.org
程序应始终尝试利用它来实现并行性。
一种技术是使用循环展开 。 循环展开允许您打破参数上的依赖链。 乱序的CPU可以并行执行多个指令,从而使程序运行更快。
func F1 () int {
var acc int = 1;
for j := 0; j < length; j++ {
acc = acc + inputList1[j]
}
return acc
}
func F2 () int {
var acc1 int = 1;
var acc2 int = 1;
for j := 0; j + 1 < length; j += 2 {
acc1 = acc1 + inputList1[j]
acc2 = acc2 + inputList1[j + 1]
}
return acc1 + acc2
}
在F1中,程序通常必须等待acc更新才能执行下一个循环。 但是,在F2中,您的程序将利用内核中的多个整数加法单元,并将并行进行。 如果运行基准测试,您会发现F2的速度几乎是F1的两倍。
矢量指令
现代CPU可以将向量(原始类型的数组)作为一个单元而不是单个元素进行操作。 向量指令涉及在单个指令中一次加载多个值并对其进行存储。 这些指令称为SIMD (单指令,多数据)。 SIMD的流行扩展是SSE , AVX以及那里的扩展。
SIMD指令通常可以一次处理8个32位或4个64位值,因此可以使程序真正更快。 通常,编译器会注意这一点。
快取
缓存通常被程序员视为理所当然。 您编写的代码通常会对是否进行有效的缓存产生重大影响。
以github为例
func ColumnTraverse() int {
var ctr int
for col := 0; col < cols ; col++ {
for row := 0; row < rows ; row++ {
if matrix[row][col] == 0xFF {
ctr++
}
}
}
return ctr
}
func RowTraverse() int {
var ctr int
for row := 0; row < rows ; row++ {
for col := 0; col < cols ; col++ {
if matrix[row][col] == 0xFF {
ctr++
}
}
}
return ctr
}
这两个函数都在做相同的事情,并且具有相同的渐近复杂度。 如果运行基准测试,您会发现RowTraverse通常比ColumnTraverse快4倍。
这是因为行遍历会利用缓存中已存在的值,但是ColumnTraverse必须在每次比较时从内存中获取一个值。
您可以在我的文章中阅读有关缓存的更多信息
分支预测
现代CPU在看到分支时不会停止(即条件类型)。 取而代之的是,他们假定是否采用了分支,并推测性地执行下一条指令。 仅在确定预测是否正确时才提交结果。 如果预测错误,CPU将丢弃结果,并在分支之后从第一条语句开始执行指令。 除了执行更多指令的额外开销外,这种遗漏通常会导致5-10 ns的损失。
如果您的程序在if-else之间切换很多,那么分支错误预测损失将对您的性能造成损害。 避免这种情况的最佳方法是使用尽可能少的分支,如果绝对必要,则使它们可预测(例如,在比较之前对值进行排序)。
并发!=并行
您将线程数从10增加到100,程序的性能变得更差。 为什么会这样?
问题是CPU。 它仅具有有限数量的内核来支持并行操作。 在应用程序中似乎并行发生时,处理器通常会在线程之间快速切换。 但是,这种切换非常昂贵,因为它涉及来回复制PC,SP和内存/高速缓存中的所有寄存器。
如果您有大量的线程,CPU将大部分时间花在这些线程之间,而不是执行实际的指令。 现代语言试图通过在几个OS线程上复用大量抽象(例如goroutine)来解决此问题。
互斥体
如果您实现了最佳的并发性,但是您正在使用锁来同步数据结构,那么您仍然不会注意到应有的性能提升。
这是因为简单的互斥锁锁定/解锁大约需要25ns。 这似乎并不多,但是一旦您以1000万RPM的速度运行程序,则25ns变为250ms,这是很多。
避免互斥的最简单方法之一就是拥有线程本地数据结构。 如果需要在线程之间共享数据,请使用无锁通信技术(例如goroutine中的通道),而不要使用共享内存DS。
内存参考
除非绝对需要,否则不应该使用内存引用。 编译器通常会避免对内存引用进行优化,因为它可能具有空前的含义。
func AddPointers(x *int, y *int){
*x += *y
*x += *y
}
一个简单的优化编译器应该做的是使它* x = 2 *(* x)+ * y从而减少指令数。
但是,如果x和y指向相同的变量,则AddPointers将给出输出4(* x),而优化将给出输出3(* x),这是不正确的。 编译器在遇到这种情况时会安全地播放,并且会避免任何优化。
可以应用更多的技术,例如内核旁路,避免内核间通信等。但是这些技术不满足简单性的标准。 我将在另一篇文章中详细讨论这些内容。
涉及这些主题的一些资源包括:
- 计算机系统程序员的观点
- http://highscalability.com/all-time-favorites/
- Scott Meyers:CPU缓存以及您为什么要关心
- 每个程序员都应该知道的延迟数
在 LinkedIn 或 Facebook 上与我联系, 或 发送邮件至 kharekartik@gmail.com 共享反馈。