AMD64 缓存优化策略——栈、符号、变量和字符串表

问题描述

介绍

我将用 GNU 汇编器 (GAS) 为 Linux x86-64(特别是我桌上的 AMD Ryzen 9 3900X)编写我自己的 FORTH“引擎”。

(如果成功,我可能会用类似的想法为retro 6502和类似的家用电脑制作固件)

我想添加一些有趣的调试功能,如保存带有附加字符串的“N​​OP 词”的编译代码的注释,这在运行时什么都不做,但是当反汇编/打印出已经定义的词时,它会打印那些评论也是如此,所以它不会丢失所有标题(ab - c)和评论(这里有这个特别的小技巧),我将能够尝试用文档定义新词,然后以某种很好的方式打印所有定义并从这些库中创建新库,我认为这很好。 (并切换到忽略“生产版本”的评论

在这里阅读了太多关于优化的文章,但几周后我无法理解所有内容,因此我将推出微优化,直到它遇到性能问题,然后我才开始分析。

但我想从至少不错的架构决策开始。

我所理解的:

  • 如果程序主要从 cpu 缓存运行,而不是从内存运行,那就太好了
  • 以某种方式“自动”填充缓存,但将相关数据/代码紧凑且尽可能靠近可能会有很大帮助
  • 我确定了一些适合缓存的区域和一些不太好的区域 - 我按重要性对其进行了排序:
    • 汇编代码 - 引擎和基本词,如“+” - 一直使用(固定大小,.text 部分)
    • 两个堆栈 - 也一直使用(动态的,我可能会使用 rsp 作为数据堆栈并独立实现返回堆栈 - 还不确定,哪个是“原生”的,哪个是“模拟的”)
    • 第四字节码 - 定义和编译的字 - 在运行时使用,当速度很重要时(仍在增长)
    • 变量、常量、字符串、其他内存分配(在运行时使用)
    • 词名(“DUP”、“DROP”——仅在编译阶段定义新词时使用)
    • 评论(每天使用一个左右)

问题:

由于有很多“堆”长大(好吧,没有使用“免费”,所以它也可能是堆栈或堆栈增长)(以及两个向下增长的堆栈)我不确定如何实现它,所以 cpu 缓存会以某种方式适当地覆盖它。

我的想法是使用一个“大堆”(并在需要时使用 brk() 增加它),然后在其上分配大块对齐的内存,在每个块中实现“较小的堆”并将它们扩展到另一个大堆当旧的被填满时分块。

我希望,缓存会自动获取最常用的块,首先在大部分时间保留它,而较少使用的块将大部分被缓存忽略(分别只占用一小部分并被读取并全部踢出)时间),但也许我没有做对。

但也许有更好的策略吗?

解决方法

您进一步阅读的第一站应该是:

所以我会推出微优化,直到它遇到性能问题,然后我才会开始分析。

是的,开始尝试可能很好,这样您就可以使用硬件性能计数器进行分析,这样您就可以将您正在阅读的有关性能方面的内容与实际发生的情况相关联。因此,在深入优化整体设计理念之前,您会获得一些尚未想到的可能细节的想法。您可以从非常规模的一些事情开始,例如没有任何复杂分支的单个循环,了解有关 asm 微优化的很多知识。


由于现代 CPU 使用分离的 L1i 和 L1d 缓存以及第一级 TLB,因此将代码和数据放在一起并不是一个好主意。 (尤其不是读写数据;自修改代码由 flushing the whole pipeline on any store too near any code that's in-flight anywhere in the pipeline 处理。)

相关:Why do Compilers put data inside .text(code) section of the PE and ELF files and how does the CPU distinguish between data and code? - 他们没有,只有经过混淆的 x86 程序才会这样做。 (ARM 代码有时会混合代码/数据,因为与 PC 相关的负载在 ARM 上的范围有限。)


是的,确保所有数据分配都在附近应该有利于 TLB 位置。硬件通常使用伪 LRU 分配/逐出算法,该算法通常可以很好地将热数据保存在缓存中,并且通常不值得尝试手动 clflushopt 任何帮助它。软件预取也很少有用,尤其是在数组的线性遍历中。如果您知道以后要在何处访问相当多的指令,但 CPU 无法轻易预测,那么有时这样做是值得的。

AMD 的 L3 缓存可能会像 adaptive replacement 一样使用 Intel does,以尝试保留更多可以重用的行,而不是让它们很容易被不会被重用的行驱逐。但是 Zen2 的 512kiB L2 以 Forth 标准来说是比较大的;您可能不会有大量的 L2 缓存未命中。 (乱序执行程序可以做很多事情来隐藏 L1 未命中/L2 命中。甚至隐藏一些 L3 命中的延迟。)当代 Intel CPU 通常使用 256k L2 缓存;如果您对通用现代 x86 进行缓存阻塞,那么 128kiB 是块大小的不错选择,假设您可以写入然后在获得 L2 命中时再次循环。

L1i 和 L1d 缓存(每个 32k),甚至 uop 缓存(高达 4096 uop,每条指令大约 1 或 2 个),在像 Zen2 (https://en.wikichip.org/wiki/amd/microarchitectures/zen_2#Architecture) 或 Skylake 这样的现代 x86 上,都非常大与 Forth 实现相比;大多数情况下,所有内容可能都会命中 L1 缓存,当然还有 L2。是的,代码局部性通常很好,但是 L2 缓存比典型 6502 的整个内存多,你真的不用担心:P


解释器更关心的是分支预测,但幸运的是 Zen2(以及自 Haswell 以来的英特尔)具有 TAGE 预测器,即使有一个“大中央调度”分支,它也能很好地学习间接分支的模式:Branch Prediction and the Performance of Interpreters - Don’t Trust Folklore