不是popcorn,更不是pornhub

本文感谢 Amnesia 赞助

最近群聊里传了一个面试题

实现统计1的个数(汉明权重 hammingWeight),使用popcnt的算法对硬件不友好,有无绕过的思路

显然这个哥们的第一个实现是

int hammingWeight_popcnt(uint64_t n) {
  return __builtin_popcountll(n);
}

当然c++20也支持 https://en.cppreference.com/w/cpp/numeric/popcount

一行,觉得自己很帅,当然面试官不喜欢,提示不要用popcnt,所谓的对硬件不友好指的应该是部分硬件没有这个指令

又或者性能原因?难道GPU上的popcnt性能很差?按下不表

直接贴实现

int hammingWeight(uint64_t n) {
    int ret = 0;
    while (n) {
        n &= n - 1;
        ret++;
    }
    return ret;
}

其实开启 O2 加上 -march=native,大家都会生成相同的popcnt, 早在2016年Lemire大哥就发现了

https://lemire.me/blog/2016/05/23/the-surprising-cleverness-of-modern-compilers/

附上llvm检测的代码 https://github.com/llvm-mirror/llvm/blob/f36485f7ac2a8d72ad0e0f2134c17fd365272285/lib/Transforms/Scalar/LoopIdiomRecognize.cpp#L960

只开O2可能保守场景不会生成popcnt

如果不用popcnt,代码的性能和popcnt差距大吗?或者说,popcnt有危害吗?比如延迟高?

直接上llvm-mca分析 https://godbolt.org/z/odox8Wdr5

首先插入一个简单粗暴的教程,如何看懂llvm-mca https://llvm.org/docs/CommandGuide/llvm-mca.html

就是机器码分析器,模拟机器码执行效果,我们不用装llvm-mca,直接用godbolt内置的工具。代码已经生成好了

直接贴popcnt代码的结果吧

Iterations:        100
Instructions:      200
Total Cycles:      57
Total uOps:        200

Dispatch Width:    6
uOps Per Cycle:    3.51
IPC:               3.51
Block RThroughput: 0.5


Instruction Info:
[1]: #uOps
[2]: Latency
[3]: RThroughput
[4]: MayLoad
[5]: MayStore
[6]: HasSideEffects (U)

[1]    [2]    [3]    [4]    [5]    [6]    Instructions:
 1      1     0.25                        popcnt	rax, rdi
 1      5     0.50                  U     ret


Resources:
[0]   - Zn3AGU0
[1]   - Zn3AGU1
[2]   - Zn3AGU2
[3]   - Zn3ALU0
[4]   - Zn3ALU1
[5]   - Zn3ALU2
[6]   - Zn3ALU3
[7]   - Zn3BRU1
[8]   - Zn3FPP0
[9]   - Zn3FPP1
[10]  - Zn3FPP2
[11]  - Zn3FPP3
[12.0] - Zn3FPP45
[12.1] - Zn3FPP45
[13]  - Zn3FPSt
[14.0] - Zn3LSU
[14.1] - Zn3LSU
[14.2] - Zn3LSU
[15.0] - Zn3Load
[15.1] - Zn3Load
[15.2] - Zn3Load
[16.0] - Zn3Store
[16.1] - Zn3Store


Resource pressure per iteration:
[0]    [1]    [2]    [3]    [4]    [5]    [6]    [7]    [8]    [9]    [10]   [11]   [12.0] [12.1] [13]   [14.0] [14.1] [14.2] [15.0] [15.1] [15.2] [16.0] [16.1] 
0.33   0.33   0.34   0.50   0.33   0.33   0.34   0.50    -      -      -      -      -      -      -     0.33   0.33   0.34   0.33   0.33   0.34    -      -     

Resource pressure by instruction:
[0]    [1]    [2]    [3]    [4]    [5]    [6]    [7]    [8]    [9]    [10]   [11]   [12.0] [12.1] [13]   [14.0] [14.1] [14.2] [15.0] [15.1] [15.2] [16.0] [16.1] Instructions:
 -      -      -      -     0.33   0.33   0.34    -      -      -      -      -      -      -      -      -      -      -      -      -      -      -      -     popcnt	rax, rdi
0.33   0.33   0.34   0.50    -      -      -     0.50    -      -      -      -      -      -      -     0.33   0.33   0.34   0.33   0.33   0.34    -      -     ret
warning: found a return instruction in the input assembly sequence.
note: program counter updates are ignored.

先记下这几个数字

Iterations:        100
Instructions:      200
Total Cycles:      57
Total uOps:        200

Dispatch Width:    6
uOps Per Cycle:    3.51
IPC:               3.51
Block RThroughput: 0.5

重点参数 是IPC, uOps Per Cycle, 和 Block RThroughput (Block Reciprocal Throughput).

  • IPC就是模拟的总指令数字除以总cycle数 一般这个就表示吞吐了 Instructions / Total Cycles 显然这个值越高越好

  • Block RThroughput (Block Reciprocal Throughput) 就是Block Throughput(每个cycle能运行几次block)的倒数 就是 Total Cycles / Iterations 每次运行能用几个cycle的意思,显然,这个值越低越好

  • Instructions / Iterations 代表每次迭代能执行几次指令 显然 Instructions / Iterations / Block RThroughput = IPC 这个数比直接算IPC大点(有误差。。。。)你就当他是无影响的最大IPC吧
    • (循环数据引用问题可能导致影响。假设循环展开无影响)
  • uOps Per Cycle,就是模拟的微指令数总和除以总cycle数字 Total uOps/ Total Cycles ,这个和IPC含义差不多,显然这个值越高越好,但是要关注Dispatch Width - uOps Per Cycle 差值, Dispatch Width 表示最大发射指令的并行带宽,相当于资源限制了,uOps Per Cycle表示实际模拟使用的带宽,显然越接近Dispatch Width越说明资源受限制,利用率太高了,相当于CPU高了,需要找到瓶颈来源

  • 剩下的是执行模拟以及在哪里卡了,具体分析可以用-bottleneck-analysis,得本地搞了godbolt貌似不能玩

好了,根据上面的godbolt结果,直接把数据差异对比一下

另外 网上搜到了google的两个实现,把数据补充上 https://godbolt.org/z/9nsczeT5c

int hammingWeightV2(uint64_t n) {
    n -= (n >> 1) & 0x5555555555555555ULL;
    n = ((n >> 2) & 0x3333333333333333ULL) + (n & 0x3333333333333333ULL);
    return (((n + (n >> 4)) & 0xF0F0F0F0F0F0F0FULL)
            * 0x101010101010101ULL) >> 56;
}

这个实现在一些cpu上有问题 type mismatch。不过一般来说和buildin popcnt一样效果

int hammingWeight_popcntV2(uint64_t n) {
    int64_t count = 0;
    asm("popcnt %1,%0" : "=r"(count) : "rm"(n) : "cc");
    return count;
}
实现 编译器版本 Dispatch Width uOps/Cycle IPC Block RThroughput
popcnt gcc 13.2 6 3.67 1.83 1.0
普通实现 gcc 13.2 4 3.94 3.54 2.5
popcnt clang 17.0.1 6 4.59 2.75 1.0
普通实现 clang 17.0.1 6 4.78 3.83 1.7
popcnt v2 gcc 13.2 6 2.14 2.14 1.3
手写SWAR gcc 13.2 6 4.09 3.72 3.8
popcnt v2 clang 17.0.1 6 3.67 1.83 1.0
手写SWAR clang 17.0.1 6 4.09 3.72 3.8

能看出popcnt的Block RThroughput 低,这显然说明性能更好

然后看IPC和uOps/Cycle clang的明显比gcc的要高,但汇编说实话一个两行一个一行,这个没啥比较的意义了

重点和普通实现比,clang生成的汇编要比gcc好一点,Block RThroughput 低 IPC高,且没有特别接近Dispatch Width瓶颈

但说实话就差一个汇编这点差距根本比不出什么。只能大概说一下popcnt的汇编更少,性能更好而已

感觉SWAR这种看起来很屌, 但看mca分析感觉不太行 我跑了个qb压测,但是网站挂了,还需要本地跑一下

https://github.com/wanghenshui/little_bm/blob/dev/hamming_weight/hamming_weight.cc

我的测试结果来看,SWAR性能反而比popcnt要好,即使Block RThroughput 很高,但IPC也很高,性能反而非常好

taskset -c 0  ./hamming_weight
2024-02-03T22:39:35+08:00
Running ./hamming_weight
Run on (16 X 3392.38 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x8)
  L1 Instruction 32 KiB (x8)
  L2 Unified 512 KiB (x8)
  L3 Unified 16384 KiB (x1)
Load Average: 0.01, 0.02, 0.00
-----------------------------------------------------------------------
Benchmark                             Time             CPU   Iterations
-----------------------------------------------------------------------
BM_hammingWeight_popcnt/0          26.6 ns         26.6 ns     26281110
BM_hammingWeight_popcnt/128         265 ns          265 ns      2622989
BM_hammingWeight_popcnt/256         526 ns          526 ns      1332695
BM_hammingWeight_popcnt/512        1048 ns         1048 ns       666562
BM_hammingWeight_popcnt/1024       2096 ns         2095 ns       334434
BM_hammingWeight/0                 80.7 ns         80.7 ns      8689750
BM_hammingWeight/128               1643 ns         1642 ns       447638
BM_hammingWeight/256               3646 ns         3646 ns       195882
BM_hammingWeight/512               8099 ns         8097 ns        85508
BM_hammingWeight/1024             17193 ns        17190 ns        41208
BM_hammingWeightV2/0/0             11.8 ns         11.8 ns     58402778
BM_hammingWeightV2/128              118 ns          118 ns      5951445
BM_hammingWeightV2/256              233 ns          233 ns      3001257
BM_hammingWeightV2/512              463 ns          463 ns      1510925
BM_hammingWeightV2/1024             927 ns          927 ns       758631

不过还需要其他机器测试,我的nuc是r9 6950hx zen3+,性能不错

github CI机器,SWAR和popcnt就差不多了。

Running ./bm_hamming_weight
Run on (4 X 2868.73 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x2)
  L1 Instruction 32 KiB (x2)
  L2 Unified 512 KiB (x2)
  L3 Unified 32768 KiB (x1)
Load Average: 1.98, 0.54, 0.19
-----------------------------------------------------------------------
Benchmark                             Time             CPU   Iterations
-----------------------------------------------------------------------
BM_hammingWeight_popcnt/0          17.5 ns         17.5 ns     39446954
BM_hammingWeight_popcnt/128         173 ns          173 ns      4056917
BM_hammingWeight_popcnt/256         342 ns          342 ns      2051152
BM_hammingWeight_popcnt/512         679 ns          679 ns      1032384
BM_hammingWeight_popcnt/1024       1354 ns         1354 ns       517551
BM_hammingWeight/0                  124 ns          124 ns      5638895
BM_hammingWeight/128               2394 ns         2394 ns       280377
BM_hammingWeight/256               5511 ns         5511 ns       123293
BM_hammingWeight/512              12036 ns        12036 ns        58336
BM_hammingWeight/1024             25711 ns        25710 ns        27149
BM_hammingWeightV2/0/0             17.8 ns         17.8 ns     39494382
BM_hammingWeightV2/128              182 ns          182 ns      3848768
BM_hammingWeightV2/256              360 ns          360 ns      1943778
BM_hammingWeightV2/512              709 ns          709 ns       988825
BM_hammingWeightV2/1024            1418 ns         1418 ns       493319

家人们,需要你们的补充测试,各种机器, 复现代码https://github.com/wanghenshui/little_bm 运行build.sh即可


话说回来,数1到底能干嘛?这里要引入汉明距离 编辑距离相关的概念

简单理解就是查diff 纠错码之类的效果

popcnt的来源 http://www.talkchess.com/forum3/viewtopic.php?t=38521

上个世纪60年代,计算机还属于大型机百花齐放的年代,Control Data Corporation公司的CDC 机器卖的不错,国际象棋也在用这个软件。他们的场景就是棋盘格确认位置,所以实现了popcnt类似的能力,算位置坐标,美国国家安全局(NSA)发现了他们有这个能力,他们的新机器CDC 6000,政府采购并要求加上这个功能,主要是为了类似汉明距离之类的信息统计,相当于变相hash,用来实现校对diff之类的能力,所以也被叫做NSA Instruction (NSA指令)

这个指令也是那个时代的特殊产物把,算力不行并没有高级的hash能力,只能通过数1模拟,后来CPU性能提升渐渐的都不支持了,然后后来部分CPU支持部分CPU不支持,到现代全都捡回来

现在的CPU也有很多不支持popcnt指令,以至于游戏客户端领域会有popcnt patch之类的玩意,给玩家打patch绕过popcnt https://github.com/ogurets/popcnt_emulator

还有什么能用到数1?

指纹?安全领域,这种更多是汉明距离场景的推广

能用到bitmap的地方,不过使用bitmap不一定非得算总数

比如 Hash Array Mapped Tries 结合tries的压缩优点 + bitmap定位槽,

bitmap浪费所以要压缩一下,位运算躲不了数1场景

再比如 Succinct Data Structures terarkdb的memtable用的就这玩意,压缩率高

关于popcnt的信息我就收集到这么多的,大家有其他见解/批评还可以补充一下

另外,跑一下压测代码!看看你的CPU结果是什么样子的

参考

  • https://vaibhavsagar.com/blog/2019/09/08/popcount/ 一些资料汇总在这里搜到的。我一开始是根据群友聊的和关键字搜到hackernews上这篇文章的分享,介绍了背景和部分应用
  • https://abseil.io/fast/9 这里说的话我很赞同,性能测试是个周期性的工作,可能旧的代码有时候快,后面时代/硬件进步,又慢了 还是要具体机器具体分析
  • https://github.com/google/supersonic/blob/master/supersonic/utils/bits.h
  • https://stackoverflow.com/questions/28802692/how-is-popcnt-implemented-in-hardware 这个没看,但感觉现代CPU popcnt代价已经很低了