1. 造成延迟的3个方面
1.1. CPU
1.2. I/O
1.3. 人
2. 不要打包数据
2.1. 一个打包的数据结构
2.1.1. C#
struct UserPreferences {
public byte ItemsPerPage;
public byte NumberOfItemsOnTheHomepage;
public byte NumberOfAdClicksICanStomach;
public byte MaxNumberOfTrollsInADay;
public byte NumberOfCookiesIAmWillingToAccept;
public byte NumberOfSpamEmailILoveToGetPerDay;
}
2.1.2. 由于CPU对未对齐边界的内存地址的访问速度较慢,你节省空间的好处就被这个速度损失给抵消了
2.1.3. 把结构中的数据类型从byte改为int,并创建一个基准测试来测试二者差异,你可以看到byte的访问时间几乎是int的两倍,尽管它只占用了1/4的内存
2.1.4. 要避免不必要地优化内存
2.2. 对齐是指内存地址为4、8、16等2的倍数,至少是CPU的字大小(word size)
2.3. CPU的字大小
2.3.1. 由CPU一次能处理多少比特的数据来决定
2.3.2. 与CPU是32位还是64位的密切相关
2.3.3. 字大小主要反映了CPU的累加寄存器的大小
2.4. CPU从没有对齐的内存地址(unaligned memory addresses)读取数据时,会造成“惩罚”
2.4.1. 从某一个内存地址,比如1023,读取数据的时间会比从内存地址1024读取数据的时间要长
2.5. 有些CPU根本不允许你访问未对齐的内存地址
2.5.1. Amiga计算机中用到的Motorola 68000和一些基于ARM的处理器
2.6. 编译器通常会处理好对齐问题
3. 就地取材
3.1. 缓存是指将经常使用的数据保存在同一个位置,这个位置相较其他位置来说,访问速度更快
3.2. CPU有自己的缓存存储器,访问速度各不相同,但都比RAM的访问速度快
3.2.1. 顺序读取比随机读取内存的速度要快
3.2.2. 顺序读取一个数组可以比顺序读取一个链表更快,尽管两者从头到尾读取一遍需要的时间都为O(N),但数组的性能比链表更好,原因是数组的下一个元素在内存的缓存区的可能性更大
3.3. CPU通常猜你是按顺序读取数据的
3.4. 链表中的元素在内存中是分散的,因为它们是单独分配的
3.4.1. 并不意味着链表没有用,它有很好的插入/删除性能,而且当它增长时,内存开销较少
3.5. 在大多数情况下,列表对你来说是最优选择,而且它的读取速度更快
4. 将依赖性工作分开
4.1. CPU指令是由处理器上不连续的单元处理的
4.2. 管线(pipelining)
4.2.1. 由于解码单元需要等待指令完成,它可以在内存访问时为下一条指令做解码工作
4.2.2. CPU可以在单个内核上并行执行多条指令,只要下一条指令不依赖于前一条指令的结果
4.3. 重新排序指令,减少代码之间的依赖性,这样一条指令就不会因为依赖上一个操作的结果而阻塞管线上的下一条指令
4.3.1. 可以帮你提高代码的运行速度,因为强依赖代码会阻塞管线
5. 要有可预测性
5.1. 分支预测(branch prediction)
5.2. 到了编译阶段,它们都会变成一堆比较、加法和分支操作
5.3. 机器语言,即CPU能看懂的原生语言,由一连串的数字组成
5.4. 汇编语言是由机器语言转换过来,让人能够看懂的语言
5.4.1. 当你需要了解CPU计算密集型的任务时,汇编语言尤其能发挥神奇的作用
5.4.2. 只要你熟悉汇编语言的结构,就可以阅读JIT编译器生成的机器代码,并了解其实际行为
5.5. 汇编语法在不同的CPU架构中有所不同,建议至少要熟悉一种
5.5.1. 它将减少你对“黑箱”内发生的事情的恐惧
5.5.2. 它可能看起来很复杂
5.5.3. 但它比我们写程序的语言更简单,甚至可以说是原始的
5.6. 在执行之前,CPU不可能知道compare指令是否会执行成功,但由于有了分支预测,它可以根据观察到的情况做出较为靠谱的预测
5.7. 根据它的预测,CPU开始处理它预测的那个分支的指令,如果它预测成功,那之前做的准备就派上了用处,提高了性能
5.8. 你给CPU的“惊喜”越少,它的表现就越好
5.8.1. 由随机数组成的数组会比较慢的原因:在这种情况下,分支预测会派不上用场
6. SIMD
6.1. 单指令、多数据(single instruction,multiple data,SIMD)
6.2. CPU也支持专门的指令,可以用一条指令同时对多个数据进行计算
6.3. 对多个变量进行相同的计算,SIMD可以在其支持的架构上大大提升其性能
6.4. 当你有一个计算密集型的任务,需要同时对多个元素进行相同的操作时,你可以考虑使用SIMD
6.5. C#通过System.Numerics命名空间中的Vector类型提供SIMD功能
6.6. 有些CPU根本不支持SIMD,所以你必须先检查CPU上是否有这个功能
6.6.1. C#
if (!Vector.IsHardwareAccelerated) {
//这里是非向量实现
}
6.7. CPU在同一时间可以处理多少个给定的类型。这在不同的处理器上是不同的,所以你必须先查询一下
6.7.1. int chunkSize = Vector<int>.Count;
6.7.2. 当你知道你一次可以处理的数量时,你可以直接分块处理缓冲区(buffer in chunk)
6.8. 经典的简单乘法
6.8.1. C#
public static void MultiplyEachClassic(int[] buffer, int value) {
for (int n = 0; n < buffer.Length; n++) {
buffer[n] *= value;
}
}
6.9. Vector类型乘法
6.9.1. C#
public static void MultiplyEachSIMD(int[] buffer, int value) {
if (!Vector.IsHardwareAccelerated) {
MultiplyEachClassic(buffer, value); ⇽--- 如果CPU不支持SIMD,则调用之前的普通方法来实现
}
int chunkSize = Vector<int>.Count; ⇽--- 查询SIMD一次可以处理多少值
int n = 0;
for (; n < buffer.Length - chunkSize; n += chunkSize) {
var vector = new Vector<int>(buffer, n); ⇽--- 将数组段复制到SIMD寄存器当中
vector *= value; ⇽--- 一次性乘所有的值
vector.CopyTo(buffer, n); ⇽--- 替换结果
}
for (; n < buffer.Length; n++) { ⇽--- 用普通的方法处理剩余的字节
buffer[n] *= value;
} ⇽---
}