Java: vòng lặp không được kiểm soát thủ công vẫn nhanh hơn vòng lặp ban đầu. Tại sao?


13

Hãy xem xét hai đoạn mã sau trên một mảng có độ dài 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);
}

Tôi cho rằng hiệu suất của hai tác phẩm này sẽ tương tự nhau sau khi khởi động đủ.
Tôi đã kiểm tra điều này bằng cách sử dụng khung điểm chuẩn vi mô JMH như được mô tả, ví dụ ở đâyở đây và nhận thấy rằng đoạn mã thứ hai nhanh hơn 10%.

Câu hỏi: tại sao Java không tối ưu hóa đoạn mã đầu tiên của tôi bằng cách sử dụng kỹ thuật hủy đăng ký vòng lặp cơ bản?
Cụ thể, tôi muốn hiểu những điều sau:

  1. Tôi có thể dễ dàng tạo mã phù hợp cho các trường hợp 2 bộ lọc và vẫn có thể hoạt động trong trường hợp có một số bộ lọc khác (hãy tưởng tượng một trình xây dựng đơn giản) :
    return (filters.length) == 2 ? new FilterChain2(filters) : new FilterChain1(filters). JITC có thể làm như vậy và nếu không, tại sao?
  2. JITC có thể phát hiện ra rằng ' bộ lọc.length == 2 ' là trường hợp thường xuyên nhất và tạo ra mã tối ưu cho trường hợp này sau khi khởi động không? Điều này sẽ gần như tối ưu như phiên bản không được kiểm soát thủ công.
  3. JITC có thể phát hiện ra rằng một thể cụ thể được sử dụng rất thường xuyên và sau đó tạo mã cho trường hợp cụ thể này (mà nó biết rằng số lượng bộ lọc luôn luôn là 2)?
    Cập nhật: đã có câu trả lời rằng JITC chỉ hoạt động ở cấp độ lớp. OK đã nhận nó.

Lý tưởng nhất, tôi muốn nhận được câu trả lời từ một người có hiểu biết sâu sắc về cách thức hoạt động của JITC.

Chi tiết điểm chuẩn chạy:

  • Đã thử trên các phiên bản mới nhất của Java 8 OpenJDK và Oracle HotSpot, kết quả tương tự
  • Các cờ Java đã sử dụng: -Xmx4g -Xms4g -server -Xbatch -XX: CICompilerCount = 2 (cũng có kết quả tương tự mà không có các cờ ưa thích)
  • Nhân tiện, tôi nhận được tỷ lệ thời gian chạy tương tự nếu tôi chỉ chạy nó vài tỷ lần trong một vòng lặp (không thông qua JMH), tức là đoạn mã thứ hai luôn nhanh hơn rõ ràng

Sản lượng chuẩn điển hình:

Benchmark (filterIndex) Chế độ Cnt Điểm Lỗi Units
LoopUnrollingBenchmark.runBenchmark 0 avgt 400 44,202 ± 0,224 ns / op
LoopUnrollingBenchmark.runBenchmark 1 avgt 400 38,347 ± 0,063 ns / op

(Dòng đầu tiên tương ứng với đoạn đầu tiên, dòng thứ hai - với dòng thứ hai.

Hoàn thành mã điểm chuẩn:

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
Trình biên dịch không thể đảm bảo rằng độ dài của mảng là 2. Tôi không chắc chắn nó sẽ hủy đăng ký ngay cả khi nó có thể.
marstran

1
@Setup(Level.Invocation): không chắc chắn nó giúp (xem javadoc).
GPI

3
Vì không có gì đảm bảo rằng mảng luôn luôn có độ dài 2, hai phương thức không làm cùng một thứ. Làm thế nào JIT có thể tự cho phép mình thay đổi cái đầu tiên thành cái thứ hai?
Andreas

@Andreas Tôi đề nghị bạn trả lời câu hỏi, nhưng giải thích tại sao JIT không thể hủy đăng ký trong trường hợp này so với một số trường hợp tương tự khác có thể
Alexander

1
@Alexander JIT có thể thấy rằng độ dài mảng không thể thay đổi sau khi tạo, bởi vì trường là final, nhưng JIT không thấy rằng tất cả các phiên bản của lớp sẽ có một mảng có độ dài 2. Để thấy rằng, nó sẽ phải đi sâu vào createLeafFilters()Phương pháp và phân tích mã đủ sâu để biết rằng mảng sẽ luôn dài 2. Tại sao bạn tin rằng trình tối ưu hóa JIT sẽ đi sâu vào mã của bạn?
Andreas

Câu trả lời:


10

TL; DR Lý do chính của sự khác biệt hiệu suất ở đây không liên quan đến việc hủy vòng lặp. Nó đúng hơn là đầu cơ kiểubộ đệm nội tuyến .

Chiến lược không kiểm soát

Trong thực tế, trong thuật ngữ HotSpot, các vòng lặp như vậy được coi là được tính và trong một số trường hợp nhất định, JVM có thể hủy đăng ký chúng. Không phải trong trường hợp của bạn mặc dù.

HotSpot có hai chiến lược hủy đăng ký vòng lặp: 1) hủy đăng ký tối đa, tức là loại bỏ hoàn toàn vòng lặp; hoặc 2) dán nhiều lần lặp lại liên tiếp với nhau.

Không thể kiểm soát tối đa có thể được thực hiện, chỉ khi biết số lần lặp chính xác .

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

Tuy nhiên, trong trường hợp của bạn, hàm có thể quay lại sớm sau lần lặp đầu tiên.

Unrolling có thể được áp dụng, nhưng điều kiện sau đây phá vỡ unrolling:

  // 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;
  }

Vì trong trường hợp của bạn, số chuyến đi dự kiến ​​ít hơn 2, HotSpot cho rằng nó không xứng đáng để hủy đăng ký thậm chí hai lần lặp. Lưu ý rằng lần lặp đầu tiên được trích xuất thành vòng lặp trước ( tối ưu hóa lột vòng lặp ), do đó, việc bỏ kiểm soát thực sự không có lợi cho lắm ở đây.

Kiểu đầu cơ

Trong phiên bản chưa được kiểm soát của bạn, có hai invokeinterfacemã byte khác nhau . Những trang web này có hai loại hồ sơ riêng biệt. Người nhận thứ nhất luôn luôn Filter1, và người nhận thứ hai luôn luôn Filter2. Vì vậy, về cơ bản, bạn có hai trang web gọi đơn hình và HotSpot hoàn toàn có thể thực hiện cả hai cuộc gọi - được gọi là "bộ đệm nội tuyến" có tỷ lệ truy cập 100% trong trường hợp này.

Với vòng lặp, chỉ có một invokeinterfacemã byte và chỉ có một loại hồ sơ được thu thập. HotSpot JVM thấy rằng filters[j].isOK()được gọi là 86% lần với Filter1người nhận và 14% lần với Filter2người nhận. Đây sẽ là một cuộc gọi lưỡng kim. May mắn thay, HotSpot cũng có thể gọi các cuộc gọi lưỡng tính theo dòng suy đoán. Nó nội tuyến cả hai mục tiêu với một nhánh có điều kiện. Tuy nhiên, trong trường hợp này, tỷ lệ trúng sẽ nhiều nhất là 86% và hiệu suất sẽ bị các nhánh dự đoán sai tương ứng ở cấp kiến ​​trúc.

Mọi thứ sẽ còn tồi tệ hơn, nếu bạn có 3 hoặc nhiều bộ lọc khác nhau. Trong trường hợp isOK()này sẽ là một cuộc gọi siêu mẫu mà HotSpot không thể thực hiện được. Vì vậy, mã được biên dịch sẽ chứa một lệnh gọi giao diện thực sự có tác động hiệu suất lớn hơn.

Tìm hiểu thêm về nội suy đầu cơ trong bài viết Công thức phương pháp ma thuật đen (Java) .

Phần kết luận

Để thực hiện các cuộc gọi ảo / giao diện nội tuyến, HotSpot JVM thu thập các cấu hình loại trên mỗi lệnh gọi mã byte. Nếu có một cuộc gọi ảo trong một vòng lặp, sẽ chỉ có một loại hồ sơ cho cuộc gọi, bất kể vòng lặp đó có được kiểm soát hay không.

Để tận dụng tốt nhất từ ​​tối ưu hóa cuộc gọi ảo, bạn cần phải phân tách vòng lặp theo cách thủ công, chủ yếu cho mục đích chia nhỏ các loại hồ sơ. HotSpot không thể làm điều này tự động cho đến nay.


cảm ơn vì câu trả lời tuyệt vời Chỉ để hoàn thiện: bạn có biết về bất kỳ kỹ thuật JITC nào có thể tạo mã cho một trường hợp cụ thể không?
Alexander

@Alexander HotSpot không tối ưu hóa mã cho một trường hợp cụ thể. Nó sử dụng số liệu thống kê thời gian chạy bao gồm các bộ đếm theo mã byte, hồ sơ loại, xác suất mục tiêu chi nhánh, v.v. Nếu bạn muốn tối ưu hóa mã cho một trường hợp cụ thể, hãy tạo một lớp riêng cho nó, theo cách thủ công hoặc tạo mã byte động.
apangin

13

Vòng lặp được trình bày có khả năng thuộc nhóm vòng lặp "không tính", là các vòng lặp mà số lần lặp không thể được xác định tại thời gian biên dịch cũng như thời gian chạy. Không chỉ vì tranh luận @Andreas về kích thước mảng mà còn vì điều kiện ngẫu nhiên break(đã từng nằm trong điểm chuẩn của bạn khi tôi viết bài đăng này).

Các trình biên dịch hiện đại không tích cực tối ưu hóa chúng, vì việc không kiểm soát các vòng lặp không được tính thường liên quan đến việc sao chép cả điều kiện thoát của vòng lặp, do đó chỉ cải thiện hiệu năng trong thời gian chạy nếu tối ưu hóa trình biên dịch tiếp theo có thể tối ưu hóa mã không được kiểm soát. Xem bài viết năm 2017 này để biết chi tiết nơi họ đưa ra đề xuất về cách hủy đăng ký những thứ đó.

Từ đó, giả định của bạn không cho rằng bạn đã thực hiện "hủy đăng ký thủ công" của vòng lặp. Bạn đang xem nó là một kỹ thuật unrolling vòng lặp cơ bản để chuyển đổi một lần lặp qua một mảng với sự phá vỡ có điều kiện thành một &&biểu thức boolean chuỗi. Tôi coi đây là một trường hợp khá đặc biệt và sẽ rất ngạc nhiên khi thấy một trình tối ưu hóa điểm nóng thực hiện tái cấu trúc phức tạp khi đang bay. Ở đây họ đang thảo luận về những gì nó thực sự có thể làm, có lẽ tài liệu tham khảo này là thú vị.

Điều này sẽ phản ánh gần hơn các cơ chế của việc không kiểm soát đương đại và có lẽ vẫn không ở đâu gần mã máy không được kiểm soát sẽ như thế nào:

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

Bạn đang kết luận, bởi vì một đoạn mã chạy nhanh hơn một đoạn mã khác mà vòng lặp không được kiểm soát. Ngay cả khi có, bạn vẫn có thể thấy sự khác biệt về thời gian chạy do thực tế là bạn đang so sánh các triển khai khác nhau.

Nếu bạn muốn đạt được sự chắc chắn hơn, có bộ phân tích / trình hiển thị jitwatch của các hoạt động Jit thực tế bao gồm mã máy (github) (slide thuyết trình) . Nếu cuối cùng có một cái gì đó để xem, tôi tin vào mắt mình hơn bất kỳ ý kiến ​​nào về những gì JIT có thể hoặc không thể nói chung, vì mọi trường hợp đều có chi tiết cụ thể. Ở đây, họ băn khoăn về những khó khăn để đi đến các tuyên bố chung cho các trường hợp cụ thể liên quan đến JIT và cung cấp một số liên kết thú vị.

Vì mục tiêu của bạn là thời gian chạy tối thiểu, a && b && c ...biểu mẫu có thể là hiệu quả nhất, nếu bạn không muốn phụ thuộc vào hy vọng cho việc không kiểm soát vòng lặp, ít nhất là hiệu quả hơn bất kỳ điều gì khác được trình bày. Nhưng bạn không thể có điều đó một cách chung chung. Với thành phần chức năng của java.util.Chức năng lại có chi phí rất lớn (mỗi Hàm là một lớp, mỗi cuộc gọi là một phương thức ảo cần gửi đi). Có lẽ trong một kịch bản như vậy, có thể có ý nghĩa để lật đổ cấp độ ngôn ngữ và tạo mã byte tùy chỉnh khi chạy. Mặt khác, &&logic cũng yêu cầu phân nhánh theo cấp độ mã byte và có thể tương đương với if / return (cũng không thể được tạo ra mà không có chi phí hoạt động).


chỉ là một phụ lục nhỏ: một vòng lặp được tính trong thế giới JVM là bất kỳ vòng lặp nào "chạy" trên int i = ....; i < ...; ++ibất kỳ vòng lặp nào khác thì không.
Eugene
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.