这周主要是把 CPU 的前后端主要组件过了一下,做了一些简单的微基准测试,学习了对应工具的使用方法,ebpf 的内存泄漏工具大概过了一遍。

  • 测试了一个 Windows 特定版本存在的类似预映射机制,也就是当正向访问一块匿名内存的时候,累计访问的 4K 页超过特定阈值(这里是 16),后续的访问并不是在缺页异常中只映射当前这个页,而是会映射加上本页和后面的一共 16 页,跨 PT,也就是累计 512 个 4K 页后,阈值会清零。由于是黑盒,所以只能大概知道有这个机制,wrk 中有一个类似机制叫页面聚类,不过是针对文件映射的,不是匿名内存。

  • 了解了一下奔腾 4 的失败,为了 10GHZ 的频率将流水线加到了 31 级,导致了巨大的分支预测惩罚和无法控制的发热。

  • 对当前 CPU 的后端 AGU 端口数量进行了探测,也就是 load 微指令的单核并行能力。具体来说就是固定 load 数量,更改每个循环中的 mov 读取指令数量,看他的线性提升什么时候结束。探测出来的 load 端口是 3 个,符合当前的硬件数据,也就是 Golden Cove,具有 3 个 AGU 端口。为了确定他是跑在性能核而不是能效核(能效核的 AGU 端口是 2 个)上的,使用 WINAPI 绑定到一个核上,然后使用 Intel VTune Profiler 查看硬件事件中的 Core Type,可以确定他具体跑在了性能核还是能效核上。

  • 对当前 CPU 的 L1/2/3 缓存大小进行了探测,这个就是按照不同的个大小范围(1KB 到 32MB),一共循环读 1G 数据,算带宽。在 48KB、1.25MB、24MB 出现了明显的带宽下降,符合当前硬件的数据。

  • 对当前 CPU 的 cacheline 内未对齐惩罚,跨 cacheline 读取惩罚进行了探测。

    1. 测试 cacheline 内未对齐惩罚使用的是指令缓存,也就是运行一个完全相同的循环,使用 align 64 和 nop 指令创造不对齐情况,测试运行速度。在 LLVM/GCC 中也有对循环/函数代码的对齐优化策略,是一种空间换时间的优化方式。早期硬件下 cacheline 内未对齐读取的惩罚较大,而且不能保证这种情况下内存操作的原子性,可能会出现撕裂读取现象。在现代处理器上,cacheline 内未对齐读取的操作已经大幅优化,同时也保证了原子性。
    2. 测试跨 cacheline 读取惩罚使用 AVX2 的指令读取 32 字节,一个循环中读取两次,一共读 1G 的数据,区别在读取的偏移不同,测试带宽。跨 cacheline 的读取是频繁使用 L1 场景下拖慢后端访存延迟的重要因素,因为他需要 AGU 拆分微指令并串行执行,在行内的使用一个微指令就可以读取。
  • 了解了一些常见的内存泄露检测工具。有很多标准来对他们进行分类,包括需不需要源码/执行方式/侵入性等。这里把内存泄露检测工具从如何定义”内存泄露”当作一个标准,可以将他们大致分为两类:

    1. heaptrack 等小型项目。他们将泄露的内存定义为”退出时没有释放的内存”,检测原理是通过 hook 或者 ebpf,在分配/释放函数上插桩,分配时记录,释放时删除,退出的时候剩下的就是泄露的。
    2. valgrind LSan。他们将泄露的内存定义为”退出时不可达的内存”。原理是编译时插桩/转化为中间代码再插桩,在退出时扫描寄存器、栈、数据段、TLS 中的值,看他们是否指向一个”堆上已分配块”,如果是,就递归扫描这一块的堆内存,看他有没有指向。没有任何指针指向它就是 kDirectlyLeaked(直接泄漏),如果有指针指向他但是这个指针是直接泄漏的,那他就是 kIndirectlyLeaked(间接泄漏)。

第一种工具的检测结果是包含第二种的,但是在大型项目中会有很多噪声,比如全局单例。

第二种标准检测的是能力而不是行为,程序只要持有一块内存的指针,他就拥有释放内存的能力,工具并不关注是否做了释放这个行为。其实第二种本质上就是一个只在退出的时候运行一次的 GC,他不用处理 GC 的 stop the world,不用做”什么时候扫描”的抉择。

不管是 Boehm GC 还是 Go 的 GC,都是用可达性判断是否需要回收内存。

https://github.com/bdwgc/bdwgc

只不过都写 C++ 了,如果还要引入 GC,那为什么不去写 Go 呢🤣。