2. 問題
代碼中沒有浮點或矢量操作,為什么在 JVM x86 平臺生成的機器代碼中會看到 XMM 寄存器?
3. 理論
FPU 和矢量單元在現代 CPU 中無處不在。通常,它們會為 FPU 特定操作提供了備用寄存器。例如,英特爾 x86_64 平臺的 SSE 和 AVX 擴展包含了一組豐富的 XMM、YMM 和 ZMM 寄存器供指令操作。
雖然非矢量指令集與矢量、非矢量寄存器通常不會正交,比如不能在 x86_64 上對 XMM 寄存器執行通用 IMUL,但是這些寄存器仍然提供了一種存儲選項。即使不用于矢量計算,也可以在這些寄存器中存儲數據。
(1) 最極端的情況是把矢量寄存器當緩沖用。
寄存器分配器的任務是在一個特定的編譯單元(比如方法)中獲取程序需要的所有操作數,并為它們分配寄存器——映射到機器實際寄存器。真實程序中,需要的操作數大于機器中可用的寄存器數目。這時寄存器分配器必須把一些操作數放到寄存器以外的某個地方(比如堆棧),也就是說會發生操作數溢出。
x86_64 上有16個通用寄存器(并非每個寄存器都可用)。目前,大多數機器還有16個 AVX 寄存器。發生溢出時,可以不存儲到堆棧而存儲到 XMM 寄存器中嗎?答案是可以。這么做會帶來什么好處?
4. 實驗
看看下面這個簡單的 JMH 基準測試,用一種非常特殊的方式構建基準(簡單起見,這里假設 Java 具備有預處理能力):
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class FPUSpills {
int s00, s01, s02, s03, s04, s05, s06, s07, s08, s09;
int s10, s11, s12, s13, s14, s15, s16, s17, s18, s19;
int s20, s21, s22, s23, s24;
int d00, d01, d02, d03, d04, d05, d06, d07, d08, d09;
int d10, d11, d12, d13, d14, d15, d16, d17, d18, d19;
int d20, d21, d22, d23, d24;
int sg;
volatile int vsg;
int dg;
@Benchmark
#ifdef ORDERED
public void ordered() {
#else
public void unordered() {
#endif
int v00 = s00; int v01 = s01; int v02 = s02; int v03 = s03; int v04 = s04;
int v05 = s05; int v06 = s06; int v07 = s07; int v08 = s08; int v09 = s09;
int v10 = s10; int v11 = s11; int v12 = s12; int v13 = s13; int v14 = s14;
int v15 = s15; int v16 = s16; int v17 = s17; int v18 = s18; int v19 = s19;
int v20 = s20; int v21 = s21; int v22 = s22; int v23 = s23; int v24 = s24;
#ifdef ORDERED
dg = vsg; // 給 optimizer 制造點麻煩
#else
dg = sg; // 只做常規存儲
#endif
d00 = v00; d01 = v01; d02 = v02; d03 = v03; d04 = v04;
d05 = v05; d06 = v06; d07 = v07; d08 = v08; d09 = v09;
d10 = v10; d11 = v11; d12 = v12; d13 = v13; d14 = v14;
d15 = v15; d16 = v16; d17 = v17; d18 = v18; d19 = v19;
d20 = v20; d21 = v21; d22 = v22; d23 = v23; d24 = v24;
}
}
上面的例子中一次會讀寫多對字段。實際上,優化器本身并不會與具體程序綁定。事實上,這就是在 unordered 測試中觀察到的結果:
Benchmark Mode Cnt Score Error Units
FPUSpills.unordered avgt 15 6.961 ± 0.002 ns/op
FPUSpills.unordered:CPI avgt 3 0.458 ± 0.024 #/op
FPUSpills.unordered:L1-dcache-loads avgt 3 28.057 ± 0.730 #/op
FPUSpills.unordered:L1-dcache-stores avgt 3 26.082 ± 1.235 #/op
FPUSpills.unordered:cycles avgt 3 26.165 ± 1.575 #/op
FPUSpills.unordered:instructions avgt 3 57.099 ± 0.971 #/op
上面展示了26對 load-store,實際測試中大致有25對,但是這里沒有25個通用寄存器!從 perfasm 結果中可以看到,優化器會把臨近的 load-store 對合并,減小寄存器壓力:
0.38% 0.28% ↗ movzbl 0x94(%rcx),%r9d
│ ...
0.25% 0.20% │ mov 0xc(%r11),%r10d ; 讀取字段 s00
0.04% 0.02% │ mov %r10d,0x70(%r8) ; 存儲字段 d00
│ ...
│ ... (transfer repeats for multiple vars) ...
│ ...
╰ je BACK
ordered 測試會給優化器制造一點混亂,在存儲前全部加載。上面的結果也印證了這一點:先全部加載,再全部存儲。加載全部完成時寄存器的壓力最大,這時還沒有開始存儲。即便如此,從結果來看與 unordered 差異不大:
Benchmark Mode Cnt Score Error Units
FPUSpills.unordered avgt 15 6.961 ± 0.002 ns/op
FPUSpills.unordered:CPI avgt 3 0.458 ± 0.024 #/op
FPUSpills.unordered:L1-dcache-loads avgt 3 28.057 ± 0.730 #/op
FPUSpills.unordered:L1-dcache-stores avgt 3 26.082 ± 1.235 #/op
FPUSpills.unordered:cycles avgt 3 26.165 ± 1.575 #/op
FPUSpills.unordered:instructions avgt 3 57.099 ± 0.971 #/op
FPUSpills.ordered avgt 15 7.961 ± 0.008 ns/op
FPUSpills.ordered:CPI avgt 3 0.329 ± 0.026 #/op
FPUSpills.ordered:L1-dcache-loads avgt 3 29.070 ± 1.361 #/op
FPUSpills.ordered:L1-dcache-stores avgt 3 26.131 ± 2.243 #/op
FPUSpills.ordered:cycles avgt 3 30.065 ± 0.821 #/op
FPUSpills.ordered:instructions avgt 3 91.449 ± 4.839 #/op
這是因為已經設法把操作數溢出到 XMM 寄存器中,而不是在堆棧上存儲:
3.08% 3.79% ↗ vmovq %xmm0,%r11
│ ...
0.25% 0.20% │ mov 0xc(%r11),%r10d ; 讀取字段 s00
0.02% │ vmovd %r10d,%xmm4 ; < --- FPU 溢出
0.25% 0.20% │ mov 0x10(%r11),%r10d ; 讀取字段 s01
0.02% │ vmovd %r10d,%xmm5 ; < --- FPU 溢出
│ ...
│ ... (讀取更多字段和 XMM 溢出) ...
│ ...
0.12% 0.02% │ mov 0x60(%r10),%r13d ; 讀取字段 s21
│ ...
│ ... (讀取到寄存器) ...
│ ...
│ ------- 讀取完成, 開始寫操作 ------
0.18% 0.16% │ mov %r13d,0xc4(%rdi) ; 存儲字段 d21
│ ...
│ ... (讀寄存器并存儲字段)
│ ...
2.77% 3.10% │ vmovd %xmm5,%r11d : < --- FPU 取消溢出
0.02% │ mov %r11d,0x78(%rdi) ; 存儲字段 d01
2.13% 2.34% │ vmovd %xmm4,%r11d ; < --- FPU 取消溢出
0.02% │ mov %r11d,0x70(%rdi) ; 存儲字段 d00
│ ...
│ ... (取消溢出并存儲字段)
│ ...
╰ je BACK
請注意:這里的確對某些操作數使用了通用寄存器(GPR),但是當所有寄存器被用完時會發生溢出。這里對時機的描述并不確切。看起來先發生了溢出,然后使用 GPR。然而這是一個假象,因為寄存器分配器是在全局進行分配。
(2) 一些寄存器分配器實際執行的是線性分配,提高了 regalloc 的速度與生成代碼的效率。
XMM 溢出延遲似乎是最小的:盡管溢出需要更多指令,但它們的執行效率很高能夠有效彌補流水線的缺陷。通過34條額外指令,大約17條溢出指令對,實際只要求4個額外周期。請注意,按照 4/34 = ~0.11 時鐘/指令 計算 CPI 是不對的,計算結果會超出當前 CPU 處理能力。但是實際帶來的改進是真實的,因為使用了以前沒有用到的執行塊。
沒有參照談效率是毫無意義的。這里用 -XX:-UseFPUForSpilling 讓 Hotspot 禁用 FPU 溢出,這樣可以了解 XMM 溢出帶來的好處:
Benchmark Mode Cnt Score Error Units
# Default
FPUSpills.ordered avgt 15 7.961 ± 0.008 ns/op
FPUSpills.ordered:CPI avgt 3 0.329 ± 0.026 #/op
FPUSpills.ordered:L1-dcache-loads avgt 3 29.070 ± 1.361 #/op
FPUSpills.ordered:L1-dcache-stores avgt 3 26.131 ± 2.243 #/op
FPUSpills.ordered:cycles avgt 3 30.065 ± 0.821 #/op
FPUSpills.ordered:instructions avgt 3 91.449 ± 4.839 #/op
# -XX:-UseFPUForSpilling
FPUSpills.ordered avgt 15 10.976 ± 0.003 ns/op
FPUSpills.ordered:CPI avgt 3 0.455 ± 0.053 #/op
FPUSpills.ordered:L1-dcache-loads avgt 3 47.327 ± 5.113 #/op
FPUSpills.ordered:L1-dcache-stores avgt 3 41.078 ± 1.887 #/op
FPUSpills.ordered:cycles avgt 3 41.553 ± 2.641 #/op
FPUSpills.ordered:instructions avgt 3 91.264 ± 7.312 #/op
上面的結果可以看到 load/store 計數增加,為什么?這些是堆棧溢出。雖然堆棧本身速度很快,但仍然在內存中運行,訪問 L1 緩存中的堆棧空間。基本上大約需要額外17個存儲對,但現在只需要約11個時鐘周期。這里 L1 緩存的吞吐量是主要限制。
最后,可以觀察 -XX:-UseFPUForSpilling 的 perfasm 輸出:
2.45% 1.21% ↗ mov 0x70(%rsp),%r11
│ ...
0.50% 0.31% │ mov 0xc(%r11),%r10d ; 讀取字段 s00
0.02% │ mov %r10d,0x10(%rsp) ; < --- 堆棧溢出!
2.04% 1.29% │ mov 0x10(%r11),%r10d ; 讀取字段 s01
│ mov %r10d,0x14(%rsp) ; < --- 堆棧溢出!
│ ...
│ ... (讀取其它字段和堆棧溢出) ...
│ ...
0.12% 0.19% │ mov 0x64(%r10),%ebp ; 讀取字段 s22
│ ...
│ ... (more reads into registers) ...
│ ...
│ ------- 讀取完成, 開始寫操作 ------
3.47% 4.45% │ mov %ebp,0xc8(%rdi) ; 存儲字段 d22
│ ...
│ ... (讀取更多寄存器和存儲字段)
│ ...
1.81% 2.68% │ mov 0x14(%rsp),%r10d ; < --- 取消堆棧溢出
0.29% 0.13% │ mov %r10d,0x78(%rdi) ; 存儲字段 d01
2.10% 2.12% │ mov 0x10(%rsp),%r10d ; < --- 取消堆棧溢出
│ mov %r10d,0x70(%rdi) ; 存儲字段 d00
│ ...
│ ... (取消其它溢出和存儲字段)
│ ...
╰ je BACK
的確,在堆棧溢出發生的地方也可以看到 XMM 溢出。
5. 觀察
FPU 溢出是緩解寄存器壓力的一種好辦法。雖然不增加通用寄存器寄存器數量,但確實在溢出時提供了更快的臨時存儲。在僅需要幾個額外的溢出存儲時,可以避免轉存到 L1 緩存支持的堆棧。
這為什么有時會出現奇怪的性能差異:如果在一些關鍵路徑上沒有用到 FPU 溢出,很可能會看到性能下降。例如,引入一個 slow-path GC 屏障,假定會清除 FPU 寄存器,可能會讓編譯器回退到堆棧溢出,并不去嘗試其它優化。
對支持 SSE 的 x86 平臺、ARMv7 和 AArch64,Hotspot 默認啟用 -XX:+UseFPUForSpilling。因此,無論是否知道這個技巧,大多數程序都能從中受益。
-
ARM
+關注
關注
134文章
9107瀏覽量
367978 -
寄存器
+關注
關注
31文章
5357瀏覽量
120638 -
存儲器
+關注
關注
38文章
7514瀏覽量
163990 -
JVM
+關注
關注
0文章
158瀏覽量
12238 -
FPU
+關注
關注
0文章
42瀏覽量
21342
發布評論請先 登錄
相關推薦
評論