Java: ลูปที่ไม่ได้ควบคุมด้วยตนเองยังคงเร็วกว่าลูปดั้งเดิม ทำไม?


13

ลองพิจารณาตัวอย่างของโค้ดสองชุดต่อไปนี้ในอาร์เรย์ที่มีความยาว 2:

boolean isOK(int i) {
    for (int j = 0; j < filters.length; ++j) {
        if (!filters[j].isOK(i)) {
            return false;
        }
    }
    return true;
}

และ

boolean isOK(int i) {
     return filters[0].isOK(i) && filters[1].isOK(i);
}

ฉันคิดว่าประสิทธิภาพของสองชิ้นนี้ควรจะคล้ายกันหลังจากการวอร์มอัพเพียงพอ
ฉันได้ตรวจสอบสิ่งนี้โดยใช้กรอบการเปรียบเทียบไมโคร JMH ตามที่อธิบายไว้เช่นที่นี่และที่นี่และสังเกตว่าตัวอย่างที่สองนั้นเร็วกว่า 10%

คำถาม: ทำไม Java ถึงไม่ปรับข้อมูลโค้ดแรกของฉันให้ดีขึ้นโดยใช้เทคนิคการปลดลูปพื้นฐาน
โดยเฉพาะฉันต้องการทำความเข้าใจสิ่งต่อไปนี้:

  1. ฉันสามารถผลิตรหัสที่เป็นที่เหมาะสมสำหรับกรณีของ 2 ตัวกรองและยังคงสามารถทำงานในกรณีของหมายเลขอื่นของตัวกรอง (จินตนาการสร้างง่าย)
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters)A: JITC สามารถทำสิ่งเดียวกันได้หรือไม่ถ้าไม่ทำไม
  2. JITC สามารถตรวจจับได้หรือไม่ว่า ' filters.length == 2 ' เป็นกรณีที่พบบ่อยที่สุดและสร้างรหัสที่เหมาะสมที่สุดสำหรับกรณีนี้หลังจากทำการอุ่นเครื่องหรือไม่ นี่น่าจะเหมาะสมเกือบเท่ากับเวอร์ชันที่ไม่ได้ควบคุมด้วยตนเอง
  3. JITC สามารถตรวจพบว่ามีการใช้งานอินสแตนซ์ที่เฉพาะเจาะจงบ่อยครั้งมากแล้วสร้างรหัสสำหรับอินสแตนซ์ที่เฉพาะเจาะจงนี้ (ซึ่งทราบว่าจำนวนตัวกรองเป็น 2 เสมอ)?
    อัปเดต:รับคำตอบที่ JITC ใช้งานได้เฉพาะในระดับชั้นเรียนเท่านั้น ตกลงเข้าใจแล้ว

เป็นการดีที่ฉันต้องการได้รับคำตอบจากคนที่มีความเข้าใจอย่างลึกซึ้งว่า JITC ทำงานอย่างไร

รายละเอียดการเรียกใช้เกณฑ์มาตรฐาน:

  • ทดลองใช้ Java 8 OpenJDK และ Oracle HotSpot เวอร์ชันล่าสุดผลลัพธ์จะคล้ายกัน
  • ธง Java ที่ใช้: -Xmx4g -Xms4g -server -Xbatch -XX: CICompilerCount = 2 (รับผลลัพธ์ที่คล้ายกันโดยไม่มีแฟล็กแฟนซีเช่นกัน)
  • โดยวิธีการที่ฉันได้รับอัตราส่วนเวลาที่คล้ายกันถ้าฉันเพียงแค่เรียกใช้มันหลายพันล้านครั้งในวง (ไม่ผ่าน JMH) นั่นคือตัวอย่างที่สองจะเร็วขึ้นอย่างชัดเจนเสมอ

เอาต์พุตมาตรฐานทั่วไป:

เกณฑ์มาตรฐาน (filterIndex) โหมด Cnt คะแนนข้อผิดพลาดหน่วย
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44.202 ± 0.224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38.347 ± 0.063 ns / op

(บรรทัดแรกสอดคล้องกับตัวอย่างแรกบรรทัดที่สอง - ถึงที่สอง

รหัสมาตรฐานที่สมบูรณ์:

public class LoopUnrollingBenchmark {

    @State(Scope.Benchmark)
    public static class BenchmarkData {
        public Filter[] filters;
        @Param({"0", "1"})
        public int filterIndex;
        public int num;

        @Setup(Level.Invocation) //similar ratio with Level.TRIAL
        public void setUp() {
            filters = new Filter[]{new FilterChain1(), new FilterChain2()};
            num = new Random().nextInt();
        }
    }

    @Benchmark
    @Fork(warmups = 5, value = 20)
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public int runBenchmark(BenchmarkData data) {
        Filter filter = data.filters[data.filterIndex];
        int sum = 0;
        int num = data.num;
        if (filter.isOK(num)) {
            ++sum;
        }
        if (filter.isOK(num + 1)) {
            ++sum;
        }
        if (filter.isOK(num - 1)) {
            ++sum;
        }
        if (filter.isOK(num * 2)) {
            ++sum;
        }
        if (filter.isOK(num * 3)) {
            ++sum;
        }
        if (filter.isOK(num * 5)) {
            ++sum;
        }
        return sum;
    }


    interface Filter {
        boolean isOK(int i);
    }

    static class Filter1 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 3 == 1;
        }
    }

    static class Filter2 implements Filter {
        @Override
        public boolean isOK(int i) {
            return i % 7 == 3;
        }
    }

    static class FilterChain1 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            for (int j = 0; j < filters.length; ++j) {
                if (!filters[j].isOK(i)) {
                    return false;
                }
            }
            return true;
        }
    }

    static class FilterChain2 implements Filter {
        final Filter[] filters = createLeafFilters();

        @Override
        public boolean isOK(int i) {
            return filters[0].isOK(i) && filters[1].isOK(i);
        }
    }

    private static Filter[] createLeafFilters() {
        Filter[] filters = new Filter[2];
        filters[0] = new Filter1();
        filters[1] = new Filter2();
        return filters;
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.Main.main(args);
    }
}

1
คอมไพเลอร์ไม่สามารถรับประกันได้ว่าความยาวของอาเรย์คือ 2 ฉันไม่แน่ใจว่ามันจะเปิดใช้งานได้แม้ว่าจะทำได้
marstran

1
@Setup(Level.Invocation): ไม่แน่ใจว่ามันช่วยได้ (ดู javadoc)
GPI

3
เนื่องจากไม่มีการรับประกันใด ๆ ว่าอาเรย์จะมีความยาวเป็น 2 เสมอดังนั้นทั้งสองวิธีจึงไม่ได้ทำสิ่งเดียวกัน JIT จะอนุญาตให้ตัวเองเปลี่ยนครั้งแรกเป็นครั้งที่สองได้อย่างไร
Andreas

@ Andreas ฉันขอแนะนำให้คุณตอบคำถาม แต่อธิบายอย่างละเอียดว่าเหตุใด JIT จึงไม่สามารถคลี่คลายในกรณีนี้เมื่อเปรียบเทียบกับกรณีอื่นที่คล้ายคลึงกันที่สามารถทำได้
Alexander

1
@Alexander JIT จะเห็นว่าความยาวของอาร์เรย์ไม่สามารถเปลี่ยนแปลงได้หลังจากการสร้างเนื่องจากฟิลด์นั้นเป็นfinalแต่ JIT ไม่เห็นว่าอินสแตนซ์ทั้งหมดของคลาสจะได้รับอาร์เรย์ที่มีความยาว 2 เพื่อดูว่าจะต้องดำดิ่งลงในcreateLeafFilters()วิธีการและวิเคราะห์รหัสลึกพอที่จะเรียนรู้ว่าอาเรย์จะยาว 2 เสมอ ทำไมคุณถึงเชื่อว่าเครื่องมือเพิ่มประสิทธิภาพของ JIT จะดำลงไปในโค้ดของคุณ
Andreas

คำตอบ:


10

TL; DRเหตุผลหลักของความแตกต่างของประสิทธิภาพที่นี่ไม่เกี่ยวข้องกับการวนซ้ำ มันเป็นค่อนข้างเก็งกำไรประเภทและแคชแบบอินไลน์

การคลายกลยุทธ์

ในความเป็นจริงในคำศัพท์ของ HotSpot ลูปดังกล่าวจะถูกนับเป็นจำนวนและในบางกรณี JVM สามารถเปิดใช้งานได้ ไม่ได้อยู่ในกรณีของคุณ

HotSpot มีสองกลยุทธ์ในการคลี่วงออกมา: 1) คลี่วงออกสุดสุดนั่นคือลบลูปทั้งหมด; หรือ 2) กาวซ้ำหลายรอบติดต่อกันด้วยกัน

การคลายออกสูงสุดสามารถทำได้เฉพาะในกรณีที่ทราบจำนวนการทำซ้ำที่แน่นอน

  if (!cl->has_exact_trip_count()) {
    // Trip count is not exact.
    return false;
  }

อย่างไรก็ตามในกรณีของคุณฟังก์ชั่นอาจกลับมาเร็วกว่ากำหนดหลังจากการทำซ้ำครั้งแรก

อาจมีการยกเลิกบางส่วน แต่อาจใช้เงื่อนไขต่อไปนี้ :

  // Don't unroll if the next round of unrolling would push us
  // over the expected trip count of the loop.  One is subtracted
  // from the expected trip count because the pre-loop normally
  // executes 1 iteration.
  if (UnrollLimitForProfileCheck > 0 &&
      cl->profile_trip_cnt() != COUNT_UNKNOWN &&
      future_unroll_ct        > UnrollLimitForProfileCheck &&
      (float)future_unroll_ct > cl->profile_trip_cnt() - 1.0) {
    return false;
  }

เนื่องจากในกรณีของคุณจำนวนการเดินทางที่คาดหวังมีน้อยกว่า 2 HotSpot ถือว่าไม่คุ้มที่จะเปิดใช้งานซ้ำสองครั้ง โปรดทราบว่าการวนซ้ำครั้งแรกจะถูกแยกลงใน pre-loop ต่อไป ( การเพิ่มประสิทธิภาพการปอกแบบวนซ้ำ ) ดังนั้นการคลายออกจึงไม่เป็นผลดีมากที่นี่

การเก็งกำไรประเภท

ในเวอร์ชันที่ยังไม่ได้รับการเผยแพร่ของคุณมีสองinvokeinterfaceไบต์ที่แตกต่างกัน ไซต์เหล่านี้มีโปรไฟล์ประเภทสองแบบที่แตกต่างกัน รับแรกเสมอและรับที่สองอยู่เสมอFilter1 Filter2ดังนั้นโดยทั่วไปคุณมีไซต์การโทร monomorphic สองไซต์และ HotSpot สามารถรวมการโทรได้อย่างสมบูรณ์แบบ - เรียกว่า "แคชแบบอินไลน์" ซึ่งมีอัตราส่วนการเข้าชม 100% ในกรณีนี้

เมื่อวนซ้ำจะมีเพียงหนึ่งinvokeinterfaceไบต์เท่านั้นและมีการรวบรวมโปรไฟล์ประเภทเดียวเท่านั้น HotSpot JVM เห็นว่าfilters[j].isOK()เรียกว่า 86% คูณด้วยFilter1ตัวรับและ 14% คูณด้วยFilter2ตัวรับ นี่จะเป็นการเรียก bimorphic โชคดีที่ HotSpot สามารถ inline bimorphic call ได้เช่นกัน มันอินไลน์เป้าหมายทั้งสองด้วยสาขาตามเงื่อนไข อย่างไรก็ตามในกรณีนี้อัตราส่วนการเข้าชมจะมากที่สุดที่ 86% และประสิทธิภาพการทำงานจะได้รับผลกระทบจากสาขาที่มีการพิมพ์ผิดในระดับสถาปัตยกรรม

สิ่งต่าง ๆ จะยิ่งแย่ลงถ้าคุณมีตัวกรองที่แตกต่างกัน 3 ตัว ในกรณีนี้isOK()จะเป็นการโทร megamorphic ซึ่ง HotSpot ไม่สามารถ inline เลย ดังนั้นรหัสที่คอมไพล์แล้วจะมีการเรียกส่วนต่อประสานที่แท้จริงซึ่งมีผลกระทบต่อประสิทธิภาพที่ใหญ่กว่า

เพิ่มเติมเกี่ยวกับอินไลน์เก็งกำไรในบทความThe Black Magic of (Java) วิธีการจัดส่ง

ข้อสรุป

เพื่ออินไลน์การโทรเสมือน / อินเทอร์เฟซ HotSpot JVM รวบรวมโปรไฟล์ประเภทต่อไบต์ที่เรียกใช้ หากมีการโทรเสมือนในลูปจะมีโปรไฟล์ประเภทเดียวสำหรับการโทรไม่ว่าลูปนั้นจะถูกยกเลิกหรือไม่ก็ตาม

ในการรับประโยชน์สูงสุดจากการปรับการโทรเสมือนจริงคุณจะต้องแบ่งลูปด้วยตนเองเป็นหลักเพื่อจุดประสงค์ในการแยกโปรไฟล์ประเภท HotSpot ไม่สามารถทำสิ่งนี้ได้โดยอัตโนมัติ


ขอบคุณสำหรับคำตอบที่ดี เพื่อความสมบูรณ์: คุณทราบถึงเทคนิค JITC ใด ๆ ที่อาจสร้างรหัสสำหรับอินสแตนซ์ที่เฉพาะเจาะจงหรือไม่?
Alexander

@Alexander HotSpot ไม่ปรับโค้ดให้เหมาะสมสำหรับอินสแตนซ์ที่เฉพาะเจาะจง มันใช้สถิติรันไทม์ที่มีตัวนับต่อไบต์, โปรไฟล์ประเภท, ความน่าจะเป็นเป้าหมายสาขา ฯลฯ หากคุณต้องการเพิ่มประสิทธิภาพรหัสสำหรับกรณีที่เฉพาะเจาะจงให้สร้างคลาสแยกต่างหากสำหรับมันด้วยตนเองหรือด้วยการสร้าง bytecode แบบไดนามิก
apangin

13

ลูปที่นำเสนอมีแนวโน้มตกอยู่ภายใต้หมวดหมู่ "ไม่นับ" ของลูปซึ่งเป็นลูปซึ่งการนับซ้ำไม่สามารถพิจารณาได้ในเวลาคอมไพล์หรือเวลารันไทม์ ไม่เพียงเพราะอาร์กิวเมนต์ @Andreas เกี่ยวกับขนาดอาร์เรย์ แต่ยังเป็นเพราะเงื่อนไขแบบสุ่มbreak(ซึ่งเคยเป็นเกณฑ์มาตรฐานของคุณเมื่อฉันเขียนโพสต์นี้)

คอมไพเลอร์ state-of-the-art ไม่ปรับให้เหมาะสมเชิงรุกเนื่องจากการคลายลูปที่ไม่นับบ่อยครั้งนั้นเกี่ยวข้องกับการทำซ้ำนอกจากนี้ยังมีเงื่อนไขการออกจากลูปด้วยซึ่งทำให้ปรับปรุงประสิทธิภาพการทำงานในเวลานั้นเท่านั้น ดูกระดาษ 2017นี้เพื่อดูรายละเอียดที่พวกเขาทำข้อเสนอวิธีการเปิดใช้งานสิ่งต่าง ๆ เช่นกัน

จากนี้ต่อไปนี้การสันนิษฐานของคุณไม่ถือว่าคุณได้ทำการเรียงลำดับของ "การคลายตนเอง" ของการวนซ้ำ คุณกำลังพิจารณาว่าเป็นเทคนิคการวนรอบพื้นฐานเพื่อแปลงการวนซ้ำในอาร์เรย์ที่มีตัวแบ่งตามเงื่อนไขเป็นการ&&แสดงออกบูลีนที่ถูกผูกมัด ฉันคิดว่านี่เป็นกรณีที่ค่อนข้างพิเศษและจะต้องประหลาดใจเมื่อพบว่าเครื่องมือเพิ่มประสิทธิภาพฮอตสปอตทำการปรับโครงสร้างที่ซับซ้อนได้ทันที ที่นี่พวกเขากำลังพูดถึงสิ่งที่มันอาจทำจริงบางทีอ้างอิงนี้น่าสนใจ

สิ่งนี้จะสะท้อนให้เห็นถึงกลไกของการคลายในปัจจุบันและอาจจะไม่มีที่ไหนใกล้กับรหัสเครื่องที่ไม่ได้ควบคุมซึ่งจะมีลักษณะดังนี้:

if (! filters[0].isOK(i))
{
   return false;
} 
if(! filters[1].isOK(i))
{
   return false;
}
return true;

คุณกำลังสรุปว่าเนื่องจากโค้ดหนึ่งชิ้นทำงานเร็วกว่าโค้ดอื่น ๆ ที่ลูปไม่ได้คลี่คลาย แม้ว่าจะเป็นเช่นนั้นคุณยังสามารถเห็นความแตกต่างของรันไทม์เนื่องจากความจริงที่ว่าคุณกำลังเปรียบเทียบการใช้งานที่แตกต่างกัน

หากคุณต้องการที่จะได้รับความเชื่อมั่นมากขึ้นมีเป็นjitwatchวิเคราะห์ / Visualizer ของการดำเนินงานที่เกิดขึ้นจริงรวมทั้ง Jit รหัสเครื่อง(GitHub) (สไลด์การนำเสนอ) หากมีบางสิ่งที่จะเห็นในที่สุดฉันก็เชื่อสายตาตัวเองมากกว่าความคิดเห็นใด ๆ เกี่ยวกับสิ่งที่ JIT อาจหรือไม่ทำโดยทั่วไปเนื่องจากทุกกรณีมีลักษณะเฉพาะ ที่นี่พวกเขากังวลเกี่ยวกับความยากลำบากในการเข้าถึงแถลงการณ์ทั่วไปสำหรับกรณีเฉพาะที่เกี่ยวข้องกับ JIT และให้ลิงก์ที่น่าสนใจ

เนื่องจากเป้าหมายของคุณเป็นแบบรันไทม์ขั้นต่ำa && b && c ...รูปแบบจึงน่าจะมีประสิทธิภาพมากที่สุดหากคุณไม่ต้องการขึ้นอยู่กับความหวังในการวนซ้ำ - วงอย่างน้อยที่สุดก็มีประสิทธิภาพมากกว่าสิ่งอื่นใด แต่คุณไม่สามารถมีวิธีการทั่วไปได้ ด้วยองค์ประกอบการทำงานของjava.util.Functionมีค่าใช้จ่ายจำนวนมากอีกครั้ง (แต่ละฟังก์ชั่นเป็นชั้นเรียนการโทรแต่ละครั้งเป็นวิธีเสมือนที่ต้องการการจัดส่ง) บางทีในสถานการณ์เช่นนี้มันอาจสมเหตุสมผลที่จะทำลายระดับภาษาและสร้างรหัสไบต์ที่กำหนดเองเมื่อใช้งานจริง ในทางกลับกัน&&ตรรกะต้องแตกแขนงในระดับรหัสไบต์เช่นกันและอาจเทียบเท่ากับถ้า / ส่งคืน (ซึ่งไม่สามารถสร้างได้โดยไม่มีค่าใช้จ่าย)


เพียง adendum ขนาดเล็ก: ห่วงนับใน JVM โลกเป็นห่วงใด ๆ ที่ "วิ่ง" กว่าint i = ....; i < ...; ++iใด ๆอื่น ๆห่วงไม่ได้
ยูจีน
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.