我在 Intel i3-N305 3.8GHz 和 AMD Ryzen 7 3800X 3.9GHz PC 上运行了使用 gcc-13 (https://godbolt.org/z/qq5WrE8qx) 编译的相同二进制文件。此代码使用VCL库(https://github.com/vectorclass/version2):
int loop_vc_nested(const array<uint8_t, H*W> &img, const array<Vec32uc, 8> &idx) {
int sum = 0;
Vec32uc vMax, iMax, vCurr, iCurr;
for (int i=0; i<H*W; i+=W) {
iMax.load(&idx[0]);
vMax.load(&img[i]);
for (int j=1; j<8; j++) {
iCurr.load(&idx[j]);
vCurr.load(&img[i+j*32]);
iMax = select(vCurr > vMax, iCurr, iMax);
vMax = max(vMax, vCurr);
}
Vec32uc vMaxAll{horizontal_max(vMax)};
sum += iMax[horizontal_find_first(vMax == vMaxAll)];
}
return sum;
}
完整的基准源在这里:https://github.com/pauljurczak/simd-benchmarks/blob/main/main-5-vcl-eve.cpp。时间安排如下:
Ubuntu 22.04.3 LTS on AMD Ryzen 7 3800X 8-Core Processor
gcc v13.1 __cplusplus=202100
loop_vc_nested(): 3.597 3.777 [us] 108834
Ubuntu 23.10 on Intel(R) Core(TM) i3-N305
gcc v13.1 __cplusplus=202100
loop_vc_nested(): 11.804 11.922 [us] 108834
出现了 3.2 倍的意外减速。 AFAIK,这些 CPU 对于单线程程序具有类似的 SIMD 功能。 7-zip 基准测试的性能非常接近。为什么差距这么大?
这是
perf
的输出。 AMD 锐龙 7 3800X:
3,841.61 msec task-clock # 1.000 CPUs utilized
20 context-switches # 5.206 /sec
0 cpu-migrations # 0.000 /sec
2,191 page-faults # 570.333 /sec
14,909,837,582 cycles # 3.881 GHz (83.34%)
3,509,824 stalled-cycles-frontend # 0.02% frontend cycles idle (83.34%)
9,865,497,290 stalled-cycles-backend # 66.17% backend cycles idle (83.34%)
42,856,816,868 instructions # 2.87 insn per cycle
# 0.23 stalled cycles per insn (83.34%)
1,718,672,677 branches # 447.383 M/sec (83.34%)
2,409,251 branch-misses # 0.14% of all branches (83.29%)
英特尔 i3-N305:
12,015.18 msec task-clock # 1.000 CPUs utilized
57 context-switches # 4.744 /sec
0 cpu-migrations # 0.000 /sec
2,196 page-faults # 182.769 /sec
45,432,594,158 cycles # 3.781 GHz (74.97%)
42,847,054,707 instructions # 0.94 insn per cycle (87.48%)
1,714,003,765 branches # 142.653 M/sec (87.48%)
4,254,872 branch-misses # 0.25% of all branches (87.51%)
TopdownL1 # 0.2 % tma_bad_speculation
# 45.5 % tma_retiring (87.52%)
# 53.8 % tma_backend_bound
# 53.8 % tma_backend_bound_aux
# 0.5 % tma_frontend_bound (87.52%)
编译器选项:
-O3 -Wno-narrowing -ffast-math -fno-trapping-math -fno-math-errno -ffinite-math-only -march=alderlake
来自 i3-N305 上
perf stat -d
的其他缓存使用信息:
15,615,324,576 L1-dcache-loads # 1.294 G/sec (54.50%)
<not supported> L1-dcache-load-misses
60,909 LLC-loads # 5.048 K/sec (54.50%)
5,231 LLC-load-misses # 8.59% of all L1-icache accesses (54.50%)
总结评论:你的i3-N305是Alder Lake-N系列。与型号中带有 N 的早期 Celeron/Pentium CPU 一样,这些内核都是低功耗 Silvermont 系列。在本例中,Gracemont 是来自成熟的 Alder Lake 芯片的 E 核心。 (它比 Tremont 或尤其是像 Goldmont Plus 这样的前代产品要强大得多。)而且它有 AVX2+FMA,我想这就是它作为 i3 出售的理由。 https://chipsandcheese.com/2021/12/21/gracemont-revenge-of-the-atom-cores/ 是对 CPU 微架构的非常好的深入探讨,其中包含一些带宽和延迟的微基准(作为一部分) i9-12900k,IDK,如果 i3-N 系列中的互连或 L3 有所不同。)
您的 Zen 是 Zen 2,具有 256 位 ALU。 Gracemont 上的chipsandcheese 深入研究中的许多基准测试都将其与 Zen 2 进行了比较。
最重要的因素是:
矢量 ALU 和内存端口为 128 位宽,每条 256 位指令解码为 2 uops。 (如 Zen 1 和 Bulldozer 系列)。因此,当运行主要是带有一点标量开销的向量指令的代码时,每个时钟的 uops 大约是 IPC 的两倍。当每个负载仅为 128 位时,2/时钟负载带宽仅达到一半。
select
应该编译为 vpblendvb
。不幸的是,Gracemont 上的速度非常慢,请参阅https://uops.info/ - YMM 版本为 8 uops,测得的吞吐量为每 3.86 个周期 1 个。 (或者令人惊讶的是,内存源需要 3.2 个周期而不是寄存器。)Zen 系列将 4 操作数 vpblendvb
作为单个 uop 运行(甚至可以选择端口)。
8 uops 本质上会导致相同 uops/时钟吞吐量的 IPC 较低,但它也可能会导致前端停顿并减少 uops/时钟。
您可以尝试与
vpand
/ vpandn
/ vpor
手动混合,但 clang 几乎肯定会将这些内在函数“优化”为 vpblendvb
,除非 clang trunk 的 -mtune=gracemont
或 -march=gracemont
知道这速度很慢那个大主教。 MSVC,或者经典的 ICC,对于内在函数来说更加字面化。 GCC 确实优化了一些,我还没有检查它在这里做了什么。
您的实际问题可以通过其他方式完成,例如使用索引解包数据,如 寻找有效的函数以使用库在 SIMD 向量中查找最大元素的索引,因此 max
u16
元素包含数据和索引。 (索引可以来自 idx = _mm256_add_epi8(idx, _mm256_set1_epi8(32));
,而不是加载。
也许超过 256 个字节的内部循环可以完全展开,因此您有 8 个寄存器保存索引数据。)
因为无论如何你可能都想使用改进的缩减,所以更早解包可以节省一些清理工作,并且你的循环只有 8 个向量。
对于索引的总和,我想获得匹配项的第一次出现很重要?因此,您需要反转索引,以便在相等数据的平局时,打包为 u16 的 data:index 的最大值会选择较早的索引。无论如何,这就是我们想要的清理工作,将使用
vphminposuw
。
或者考虑分支策略,比如找到最大值,然后在数组中搜索第一个匹配项。 (比较/移动掩码又名
to_bits(curr == bcast_max)
,如果非零,则返回 tzcnt(mask)
)。您永远不需要加载索引数据向量,并且早期匹配可以减少工作量。 (但它可能会错误地预测哪个可能更糟糕;仍然值得一试。但是,对依赖于正确分支预测的有用的微基准测试非常困难 - 微基准可以学习一种模式。或者,如果你让它完全随机,它的预测会比真实数据更糟糕分布。)
尽管如此,只需 8 个数据向量,第二遍循环就可以在没有负载的情况下完全展开。第一遍可以将数据留在寄存器中。 (但它也必须完全展开,因此代码大小很大。) Gracemont 没有 uop 缓存;显然它的解码器的吞吐量管理得很好。