读程序员的制胜技笔记09_死磕优化(下)

发布时间 2023-11-11 08:08:53作者: 躺柒

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;
  }  ⇽--- 
}

6.9.2. 基于SIMD的代码的运行速度是普通代码的约两倍

6.9.3. 根据你处理的数据类型和你对数据进行的操作,它还可以更快

7. I/O

7.1. I/O包含CPU与外围硬件沟通的一切,比如磁盘、网络适配器,还有GPU

7.2. I/O通常是性能链上最慢的环节

7.2.1. 缓慢源自物理学,但硬件可以独立于CPU运行,所以它可以在CPU做其他工作时保持运行

7.2.2. 可以把CPU和I/O的工作重叠起来,在更小的时间范围内完成整体操作

7.3. 键盘是字符设备,因为它一次发送一个字符

7.4. 许多I/O设备都是以块的形式进行读写的,称为块设备(block device)

7.4.1. 网络和存储设备通常是块设备

7.4.2. 块设备不能读取小于块大小的东西,所以读取小于典型块大小(typical block size)的东西不合理

7.5. 缓冲区大小对I/O性能的影响

7.5.1. 图

7.5.2. 使用512字节的缓冲区也会产生巨大的作用,复制操作的速度提高了6倍

7.5.3. 将其增加到256KB时效果最好,再大一点也只能产生边际改善

7.5.3.1. 在256KB之后,提升突然变得杯水车薪

7.5.4. Windows I/O使用256KB作为其I/O操作和缓冲区管理的默认缓冲区大小

7.6. 当你处理I/O时,找到理想的缓冲区大小,并避免分配超过你需要的内存

8. 异步I/O

8.1. 它经常与多线程相混淆,多线程是一种并行化模型,通过让一个任务在不同的核心上运行,使得操作的速度变快

8.2. 多线程和异步I/O也可以一起使用,因为它们分别解决不同场景的痛点

8.3. I/O自然是异步的,因为外部硬件几乎总是比CPU响应慢,而且CPU不喜欢等待

8.4. 中断和直接存储器访问(direct memory access,DMA)等机制的发明是为了让硬件在I/O操作完成后向CPU发出信号,以便CPU能够传输结果

8.5. 异步I/O是一种仅适用于I/O重度操作的并行化模型,它可以在单个核心上工作

8.6. 异步I/O可以并行地运行多个I/O操作并收集结果,而不需要忍受多线程带来的问题

8.7. 异步代码还可以帮助提高事件驱动机制(event-driven mechanism),特别是用户界面的响应速度,完成这个操作也不需要消耗线程

8.8. 直到2010年初,异步I/O还是使用回调函数管理的

8.9. async/await语义来编写异步I/O代码的好方法

8.10. 你开始的异步操作越多,你就越容易失去对操作的控制

8.10.1. "回调地狱”(callback hell)

8.11. 如果你的异步代码在等待某些东西完成,你就做错了

9. 缓存

9.1. 缓存是立即提高性能的最有力的方法之一

9.2. 缓存失效可能是个难题,但如果你只缓存你不担心失效的东西,那它就不是个问题

9.3. 不需要使用在如Redis或Memcached这样的单独服务器上的复杂缓存层,你可以使用内存缓存(in-memory cache)

9.3.1. 微软在System.Runtime.Caching包中提供的MemoryCache类

9.4. 避免使用那些不是为缓存设计的数据结构

9.4.1. 它们通常没有任何驱逐(eviction)或过期机制,从而成为内存泄露的来源,并最终导致程序崩溃

9.5. 不要害怕缓存会无限存在

9.5.1. 无论是缓存的驱逐还是应用程序的重启都会在世界末日前发生

10. 要点

10.1. 把不成熟的优化作为练习,并从中学习

10.2. 避免为了优化而优化,这会把自己带入“深坑”

10.3. 始终用基准测试来验证你的优化

10.4. 保持优化和响应性的平衡

10.5. 在构建数据结构时,尽可能做到内存对齐,以获得更好的性能

10.6. 配备缓存定位、管线和SIMD

10.7. 使用适当的缓冲机制来提高I/O性能

10.8. 使用异步编程来并行运行代码和I/O操作,不浪费线程

10.9. 在紧急情况下,放弃缓存方案