我在蘋果 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
所以
- 我需要一種方法來獲取 addv 的輸出
- 我需要一種方法來處理任意字符陣列(這需要一個回圈,一次拉 128 位)
uj5u.com熱心網友回復:
當我開始寫答案時,我認為它會表明行內 asm 完全沒有意義,而 RustC 將在矢量化方面做得很好u8.count_ones()
。不幸的是,情況并非如此,但是如果您要撰寫一個完整的回圈,而不是一次一個,則應該只使用 asm u64
。(可能仍然是行內匯編,但撰寫一個完整的函式可能是合理的。)
如果 Rust 具有用于 ARM/AArch64 的 SIMD 內在函式,那將是比行內 asm 更好的選擇,并且絕對值得使用。
為了計算整個陣列,您不希望將每個cnt
結果分別減少為標量,尤其是不要一直減少到通用整數暫存器。尤其是u64
一次不是一個,而不是一個完整的 128 位。添加到最后只減少的計數向量中。
clang/LLVM 知道如何__builtin_popcount()
在 C 中的陣列上對 C 中的 popcount 進行自動矢量化,所以我希望基于 LLVM 的 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;
}
在https://godbolt.org/z/7qP7cK6bv上編譯-O --target aarch64-unknown-linux-gnu
(使用默認值-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 位。幾乎可以肯定是錯過了優化。(也許是一種圍繞 x86 構建的策略psadbw
,它可以一步將位元組求和到 u64 塊中;LLVM 使用 pshufb 自動矢量化 popcount 以及 x86 上的 AVX2。)
你會認為對u8
陣列做同樣的事情應該向量化到相同的代碼,只需要一些額外的標量清理,對吧? 好吧,它應該,但實際上它仍然只展開 4,就像 LLVM 喜歡做的那樣,但那是 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()
,并使用它不知道如何優化的罐頭食譜。(例如,如果結果為零,除了每個 64 位塊的低位元組之外,uaddlp
擴大是沒有意義的,他們可以只使用64 位塊的垂直。)cnt
add
與來自https://github.com/WojciechMula/sse-popcount/的手動矢量化 ARM 代碼的 C 編譯器進行比較。如果 ARM Neon 代碼與同一個 repo 中的 x86 代碼一樣經過良好調整,那么就編譯器的最終 asm 輸出而言,這就是您應該瞄準的目標,但是您可以到達那里。
我猜想一個只將每位元組計數擴大到 16 位的內部回圈會很好,運行到可能的迭代次數,而不會因為看到饋送的位元組對中的全一u16
而溢位 =16
進去。即 65535/16 向下舍入 = 4095 個向量,然后擴展到 64 位塊。
Mula 的版本只進行了 31 次內回圈迭代,累積成 8 位元素vaddq_u8
。但是uadalp v0.16b, v2.8h
u8 位元組到 u16 半字元素的水平對累積不適用于 32 位 NEON,僅適用于 AArch64 ASIMD。
關鍵是在最內層回圈中做最少的作業,理想情況下每個cnt
結果向量只使用一條廉價指令。 如果您可以在積累時免費獲得一些擴展(或者足夠便宜而不會成為瓶頸),那么可以讓執行在內部回圈中停留更長時間而不會發生溢位。(并且意味著外回圈中后面的水平縮減步驟更便宜。)
uadalp
在某些 CPU 上與純垂直的性能有些不同add
,但很可能值得使用。Cortex-A57 優化指南說它有 4 (1) 個周期延遲,1/時鐘吞吐量。(1) 部分是目標運算元的累積延遲;它允許在源元素的水平對添加已經發生之后,從相同型別的先前操作進行延遲轉發。所以在使用sum = pairs
withuadalp
的回圈中,回圈攜帶的依賴鏈只有 1 個回圈長。這是理想的。
整數向量上的規則add
是 3 個周期延遲,2 個/時鐘吞吐量,因此它具有更好的吞吐量,但創建了一個 3 周期回圈攜帶的依賴鏈。(并且沒有完成水平累加作業的步驟,并且會更快溢位,因為它只使用 8 位和。)
在 Cortex-A57 上,cnt
也只有 1/clock 吞吐量,因此uadalp
1/clock 吞吐量不是總體瓶頸。除非他們競爭同一個執行埠。 uadalp
在 F1 上運行,SIMD 整數add
在 F0/F1 上cnt
運行,在 F0/F1 上運行。所以無論哪種方式,加法操作都需要竊取一些cnt
吞吐量,問題就變成了cnt
,當 F1 有一堆未來的uadalp
操作排隊時,是否可以有效地調度到大多數埠 F0。(在尚未準備好的資料上;亂序 exec 向前看。在大多數 CPU 上,操作被調度到埠,因為它們從前端重命名/分配到后端。CPU 不知道什么訂單資料將準備好,但它可以看到埠的佇列長度不同。
有可能做(偽代碼)
// Probably not a good idea
c1 = cnt(load1)
c2 = cnt(load2)
c1 = c2 // plain SIMD vertical add
uadalp accumulator, c1
這意味著只有一個uadalp
在回圈中,以防出現吞吐量瓶頸,同時仍然只使用uadalp
累加器,這使得通過累加器的回圈攜帶的依賴鏈保持較短。(假設其他 AArch64 CPU 也對累積指令進行早期轉發)。
并且它使一個迭代內的短獨立依賴鏈稍長(從加載到累加的輸入),而不是將其保持為兩個獨立的 dep 鏈。)低端 ARM CPU 通常具有非常有限的亂序執行能力(小型調度程式緩沖區)來查找指令級并行性和跨回圈迭代的重疊作業。保持非回圈攜帶的 dep 鏈較短,這對 CPU 來說更容易。除非你展開很多,否則它對于像 Cortex-A53 這樣的有序 AArch64 來說非常糟糕。
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/468266.html