[英]Why does running multiple lambdas in loops suddenly slow down?
考慮以下代碼:
public class Playground {
private static final int MAX = 100_000_000;
public static void main(String... args) {
execute(() -> {});
execute(() -> {});
execute(() -> {});
execute(() -> {});
}
public static void execute(Runnable task) {
Stopwatch stopwatch = Stopwatch.createStarted();
for (int i = 0; i < MAX; i++) {
task.run();
}
System.out.println(stopwatch);
}
}
這目前在 Temurin 17 上的 Intel MBP 上打印以下內容:
3.675 ms
1.948 ms
216.9 ms
243.3 ms
請注意第三次(以及任何后續)執行的 100* 減速。 現在,顯然,這不是在 Java 中編寫基准測試的方法。 循環代碼沒有做任何事情,所以我希望它會被所有的重復消除。 我也無法使用 JMH 重復這種效果,這告訴我原因是棘手和脆弱的。
那么,為什么會發生這種情況? 為什么會突然出現如此災難性的放緩,引擎蓋下發生了什么? 一個假設是 C2 對我們有幫助,但我們遇到了哪個限制?
不會改變行為的事情:
“修復”行為的東西。 實際上,第三次調用和所有后續調用似乎要快得多,暗示編譯正確地完全消除了循環:
task.run()
lambda,execute()
方法,仍然維護 4 個不同的 lambda。您實際上可以使用 JMH SingleShot 模式復制它:
@BenchmarkMode(Mode.SingleShotTime)
@Warmup(iterations = 0)
@Measurement(iterations = 1)
@Fork(1)
public class Lambdas {
@Benchmark
public static void doOne() {
execute(() -> {});
}
@Benchmark
public static void doFour() {
execute(() -> {});
execute(() -> {});
execute(() -> {});
execute(() -> {});
}
public static void execute(Runnable task) {
for (int i = 0; i < 100_000_000; i++) {
task.run();
}
}
}
Benchmark Mode Cnt Score Error Units
Lambdas.doFour ss 0.446 s/op
Lambdas.doOne ss 0.006 s/op
如果您查看doFour
測試的-prof perfasm
配置文件,您會得到一個重要的線索:
....[Hottest Methods (after inlining)]..............................................................
32.19% c2, level 4 org.openjdk.Lambdas$$Lambda$44.0x0000000800c258b8::run, version 664
26.16% c2, level 4 org.openjdk.Lambdas$$Lambda$43.0x0000000800c25698::run, version 658
至少有兩個熱 lambda,它們由不同的類表示。 所以你看到的可能是單態的(一個目標),然后是雙態的(兩個目標),然后是task.run
的多態虛擬調用。
虛擬調用必須選擇從哪個class調用實現。 你擁有的類越多,優化器就越糟糕。 JVM 試圖適應,但隨着運行的進行,情況變得越來越糟。 大致是這樣的:
execute(() -> {}); // compiles with single target, fast
execute(() -> {}); // recompiles with two targets, a bit slower
execute(() -> {}); // recompiles with three targets, slow
execute(() -> {}); // continues to be slow
現在,消除循環需要看穿task.run()
。 在單態和雙態情況下,這很容易:一個或兩個目標都被內聯,它們的空體被發現,完成。 在這兩種情況下,您都必須進行類型檢查,這意味着它不是完全免費的,雙態需要額外的成本。 當你遇到多態調用時,根本就沒有這樣的運氣:它是不透明的調用。
您可以在組合中添加另外兩個基准來查看它:
@Benchmark
public static void doFour_Same() {
Runnable l = () -> {};
execute(l);
execute(l);
execute(l);
execute(l);
}
@Benchmark
public static void doFour_Pair() {
Runnable l1 = () -> {};
Runnable l2 = () -> {};
execute(l1);
execute(l1);
execute(l2);
execute(l2);
}
然后會產生:
Benchmark Mode Cnt Score Error Units
Lambdas.doFour ss 0.445 s/op ; polymorphic
Lambdas.doFour_Pair ss 0.016 s/op ; bimorphic
Lambdas.doFour_Same ss 0.008 s/op ; monomorphic
Lambdas.doOne ss 0.006 s/op
這也解釋了為什么您的“修復”有幫助:
使用 1-2 個嵌套類而不是 lambda,
雙態內聯。
using 1-2 lambda instances instead of 4 different ones,
雙態內聯。
not calling task.run() lambdas inside the loop,
避免循環中的多態(不透明)調用,允許循環消除。
inlining the execute() method, still maintaining 4 different lambdas.
避免遇到多個呼叫目標的單個呼叫站點。 換句話說,將單個多態調用站點變成一系列單態調用站點,每個調用站點都有自己的目標。
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.