ทำไม Java วนซ้ำ 4 พันล้านจึงใช้เวลาเพียง 2 ms?


113

ฉันใช้รหัส Java ต่อไปนี้บนแล็ปท็อปที่ใช้ 2.7 GHz Intel Core i7 ฉันตั้งใจจะให้มันวัดระยะเวลาที่จะจบลูปด้วยการวนซ้ำ 2 ^ 32 ซึ่งฉันคาดว่าจะประมาณ 1.48 วินาที (4 / 2.7 = 1.48)

แต่จริงๆแล้วมันใช้เวลาเพียง 2 มิลลิวินาทีแทนที่จะเป็น 1.48 วินาที ฉันสงสัยว่านี่เป็นผลมาจากการเพิ่มประสิทธิภาพ JVM ใด ๆ ที่อยู่ข้างใต้หรือไม่?

public static void main(String[] args)
{
    long start = System.nanoTime();

    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++){
    }
    long finish = System.nanoTime();
    long d = (finish - start) / 1000000;

    System.out.println("Used " + d);
}

69
ใช่ เนื่องจากตัวห่วงไม่มีผลข้างเคียงคอมไพเลอร์จึงกำจัดมันได้อย่างมีความสุข ตรวจสอบไบต์โค้ดด้วยjavap -vเพื่อดู
Elliott Frisch

36
คุณจะไม่เห็นสิ่งนั้นกลับมาในไบต์โค้ด javacทำการปรับให้เหมาะสมตามความเป็นจริงน้อยมากและปล่อยส่วนใหญ่ไว้ที่คอมไพเลอร์ JIT
Jorn Vernee

4
'ฉันสงสัยว่านี่เป็นผลมาจากการเพิ่มประสิทธิภาพ JVM ใด ๆ ที่อยู่ข้างใต้หรือไม่' - คุณคิดอย่างไร? จะมีอะไรอีกถ้าไม่ใช่การเพิ่มประสิทธิภาพ JVM
apangin

7
คำตอบสำหรับคำถามนี้มีอยู่โดยทั่วไปในstackoverflow.com/a/25323548/3182664 นอกจากนี้ยังมีแอสเซมบลีผลลัพธ์ (รหัสเครื่อง) ที่ JIT สร้างขึ้นสำหรับกรณีดังกล่าวซึ่งแสดงให้เห็นว่าJITได้รับการปรับให้เหมาะสมที่สุด (คำถามที่stackoverflow.com/q/25326377/3182664แสดงให้เห็นว่าอาจใช้เวลานานขึ้นเล็กน้อยหากลูปไม่ดำเนินการ 4 พันล้านครั้ง แต่มี 4 พันล้านลบหนึ่ง ;-)) ฉันเกือบจะถือว่าคำถามนี้ซ้ำกับคำถามอื่น ๆ - มีข้อโต้แย้งหรือไม่?
Marco13

7
คุณถือว่าโปรเซสเซอร์จะทำการวนซ้ำหนึ่งครั้งต่อ Hz นั่นเป็นข้อสันนิษฐานที่กว้างไกล ปัจจุบันโปรเซสเซอร์ดำเนินการเพิ่มประสิทธิภาพทุกประเภทตามที่ @Rahul กล่าวถึงและหากคุณไม่ทราบข้อมูลเพิ่มเติมเกี่ยวกับการทำงานของ Core i7 คุณจะไม่สามารถสรุปได้
Tsahi Asher

คำตอบ:


106

มีหนึ่งในสองความเป็นไปได้ที่เกิดขึ้นที่นี่:

  1. คอมไพเลอร์ตระหนักว่าลูปซ้ำซ้อนและไม่ทำอะไรเลยดังนั้นจึงปรับให้เหมาะสมที่สุด

  2. JIT (คอมไพเลอร์แบบทันเวลา) ตระหนักว่าลูปซ้ำซ้อนและไม่ทำอะไรเลยดังนั้นจึงปรับให้เหมาะสมที่สุด

คอมไพเลอร์สมัยใหม่ฉลาดมาก พวกเขาสามารถเห็นได้ว่าเมื่อใดที่รหัสนั้นไร้ประโยชน์ ลองใส่ลูปว่างเข้าไปในGodBoltและดูที่ผลลัพธ์จากนั้นเปิดการปรับให้-O2เหมาะสมคุณจะเห็นว่าผลลัพธ์เป็นสิ่งที่อยู่ในบรรทัดของ

main():
    xor eax, eax
    ret

ฉันต้องการชี้แจงบางอย่างใน Java การเพิ่มประสิทธิภาพส่วนใหญ่ทำโดย JIT ในภาษาอื่น ๆ (เช่น C / C ++) การปรับให้เหมาะสมส่วนใหญ่ทำโดยคอมไพเลอร์ตัวแรก


คอมไพเลอร์ได้รับอนุญาตให้ทำการเพิ่มประสิทธิภาพดังกล่าวหรือไม่? ฉันไม่ทราบแน่ชัดสำหรับ Java แต่โดยทั่วไปแล้วคอมไพเลอร์. NET ควรหลีกเลี่ยงสิ่งนี้เพื่อให้ JIT ทำการเพิ่มประสิทธิภาพที่ดีที่สุดสำหรับแพลตฟอร์ม
IllidanS4 ต้องการให้ Monica กลับมาใน

1
@ IllidanS4 โดยทั่วไปสิ่งนี้ขึ้นอยู่กับมาตรฐานภาษา หากคอมไพเลอร์สามารถทำการเพิ่มประสิทธิภาพที่หมายถึงโค้ดที่ตีความโดยมาตรฐานจะมีผลเช่นเดียวกันแสดงว่าใช่ มีรายละเอียดปลีกย่อยมากมายที่เราต้องพิจารณาเช่นมีการเปลี่ยนแปลงบางอย่างสำหรับการคำนวณจุดลอยตัวที่อาจส่งผลให้มีความเป็นไปได้ที่จะมีการนำเสนอมากเกินไป / น้อยเกินไปดังนั้นการเพิ่มประสิทธิภาพใด ๆ จึงต้องทำอย่างรอบคอบ
user1997744

9
@ IllidanS4 สภาพแวดล้อมรันไทม์จะสามารถทำการเพิ่มประสิทธิภาพให้ดีขึ้นได้อย่างไร อย่างน้อยที่สุดก็ต้องวิเคราะห์โค้ดซึ่งไม่สามารถเร็วไปกว่าการลบโค้ดในระหว่างการคอมไพล์ได้
Gerhardh

2
@Gerhardh ฉันไม่ได้พูดถึงกรณีที่แม่นยำนี้เมื่อรันไทม์ไม่สามารถทำงานได้ดีกว่าในการลบส่วนที่ซ้ำซ้อนของโค้ด แต่อาจมีบางกรณีที่เหตุผลนี้ถูกต้อง และเนื่องจากอาจมีคอมไพเลอร์อื่นสำหรับ JRE จากภาษาอื่นรันไทม์จึงควรทำการเพิ่มประสิทธิภาพเหล่านี้ด้วยดังนั้นจึงไม่มีเหตุผลใดที่พวกเขาจะต้องทำทั้งโดยรันไทม์และคอมไพเลอร์
IllidanS4 ต้องการให้ Monica กลับมาใน

6
@ IllidanS4 การเพิ่มประสิทธิภาพรันไทม์ใด ๆ ต้องใช้เวลาน้อยกว่าศูนย์ การป้องกันไม่ให้คอมไพเลอร์ลบโค้ดจะไม่สมเหตุสมผล
Gerhardh

55

ดูเหมือนว่าคอมไพเลอร์ JIT ได้รับการปรับให้เหมาะสมแล้ว เมื่อฉันปิด ( -Djava.compiler=NONE) รหัสจะทำงานช้าลงมาก:

$ javac MyClass.java
$ java MyClass
Used 4
$ java -Djava.compiler=NONE MyClass
Used 40409

ฉันใส่รหัสของ OP ไว้ข้างในclass MyClass.


2
แปลก. เมื่อฉันรันโค้ดทั้งสองวิธีมันจะเร็วขึ้นโดยไม่มีแฟล็ก แต่ด้วยปัจจัย 10 เท่านั้นและการเพิ่มหรือลบเลขศูนย์ให้กับจำนวนการวนซ้ำในลูปยังส่งผลต่อเวลาทำงานตามปัจจัยสิบด้วยและไม่มี ธง. ดังนั้น (สำหรับฉัน) การวนซ้ำดูเหมือนจะไม่ได้รับการปรับให้เหมาะสมโดยสิ้นเชิงเพียงแค่ทำให้เร็วขึ้น 10 เท่าเท่านั้น (Oracle Java 8-151)
tobias_k

@tobias_k มันขึ้นอยู่กับขั้นตอนของ JIT ที่ลูปกำลังดำเนินไปฉันเดาว่าstackoverflow.com/a/47972226/1059372
ยูจีน

21

ฉันจะบอกให้ชัดเจน - ว่านี่คือการเพิ่มประสิทธิภาพ JVM ที่เกิดขึ้นลูปก็จะถูกลบออกไปเลย นี่คือการทดสอบเล็ก ๆ ที่แสดงให้เห็นว่ามีความแตกต่างอย่างมากJITเมื่อเปิดใช้งานเฉพาะสำหรับC1 Compilerและปิดใช้งานเลย

ข้อจำกัดความรับผิดชอบ: อย่าเขียนการทดสอบเช่นนี้ - นี่เป็นเพียงเพื่อพิสูจน์ว่าการ "ลบ" ลูปที่แท้จริงเกิดขึ้นในC2 Compiler:

@Benchmark
@Fork(1)
public void full() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        ++result;
    }
}

@Benchmark
@Fork(1)
public void minusOne() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-XX:TieredStopAtLevel=1" })
public void withoutC2() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-Xint" })
public void withoutAll() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

ผลลัพธ์แสดงให้เห็นว่าขึ้นอยู่กับส่วนใดของการJITเปิดใช้งานเมธอดจะเร็วขึ้น (เร็วขึ้นมากจนดูเหมือนว่า "ไม่มีอะไร" - การลบลูปซึ่งดูเหมือนจะเกิดขึ้นในC2 Compiler- ซึ่งเป็นระดับสูงสุด):

 Benchmark                Mode  Cnt      Score   Error  Units
 Loop.full        avgt    2      10⁻⁷          ms/op
 Loop.minusOne    avgt    2      10⁻⁶          ms/op
 Loop.withoutAll  avgt    2  51782.751          ms/op
 Loop.withoutC2   avgt    2   1699.137          ms/op 

13

ดังที่ได้กล่าวไปแล้วคอมไพเลอร์JIT (just-in-time) สามารถปรับลูปว่างให้เหมาะสมเพื่อลบการทำซ้ำที่ไม่จำเป็นออกไป แต่อย่างไร?

อันที่จริงมีสองคอมไพเลอร์ JIT: C1และC2 ขั้นแรกให้คอมไพล์โค้ดด้วย C1 C1 รวบรวมสถิติและช่วยให้ JVM ค้นพบว่าใน 100% ลูปว่างของเราไม่เปลี่ยนแปลงอะไรเลยและไม่มีประโยชน์ ในสถานการณ์นี้ C2 เข้าสู่ขั้นตอน เมื่อมีการเรียกรหัสบ่อยมากสามารถปรับแต่งและรวบรวมด้วย C2 โดยใช้สถิติที่รวบรวมได้

ตัวอย่างเช่นฉันจะทดสอบข้อมูลโค้ดถัดไป (JDK ของฉันถูกตั้งค่าเป็นslowdebug build 9-internal ):

public class Demo {
    private static void run() {
        for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        }
        System.out.println("Done!");
    }
}

ด้วยตัวเลือกบรรทัดคำสั่งต่อไปนี้:

-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Demo.run

และมีวิธีการรันของฉันหลายเวอร์ชันโดยคอมไพล์ด้วย C1 และ C2 อย่างเหมาะสม สำหรับฉันตัวแปรสุดท้าย (C2) มีลักษณะดังนี้:

...

; B1: # B3 B2 <- BLOCK HEAD IS JUNK  Freq: 1
0x00000000125461b0: mov   dword ptr [rsp+0ffffffffffff7000h], eax
0x00000000125461b7: push  rbp
0x00000000125461b8: sub   rsp, 40h
0x00000000125461bc: mov   ebp, dword ptr [rdx]
0x00000000125461be: mov   rcx, rdx
0x00000000125461c1: mov   r10, 57fbc220h
0x00000000125461cb: call  indirect r10    ; *iload_1

0x00000000125461ce: cmp   ebp, 7fffffffh  ; 7fffffff => 2147483647
0x00000000125461d4: jnl   125461dbh       ; jump if not less

; B2: # B3 <- B1  Freq: 0.999999
0x00000000125461d6: mov   ebp, 7fffffffh  ; *if_icmpge

; B3: # N44 <- B1 B2  Freq: 1       
0x00000000125461db: mov   edx, 0ffffff5dh
0x0000000012837d60: nop
0x0000000012837d61: nop
0x0000000012837d62: nop
0x0000000012837d63: call  0ae86fa0h

...

มันค่อนข้างยุ่งเล็กน้อย แต่ถ้าคุณมองใกล้ ๆ คุณอาจสังเกตเห็นว่าที่นี่ไม่มีห่วงวิ่งยาว มี 3 บล็อกคือ B1, B2 และ B3 และขั้นตอนการดำเนินการสามารถหรือB1 -> B2 -> B3 B1 -> B3ที่ไหนFreq: 1- ความถี่ปกติโดยประมาณของการดำเนินการบล็อก


8

คุณกำลังวัดเวลาที่ใช้ในการตรวจจับการวนซ้ำไม่ได้ทำอะไรเลยรวบรวมรหัสในเธรดพื้นหลังและกำจัดรหัส

for (int t = 0; t < 5; t++) {
    long start = System.nanoTime();
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }
    long time = System.nanoTime() - start;

    String s = String.format("%d: Took %.6f ms", t, time / 1e6);
    Thread.sleep(50);
    System.out.println(s);
    Thread.sleep(50);
}

หากคุณเรียกใช้สิ่งนี้-XX:+PrintCompilationคุณจะเห็นว่ามีการรวบรวมโค้ดในพื้นหลังเป็นคอมไพเลอร์ระดับ 3 หรือ C1 และหลังจากนั้นไม่กี่ลูปถึงระดับ 4 ของ C4

    129   34 %     3       A::main @ 15 (93 bytes)
    130   35       3       A::main (93 bytes)
    130   36 %     4       A::main @ 15 (93 bytes)
    131   34 %     3       A::main @ -2 (93 bytes)   made not entrant
    131   36 %     4       A::main @ -2 (93 bytes)   made not entrant
0: Took 2.510408 ms
    268   75 %     3       A::main @ 15 (93 bytes)
    271   76 %     4       A::main @ 15 (93 bytes)
    274   75 %     3       A::main @ -2 (93 bytes)   made not entrant
1: Took 5.629456 ms
2: Took 0.000000 ms
3: Took 0.000364 ms
4: Took 0.000365 ms

หากคุณเปลี่ยนการวนซ้ำเพื่อใช้longมันไม่ได้รับการปรับให้เหมาะสม

    for (long i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }

แทนที่จะได้รับ

0: Took 1579.267321 ms
1: Took 1674.148662 ms
2: Took 1885.692166 ms
3: Took 1709.870567 ms
4: Took 1754.005112 ms

แปลกจัง ... ทำไมตัวlongนับถึงป้องกันไม่ให้เกิดการเพิ่มประสิทธิภาพแบบเดียวกัน
Ryan Amos

@RyanAmos การเพิ่มประสิทธิภาพจะใช้กับการนับลูปดั้งเดิมทั่วไปเท่านั้นหากชนิดintnote char และ short มีประสิทธิภาพเหมือนกันที่ระดับรหัสไบต์
Peter Lawrey

-1

คุณพิจารณาเวลาเริ่มต้นและเวลาสิ้นสุดในหน่วยนาโนวินาทีและคุณหารด้วย 10 ^ 6 เพื่อคำนวณเวลาในการตอบสนอง

long d = (finish - start) / 1000000

มันควรจะเป็น10^9เพราะ1วินาที = 10^9นาโนวินาที


สิ่งที่คุณแนะนำไม่เกี่ยวข้องกับประเด็นของฉัน สิ่งที่ฉันสงสัยคือใช้เวลานานแค่ไหนและไม่สำคัญว่าระยะเวลานี้จะพิมพ์ / แสดงเป็นมิลลิวินาทีหรือวินาที
twimo
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.