高效计算汉明权重

Efficiently calculate hamming weight

我使用的是 apple m1 处理器。

我想做的是有效地计算 rust 中大型 char 数组中的 1 位。我查找了 arm neon 指令,我想我可以通过 cnt 指令(每 8 位块计数 1)然后使用 addv 来添加每个 8 位块。

首先我想我会输入 64 位 uint。

fn aarch64_hamming_weight(inp: u64) -> u64 {
    let mut outp: u64;
    unsafe {
        asm!(
            "dup v0.2d, {input}",
            "cnt v0.16b, v0.16b",
            "addv b1, v0.16b",// this adds all the bytes of the vector, how do i get the result?!
            //"fmov {output2}, b1",
            "fmov {output}, v0.d[1]",
            input = in(reg) inp,
            output = out(reg) outp,
        )
    }

    return outp;
}

这几乎可以工作,但如果您的数字大于 8 位,则结果不太正确; 225的二进制数是4 425的二进制数是260

所以

  1. 我需要一种获取 addv 输出的方法
  2. 我需要一种方法让它与任意字符数组一起工作(这需要一个循环,一次提取 128 位)

当我开始写答案时,我认为它会表明内联 asm 完全没有意义,而 RustC 会很好地向量化 u8.count_ones()。不幸的是,情况并非如此,但如果您要编写一个完整的循环,您应该只使用 asm,而不是一次 u64 一个。 (可能仍然是内联汇编,但写一个完整的函数可能是合理的。)

如果 Rust 具有 ARM/AArch64 的 SIMD 内在函数,那将是比内联 asm 更好的选择,并且绝对值得使用。


为了对整个数组进行计数,您不想将每个 cnt 结果分别缩减为标量,尤其是不要一直缩减到 general-purpose 整数寄存器。特别是一次 u64 而不是完整的 128 位。添加到您只在最后减少的计数向量中。

clang/LLVM 知道如何 auto-vectorize 在 C 中对数组进行 __builtin_popcount() 弹出计数,所以我希望 LLVM-based RustC 对 AArch64 没问题。对于 u64 来说还可以,但不幸的是 u8 就很糟糕了。如果您可以 安全地 u64 跨度指向您的数据(或引用或 Rust 做的事情?),那么您可以获得很大一部分的好处,而且没有脆弱性内联汇编并且在某种程度上,未来的编译器版本有望改进。

pub fn hamming_weight_arr(inp: &[u64]) -> u64 {
    let mut sum : u64 = 0;
    for v in inp {
        sum += v.count_ones() as u64;  // maybe not terrible but a lot of hsumming.
        // doing ldp q2, q3 to load 32 bytes per iteration
    }
    return sum;
}

使用 -O --target aarch64-unknown-linux-gnuhttps://godbolt.org/z/7qP7cK6bv 上编译(默认 -C --opt-level=3)。

... some setup
.LBB1_5:                                 // inner loop
        ldp     q2, q3, [x8, #-16]       // load pair of 16-byte vectors = unroll by 4x u64
        add     x8, x8, #32              // pointer increment by 32 bytes
        subs    x12, x12, #4
        cnt     v2.16b, v2.16b
        cnt     v3.16b, v3.16b
        uaddlp  v2.8h, v2.16b            // hsum byte pairs to u16 halfwords
        uaddlp  v3.8h, v3.16b
        uaddlp  v2.4s, v2.8h             // hsum u16 pairs to u32 words
        uaddlp  v3.4s, v3.8h
        uadalp  v0.2d, v2.4s          // sum u32 pairs into accumulator u64 vectors (doublewords)
        uadalp  v1.2d, v3.4s
        b.ne    .LBB1_5
        add     v0.2d, v1.2d, v0.2d        // combine pair of accumulators
        cmp     x10, x11
        addp    d0, v0.2d                  // and reduce to one 64-bit
        fmov    x8, d0
        b.eq    .LBB1_9
.LBB1_7:
        add     x10, x0, x1, lsl #3
.LBB1_8:
        ldr     d0, [x9], #8               // scalar cleanup loop, one u64 at a time
        cmp     x9, x10
        cnt     v0.8b, v0.8b
        uaddlv  h0, v0.8b                  // slow instruction, as Jake mentioned.  Or at least high latency
        fmov    w11, s0
        add     x8, x11, x8
        b.ne    .LBB1_8
.LBB1_9:
        mov     x0, x8
        ret

您认为 sum: u32 会有所帮助,因为它需要较少的循环内部加宽。 (如果您有可能溢出的巨大数组,请使用外部循环)。但实际上,我认为 RustC 仍然扩大到 64 位,但随后做了更多工作将这些计数截断为 32 位。几乎可以肯定是 missed-optimization。 (也许是一种围绕 x86 psadbw 构建的策略,它可以一步将字节求和到 u64 块中;LLVM auto-vectorizes popcount with pshufb 以及 x86 上的 AVX2。)

而且您认为对 u8 数组做同样的事情应该矢量化为相同的代码,只是一些额外的标量清理,对吧? 嗯,它应该,但实际上它仍然只能像 LLVM 喜欢做的那样展开 4 个,但那是 4 个 u8 个元素,内部循环变成了完全垃圾:

// &[u8] version  inner loop is a disaster

LBB2_5:
        ldurb   w12, [x8, #-2]            // scalar zero-extending byte load
        subs    x11, x11, #4
        ldrb    w13, [x8]                 // scalar sign-extending byte load
        fmov    d2, x12                   // copy it into vector reg
        ldurb   w12, [x8, #-1]
        fmov    d3, x13
        ldrb    w13, [x8, #1]
        add     x8, x8, #4
        mov     v2.d[1], x12              // get two more bytes into the high 64 bits of a vector
        mov     v3.d[1], x13
        cnt     v2.16b, v2.16b           // same cnt / uaddlp sequence as before
        cnt     v3.16b, v3.16b
        uaddlp  v2.8h, v2.16b
        uaddlp  v3.8h, v3.16b
        uaddlp  v2.4s, v2.8h
        uaddlp  v3.4s, v3.8h
        uadalp  v0.2d, v2.4s
        uadalp  v1.2d, v3.4s
        b.ne    .LBB2_5

所以它正在矢量化 (v as u64).count(),并使用它不知道如何优化的罐装食谱。 (例如,如果 cnt 结果为零,除了每个 64 位块的低字节外,uaddlp 加宽是没有意义的,他们可以只对 64 位块使用垂直 add。)


与您从 C 编译器获得的来自 https://github.com/WojciechMula/sse-popcount/ 的手动矢量化 ARM 代码进行比较。如果 ARM Neon 代码与同一存储库中的 x86 代码一样 well-tuned,那么就编译器的最终 asm 输出而言,这就是您应该瞄准的目标,无论您到达那里。

我猜想一个只将 per-byte 计数加宽到 16 位的内部循环会很好,运行 直到在不溢出 [= 的情况下可能的迭代次数32=] 和 +=16 从在输入它的一对字节中看到 all-ones。即 65535/16 向下舍入 = 4095 个向量,然后扩展到 64 位块。

Mula's version does only 31 inner loop iterations, accumulating into 8-bit elements with vaddq_u8. But uadalp v0.16b, v2.8h horizontal-pair u8 字节到 u16 半字元素的累积不适用于 32 位 NEON,仅适用于 AArch64 ASIMD。

关键是要在 inner-most 循环中做最少的工作,理想情况下每个 cnt 结果向量只使用一条廉价指令。 如果你可以在积累时免费获得一些扩展(或者便宜到不会成为瓶颈),那么执行就会在内部循环中停留更长的时间而不会溢出。 (并且意味着外循环中后面的水平缩减步骤更便宜。)

uadalp 在某些 CPU 上与 pure-vertical add 的性能有些不同,但很可能值得使用。 Cortex-A57 optimization guide 表示它有 4 (1) 个周期延迟,1/时钟吞吐量。 (1) 部分是目标操作数的累积延迟;它允许在 horizontal-pair 添加源元素之后,从相同类型的先前操作进行延迟转发。因此在使用 sum += pairsuadalp 的循环中,loop-carried 依赖链只有 1 个循环长。这是理想的。

整数向量上的常规 add 是 3 周期延迟,2/时钟吞吐量,因此它具有更好的吞吐量但创建了 3 周期 loop-carried 依赖链。 (并且没有完成水平累加工作的一步,并且会更快溢出,因为它只使用 8 位和。)

在 Cortex-A57 上,cnt 也只有 1/clock 吞吐量,因此 uadalp 的 1/clock 吞吐量不是整体瓶颈。除非他们竞争同一个执行端口。 uadalp 在 F1 上运行,SIMD-integer add 在 F0/F1 上运行,cnt 在 F0/F1 上运行。因此,无论哪种方式,加法操作都需要窃取一些 cnt 吞吐量,问题就变成了当 F1 有一堆未来的 uadalp 操作时,cnt 是否可以有效地调度到大多数端口 F0排队。 (在尚未准备好的数据上;out-of-order exec 向前看。在大多数 CPU 上,操作作业被安排到端口,因为它们 rename/allocate 从 front-end 到 back-end。 CPU 不知道什么订单数据将准备就绪,但它可以看到端口的队列长度不同。

可以做到(伪代码)

// Probably not a good idea
c1 = cnt(load1)
c2 = cnt(load2)
c1 += c2    // plain SIMD vertical add
uadalp accumulator, c1

这意味着循环中只有一个 uadalp,以防出现吞吐量瓶颈,同时仍然仅将 uadalp 用于累加器,从而通过累加器保持 loop-carried 依赖链短的。 (假设其他 AArch64 CPUs 也为累积指令做早期转发)。

并且它使得在一次迭代中短的独立依赖链略长(从加载到积累的输入),而不是将其保持为两个独立的依赖链。)Low-end ARM CPUs通常具有非常有限的 out-of-order 执行能力(小的调度程序缓冲区)来找到 instruction-level 跨循环迭代的并行性和重叠工作。保持非 loop-carried 的 dep 链较短使得 CPU 更容易。对于像 Cortex-A53 这样的 in-order AArch64 来说,它完全糟透了,除非你展开了很多。