Context
我正在使用 NEON SIMD 指令为 ARM64 编写一些高性能代码,我正在尝试进一步优化。我只使用整数运算,不使用浮点数。此代码完全受 CPU 或内存限制:它不执行任何类型的系统调用或 I/O(文件系统、网络或其他任何内容)。该代码在设计上是单线程的——任何并行性都应该通过使用不同参数从不同的 CPU 调用代码来处理。数据工作集应该足够小,以适合我的CPU的L1 D-cache,如果它溢出一点,它肯定会适合L2,并且有大量的空闲空间。
我的开发环境是配备 M1 处理器的 Apple 笔记本电脑,运行 macOS;因此,性能调查工具的首选是 Apple 的 Instruments。我知道 VTune 有一些更高级的功能,例如自上而下的微架构分析,但显然这不适用于 ARM。
问题
我有一个想法,在高层次上,它的工作原理是这样的:某个函数f(x, y)
可以分解为两个函数g()
and h()
。我可以计算x2 = g(x)
, y2 = g(y)
进而h(x2, y2)
,获得与以下相同的结果f(x, y)
。然而,事实证明我计算f()
多次使用相同输入参数的不同组合。通过将所有这些输入应用到g()
并缓存它们的输出,我可以直接调用h()
使用这些缓存的值并节省一些重新计算的时间g()
-部分f()
.
基准测试
我通过 Google Benchmark 进行微基准测试,确认了基本想法是合理的。如果f()
需要 100 X(其中 X 是任意时间单位),然后每次调用g()
需要 14 X,并调用h()
需要 78 X。虽然打电话的时间更长g()
然后两次h()
而不是f()
,假设我需要计算f(x, y)
and f(x, z)
,通常需要 200 X。我可以计算x2 = g(x)
, y2 = g(y)
and z2 = g(z)
,取 3*14 = 42 X,然后h(x2, y2)
and h(x2, z2)
,取 2*78 = 156 X。总共,我花费了 156 + 42 = 198 X,这已经少于 200 X,对于更大的示例,节省的费用将增加,最多可达 22%,因为这就是少得多h()
成本相比f()
(假设我计算h()
比g()
)。这将显着加快我的应用程序的速度。
我继续在一个更现实的例子上测试这个想法:我有一些代码可以做很多事情,加上 3 次调用f()
它们之间使用相同的两个参数的组合。所以,我将 3 个调用替换为f()
通过 2 次调用g()
以及 3 次致电h()
。上面的基准测试表明这应该将执行时间减少 3*100 - 2*14 - 3*78 = 38 X。但是,对修改后的代码进行基准测试表明执行时间增加约 700 倍!
我尝试将每个呼叫替换为f()
分别拨打 2 次g()
其论点并呼吁h()
。这应该会使执行时间增加 2*14 + 78 - 100 = 6 X,但执行时间却增加了 230 X(并非巧合,大约是 700 X 的 1/3)。
使用 Apple Instruments 的性能计数器结果
为了给讨论带来一些数据,我使用 CPU 计数器模板在 Apple Instruments 下运行了这两个代码,监视了一些我认为可能相关的性能计数器。
作为参考,原始代码执行时间为 7.6 秒(仅考虑迭代次数乘以每次迭代的执行时间,即忽略 Google Benchmark 开销),而新代码执行时间为 9.4 秒;即相差 1.8 秒。两个版本都使用完全相同的迭代次数并处理相同的输入,产生相同的输出。该代码在 M1 的性能核心上运行,我假设该核心以其最大 3.2 GHz 时钟速度运行。
Parameter |
Original code |
New code |
Total cycles |
22,199,155,777 |
27,510,276,704 |
MAP_DISPATCH_BUBBLE |
78,611,658 |
6,438,255,204 |
L1D_CACHE_MISS_LD |
892,442 |
1,808,341 |
L1D_CACHE_MISS_ST |
2,163,402 |
4,830,661 |
L1I_CACHE_MISS_DEMAND |
2,620,793 |
7,698,674 |
INST_SIMD_ALU |
79,448,291,331 |
78,253,076,740 |
INST_SIMD_LD |
17,254,640,147 |
16,867,679,279 |
INST_SIMD_ST |
14,169,912,790 |
14,029,275,120 |
INST_INT_ALU |
4,512,600,211 |
4,379,585,445 |
INST_INT_LD |
550,965,752 |
546,134,341 |
INST_INT_ST |
455,541,070 |
455,298,056 |
INST_ALL |
119,683,934,968 |
118,972,558,207 |
MAP_STALL_DISPATCH |
6,307,551,337 |
5,470,291,508 |
SCHEDULE_UOP |
116,252,941,232 |
113,882,670,763 |
MAP_REWIND |
16,293,616 |
11,787,119 |
FLUSH_RESTART_OTHER_NONSPEC |
58,616 |
90,955 |
FETCH_RESTART |
27,417,457 |
28,119,690 |
BRANCH_MISPRED_NONSPEC |
432,761 |
465,697 |
L1I_TLB_MISS_DEMAND |
754,161 |
1,492,705 |
L2_TLB_MISS_INSTRUCTION |
485,702 |
1,217,474 |
MMU_TABLE_WALK_INSTRUCTION |
486,812 |
1,219,082 |
BRANCH_MISPRED_NONSPEC |
377,750 |
440,382 |
INST_BRANCH |
1,194,614,553 |
1,151,040,641 |
仪器不允许我将所有这些计数器添加到同一运行中,因此某些结果来自不同的运行。然而,由于代码是完全确定性的并且运行相同次数的迭代,因此运行之间的任何差异都应该只是随机噪声。
EDIT:在使用 Instruments 时,我发现一个性能计数器在原始代码和新代码之间具有截然不同的值,即MAP_DISPATCH_BUBBLE
。仍在研究它的含义,它是否可以解释我所看到的问题,以及如何解决这个问题。
EDIT 2:我决定在我可以访问的其他 ARM 处理器(Cortex-X2 和 Cortex-A72)上测试此代码。在 Cortex-X2 上,两个版本的性能相同,而在 Cortex-A72 上,新代码的性能略有提高(约 1.5%)。所以我比以往任何时候都更倾向于相信我遇到了 M1 前端瓶颈。
假设和数据分析
之前遇到过此代码库的性能问题,一些想法浮现在脑海中:
-
内存对齐:SIMD 代码有时对内存对齐很敏感,特别是对于内存绑定代码,我怀疑我的代码可能是这样。但是,添加或删除
__attribute__((aligned(64)))
没有什么区别,所以我不认为是这样。
-
D 高速缓存未命中:新代码分配一些新数组来缓存输出
g()
,因此可能会导致更多缓存未命中。事实上,L1 D 缓存未命中(加载 + 存储)比原始代码多了 360 万次。然而,正如我在开始时提到的,工作集很容易适合 L2。假设 10 个周期的 L2 缓存未命中成本,则仅为 3600 万个周期。在 3.2 GHz 下,该时间仅为 1.1 ms,即
-
指令缓存未命中:类似的情况:有额外的 510 万次 L1 I-cache 未命中,但以 10 个周期的成本计算,我们看到的是 1.6 毫秒,同样小于观察到的性能差异的 0.1%。
-
内联/展开:我在代码上采用了积极的内联和循环展开,以及 LTO 和 Unity 构建,因为性能是第一优先级,代码大小无关紧要(除非它通过 I-cache 未命中等方式影响性能)。我考虑了新代码可能会不太积极地内联/展开的可能性,因为编译器会采用某种启发式方法来获得最大代码大小。这可能会导致执行更多指令,例如循环的比较/分支,以及
CALL
/RET
以及函数调用的函数序言/尾声。然而,该表显示新代码执行的每种指令要少一些(正如我所期望的),当然,总共(INST_ALL
).
不知何故,原始代码只是实现了更高的IPC,我不知道为什么。另外,需要明确的是:两个代码使用相同的算法执行相同的操作。我所做的基本上是代码f()
(一堆对其他子例程的函数调用)之间g()
and h()
.
问题
这让我想到了我的问题:什么可能使新代码比旧代码运行得慢?我还可以在 Instruments 中查看哪些其他性能计数器来深入了解此问题?
除了这个具体问题的答案之外,我还在寻找有关如何在未来解决类似问题的一般建议。我找到了一些关于调试性能问题的书籍,但它们通常分为两个阵营。第一个仅描述了我熟悉的分析过程:找出哪些函数执行时间最长并对其进行优化。第二种是以书籍为代表,例如系统性能:企业和云 https://www.brendangregg.com/systems-performance-2nd-edition-book.html and 每台计算机性能书籍 https://rads.stackoverflow.com/amzn/click/com/1482657759,并且更接近我正在寻找的东西。然而,他们关注系统级问题,如 I/O、内核调用等;我编写的代码类型受 CPU 限制,也可能受内存限制,有很多机会转换为 SIMD,并且不与外界交互。基本上,我想知道如何使用分析器和 CPU 性能计数器(周期计数器、缓存未命中、按 ALU、内存等类型执行的指令)设计有意义的实验,以解决我的代码的此类性能问题当它们出现时。