Tại sao xử lý một mảng được sắp xếp nhanh hơn xử lý một mảng chưa sắp xếp?


24446

Đây là một đoạn mã C ++ cho thấy một số hành vi rất đặc biệt. Vì một số lý do kỳ lạ, việc sắp xếp dữ liệu một cách kỳ diệu làm cho mã nhanh hơn gần gấp sáu lần:

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
  • Không có std::sort(data, data + arraySize);, mã chạy trong 11,54 giây.
  • Với dữ liệu được sắp xếp, mã chạy trong 1,93 giây.

Ban đầu, tôi nghĩ rằng đây có thể chỉ là một ngôn ngữ hoặc trình biên dịch dị thường, vì vậy tôi đã thử Java:

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

Với một kết quả tương tự nhưng ít cực đoan hơn.


Suy nghĩ đầu tiên của tôi là việc sắp xếp đưa dữ liệu vào bộ đệm, nhưng sau đó tôi nghĩ điều đó thật ngớ ngẩn vì mảng vừa được tạo.

  • Chuyện gì đang xảy ra vậy?
  • Tại sao xử lý một mảng được sắp xếp nhanh hơn xử lý một mảng chưa sắp xếp?

Mã này đang tóm tắt một số điều khoản độc lập, vì vậy thứ tự không quan trọng.


170

16
@SachinVerma Ra khỏi đỉnh đầu của tôi: 1) JVM cuối cùng có thể đủ thông minh để sử dụng các động tác có điều kiện. 2) Mã bị giới hạn bộ nhớ. 200M là quá lớn để phù hợp với bộ đệm CPU. Vì vậy, hiệu suất sẽ bị tắc nghẽn bởi băng thông bộ nhớ thay vì phân nhánh.
Bí ẩn

12
@ Bí ẩn, khoảng 2). Tôi nghĩ bảng dự đoán theo dõi các mẫu (không phân biệt các biến thực tế đã được kiểm tra cho mẫu đó) và thay đổi đầu ra dự đoán dựa trên lịch sử. Bạn có thể vui lòng cho tôi một lý do, tại sao một mảng siêu lớn sẽ không được hưởng lợi từ dự đoán chi nhánh?
Sachin Verma

15
@SachinVerma Nó có, nhưng khi mảng đó lớn, một yếu tố thậm chí còn lớn hơn có thể xuất hiện - băng thông bộ nhớ. Ký ức không bằng phẳng . Truy cập bộ nhớ rất chậm và có một lượng băng thông hạn chế. Để đơn giản hóa mọi thứ, chỉ có rất nhiều byte có thể được chuyển giữa CPU và bộ nhớ trong một khoảng thời gian cố định. Mã đơn giản như mã trong câu hỏi này có thể sẽ đạt đến giới hạn đó ngay cả khi nó bị chậm lại bởi các dự đoán sai. Điều này không xảy ra với một mảng 32768 (128KB) vì nó phù hợp với bộ đệm L2 của CPU.
Bí ẩn

13
Có một lỗ hổng bảo mật mới được gọi là BranchScope: cs.ucr.edu/~nael/pub/asplos18.pdf
Veve

Câu trả lời:


31794

Bạn là một nạn nhân của dự đoán chi nhánh thất bại.


Dự đoán chi nhánh là gì?

Hãy xem xét một ngã ba đường sắt:

Hình ảnh cho thấy một ngã ba đường sắt Hình ảnh của Mecanismo, qua Wikimedia Commons. Được sử dụng theo giấy phép CC-By-SA 3.0 .

Bây giờ để tranh luận, giả sử điều này trở lại vào những năm 1800 - trước khi liên lạc đường dài hoặc vô tuyến.

Bạn là người điều hành một ngã ba và bạn nghe thấy một chuyến tàu đang đến. Bạn không biết nên đi theo hướng nào. Bạn dừng tàu để hỏi tài xế họ muốn đi theo hướng nào. Và sau đó bạn thiết lập công tắc một cách thích hợp.

Xe lửa nặng và có nhiều quán tính. Vì vậy, họ mất mãi mãi để bắt đầu và chậm lại.

Có cách nào tốt hơn? Bạn đoán hướng tàu sẽ đi!

  • Nếu bạn đoán đúng, nó tiếp tục.
  • Nếu bạn đoán sai, thuyền trưởng sẽ dừng lại, sao lưu và la mắng bạn để lật công tắc. Sau đó, nó có thể khởi động lại con đường khác.

Nếu bạn đoán đúng mỗi lần , tàu sẽ không bao giờ phải dừng lại.
Nếu bạn đoán sai quá thường xuyên , tàu sẽ mất rất nhiều thời gian để dừng lại, sao lưu và khởi động lại.


Xem xét một câu lệnh if: Ở cấp độ bộ xử lý, đó là một lệnh rẽ nhánh:

Ảnh chụp màn hình của mã được biên dịch có chứa một câu lệnh if

Bạn là một bộ xử lý và bạn thấy một chi nhánh. Bạn không biết nó sẽ đi theo hướng nào. Bạn làm nghề gì? Bạn dừng thực thi và đợi cho đến khi các hướng dẫn trước hoàn thành. Sau đó, bạn tiếp tục xuống con đường chính xác.

Bộ xử lý hiện đại rất phức tạp và có đường ống dài. Vì vậy, họ mất mãi mãi để "làm nóng" và "chậm lại".

Có cách nào tốt hơn? Bạn đoán hướng đi của chi nhánh!

  • Nếu bạn đoán đúng, bạn tiếp tục thực hiện.
  • Nếu bạn đoán sai, bạn cần phải xả đường ống và quay trở lại nhánh. Sau đó, bạn có thể khởi động lại con đường khác.

Nếu bạn đoán đúng mọi lúc , việc thực thi sẽ không bao giờ phải dừng lại.
Nếu bạn đoán sai quá thường xuyên , bạn dành nhiều thời gian để trì hoãn, quay trở lại và khởi động lại.


Đây là dự đoán chi nhánh. Tôi thừa nhận đó không phải là sự tương tự tốt nhất vì tàu chỉ có thể báo hiệu hướng đi bằng cờ. Nhưng trong máy tính, bộ xử lý không biết một nhánh sẽ đi theo hướng nào cho đến giây cuối cùng.

Vì vậy, làm thế nào bạn sẽ đoán chiến lược để giảm thiểu số lần tàu phải sao lưu và đi xuống con đường khác? Bạn nhìn vào lịch sử đã qua! Nếu tàu đi trái 99% thời gian, thì bạn đoán trái. Nếu nó thay thế, sau đó bạn thay thế dự đoán của bạn. Nếu nó đi một chiều cứ sau ba lần, bạn đoán giống nhau ...

Nói cách khác, bạn cố gắng xác định một mô hình và làm theo nó. Đây là ít nhiều làm thế nào các dự đoán chi nhánh hoạt động.

Hầu hết các ứng dụng có các nhánh hoạt động tốt. Vì vậy, các dự đoán chi nhánh hiện đại thường sẽ đạt tỷ lệ trúng> 90%. Nhưng khi phải đối mặt với các nhánh không thể đoán trước mà không có mô hình dễ nhận biết, các dự đoán nhánh hầu như vô dụng.

Đọc thêm: bài viết "Dự đoán chi nhánh" trên Wikipedia .


Như được gợi ý từ phía trên, thủ phạm chính là câu lệnh if này:

if (data[c] >= 128)
    sum += data[c];

Lưu ý rằng dữ liệu được phân phối đồng đều trong khoảng từ 0 đến 255. Khi dữ liệu được sắp xếp, khoảng nửa đầu của các lần lặp sẽ không nhập câu lệnh if. Sau đó, tất cả chúng sẽ nhập câu lệnh if.

Điều này rất thân thiện với người dự đoán chi nhánh vì chi nhánh liên tiếp đi cùng một hướng nhiều lần. Ngay cả một bộ đếm bão hòa đơn giản cũng sẽ dự đoán chính xác nhánh ngoại trừ một vài lần lặp sau khi nó chuyển hướng.

Hình dung nhanh:

T = branch taken
N = branch not taken

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (easy to predict)

Tuy nhiên, khi dữ liệu hoàn toàn ngẫu nhiên, bộ dự báo nhánh sẽ vô dụng, vì nó không thể dự đoán dữ liệu ngẫu nhiên. Do đó, có thể sẽ có khoảng 50% hiểu sai (không tốt hơn so với đoán ngẫu nhiên).

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (completely random - hard to predict)

Vậy thì cái gì có thể làm được?

Nếu trình biên dịch không thể tối ưu hóa nhánh thành một động thái có điều kiện, bạn có thể thử một số hack nếu bạn sẵn sàng hy sinh khả năng đọc để thực hiện.

Thay thế:

if (data[c] >= 128)
    sum += data[c];

với:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

Điều này loại bỏ nhánh và thay thế nó bằng một số hoạt động bitwise.

(Lưu ý rằng bản hack này không hoàn toàn tương đương với câu lệnh if gốc. Nhưng trong trường hợp này, nó hợp lệ cho tất cả các giá trị đầu vào của data[].)

Điểm chuẩn: Core i7 920 @ 3.5 GHz

C ++ - Visual Studio 2010 - Phát hành x64

//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587

Java - NetBeans 7.1.1 JDK 7 - x64

//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823

Quan sát:

  • Với chi nhánh: Có một sự khác biệt rất lớn giữa dữ liệu được sắp xếp và chưa được sắp xếp.
  • Với Hack: Không có sự khác biệt giữa dữ liệu được sắp xếp và chưa được sắp xếp.
  • Trong trường hợp C ++, hack thực sự chậm hơn một chút so với nhánh khi dữ liệu được sắp xếp.

Một nguyên tắc chung là tránh phân nhánh phụ thuộc dữ liệu vào các vòng quan trọng (như trong ví dụ này).


Cập nhật:

  • GCC 4.6.1 có -O3hoặc -ftree-vectorizetrên x64 có thể tạo ra một động thái có điều kiện. Vì vậy, không có sự khác biệt giữa dữ liệu được sắp xếp và chưa được sắp xếp - cả hai đều nhanh.

    (Hoặc hơi nhanh: đối với trường hợp đã được sắp xếp, cmovcó thể chậm hơn, đặc biệt là nếu GCC đưa nó vào đường dẫn quan trọng thay vì chỉ add, đặc biệt là trên Intel trước Broadwell, nơi cmovcó độ trễ 2 chu kỳ: cờ tối ưu hóa gcc -O3 làm cho mã chậm hơn -O2 )

  • VC ++ 2010 không thể tạo ra các động thái có điều kiện cho nhánh này ngay cả dưới /Ox.

  • Trình biên dịch Intel C ++ (ICC) 11 làm một điều kỳ diệu. Nó trao đổi hai vòng lặp , do đó nâng các nhánh không thể đoán trước vào vòng lặp bên ngoài. Vì vậy, nó không chỉ miễn dịch với các dự đoán sai, mà còn nhanh gấp đôi so với bất kỳ thứ gì mà VC ++ và GCC có thể tạo ra! Nói cách khác, ICC đã tận dụng vòng kiểm tra để đánh bại điểm chuẩn ...

  • Nếu bạn cung cấp cho trình biên dịch Intel mã không phân nhánh, nó sẽ hoàn toàn hợp lý hóa nó ... và cũng nhanh như với nhánh (với trao đổi vòng lặp).

Điều này cho thấy rằng ngay cả các trình biên dịch hiện đại trưởng thành cũng có thể thay đổi mạnh mẽ về khả năng tối ưu hóa mã ...


256
Hãy xem câu hỏi tiếp theo này: stackoverflow.com/questions/11276291/NH Trình biên dịch Intel đã đến khá gần để hoàn toàn thoát khỏi vòng lặp bên ngoài.
Bí ẩn

24
@Mysticial Làm thế nào để tàu / trình biên dịch biết rằng nó đã nhập sai đường dẫn?
onmyway133

26
@obe: Với các cấu trúc bộ nhớ phân cấp, không thể nói chi phí của bộ nhớ cache sẽ là bao nhiêu. Nó có thể bỏ lỡ trong L1 và được giải quyết trong L2 chậm hơn hoặc bỏ lỡ trong L3 và được giải quyết trong bộ nhớ hệ thống. Tuy nhiên, trừ khi vì một lý do kỳ quái nào đó, bộ nhớ cache này bị mất khiến bộ nhớ trong trang không cư trú được tải từ đĩa, bạn có một điểm tốt ... bộ nhớ không có thời gian truy cập trong phạm vi mili giây trong khoảng 25-30 năm ;)
Andon M. Coleman

21
Nguyên tắc cơ bản để viết mã hiệu quả trên bộ xử lý hiện đại: Mọi thứ làm cho việc thực thi chương trình của bạn đều đặn hơn (ít không đồng đều) sẽ có xu hướng làm cho nó hiệu quả hơn. Sắp xếp trong ví dụ này có hiệu ứng này vì dự đoán nhánh. Truy cập địa phương (chứ không phải truy cập ngẫu nhiên xa và rộng) có hiệu ứng này vì bộ nhớ cache.
Lutz Prechelt

22
@Seepeep Có. Bộ xử lý vẫn có dự đoán chi nhánh. Nếu bất cứ điều gì đã thay đổi, đó là trình biên dịch. Ngày nay, tôi cá là họ có nhiều khả năng làm những gì ICC và GCC (dưới -3) đã làm ở đây - nghĩa là loại bỏ chi nhánh. Cho biết câu hỏi này có cấu hình cao như thế nào, rất có thể các trình biên dịch đã được cập nhật để xử lý cụ thể trường hợp trong câu hỏi này. Việc chắc chắn chú ý đến SO. Và nó đã xảy ra với câu hỏi này , nơi GCC đã được cập nhật trong vòng 3 tuần. Tôi không hiểu tại sao nó cũng không xảy ra ở đây.
Bí ẩn 6/12/2015

4086

Chi nhánh dự đoán.

Với một mảng được sắp xếp, điều kiện data[c] >= 128đầu tiên là falsemột chuỗi các giá trị, sau đó trở thành truecho tất cả các giá trị sau này. Điều đó thật dễ đoán. Với một mảng chưa được sắp xếp, bạn phải trả chi phí phân nhánh.


105
Dự đoán nhánh có hoạt động tốt hơn trên các mảng được sắp xếp so với các mảng có các mẫu khác nhau không? Ví dụ: đối với mảng -> {10, 5, 20, 10, 40, 20, ...} phần tử tiếp theo trong mảng từ mẫu là 80. Loại mảng này sẽ được tăng tốc theo dự đoán nhánh trong mà phần tử tiếp theo là 80 ở đây nếu mẫu được theo sau? Hoặc nó thường chỉ giúp với các mảng được sắp xếp?
Adam Freeman

133
Vì vậy, về cơ bản mọi thứ tôi thường học về big-O nằm ngoài cửa sổ? Tốt hơn để phát sinh một chi phí phân loại hơn một chi phí phân nhánh?
Agrim Pathak

133
@AgrimPathak Điều đó phụ thuộc. Đối với đầu vào không quá lớn, một thuật toán có độ phức tạp cao hơn nhanh hơn thuật toán có độ phức tạp thấp hơn khi các hằng số nhỏ hơn đối với thuật toán có độ phức tạp cao hơn. Trường hợp điểm hòa vốn có thể khó dự đoán. Ngoài ra, so sánh điều này , địa phương là quan trọng. Big-O rất quan trọng, nhưng nó không phải là tiêu chí duy nhất cho hiệu suất.
Daniel Fischer

65
Khi nào dự đoán chi nhánh diễn ra? Khi nào ngôn ngữ sẽ biết rằng mảng được sắp xếp? Tôi đang nghĩ về tình huống của mảng trông giống như: [1,2,3,4,5, ... 998.999,1000, 3, 10001, 10002]? 3 tối nghĩa này sẽ tăng thời gian chạy? Nó sẽ dài như mảng chưa sắp xếp?
Filip Bartuzi

63
@FilipBartuzi Dự đoán chi nhánh diễn ra trong bộ xử lý, dưới mức ngôn ngữ (nhưng ngôn ngữ có thể cung cấp các cách để cho trình biên dịch biết những gì có thể xảy ra, do đó trình biên dịch có thể phát ra mã phù hợp với điều đó). Trong ví dụ của bạn, thứ tự 3 không theo thứ tự sẽ dẫn đến sự hiểu sai về nhánh (đối với các điều kiện thích hợp, trong đó 3 cho kết quả khác với 1000), và do đó, việc xử lý mảng đó có thể sẽ mất vài chục hoặc trăm nano giây hơn một sắp xếp mảng sẽ, hầu như không bao giờ đáng chú ý. Những gì chi phí thời gian là tôi tỷ lệ sai lầm cao, một sai lầm trên 1000 không nhiều.
Daniel Fischer

3310

Lý do tại sao hiệu suất cải thiện đáng kể khi dữ liệu được sắp xếp là hình phạt dự đoán chi nhánh được loại bỏ, như được giải thích tuyệt vời trong câu trả lời của Mysticial .

Bây giờ, nếu chúng ta nhìn vào mã

if (data[c] >= 128)
    sum += data[c];

chúng ta có thể thấy rằng ý nghĩa của if... else...nhánh đặc biệt này là thêm một cái gì đó khi một điều kiện được thỏa mãn. Loại nhánh này có thể dễ dàng chuyển thành một câu lệnh di chuyển có điều kiện , sẽ được biên dịch thành một lệnh di chuyển có điều kiện : cmovl, trong một x86hệ thống. Chi nhánh và do đó hình phạt dự đoán chi nhánh tiềm năng được loại bỏ.

Trong C, do đó C++, báo cáo kết quả, trong đó sẽ biên dịch trực tiếp (không có bất kỳ tối ưu hóa) vào hướng dẫn di chuyển có điều kiện ở x86, là các nhà điều hành ternary ... ? ... : .... Vì vậy, chúng tôi viết lại tuyên bố trên thành một tương đương:

sum += data[c] >=128 ? data[c] : 0;

Trong khi duy trì khả năng đọc, chúng ta có thể kiểm tra hệ số tăng tốc.

Trên Intel Core i7 -2600K @ 3,4 GHz và Chế độ phát hành Visual Studio 2010, điểm chuẩn là (định dạng được sao chép từ Mysticial):

x86

//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

x64

//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

Kết quả là mạnh mẽ trong nhiều bài kiểm tra. Chúng tôi nhận được một sự tăng tốc tuyệt vời khi kết quả chi nhánh là không thể dự đoán được, nhưng chúng tôi chịu đựng một chút khi dự đoán được. Trong thực tế, khi sử dụng một động thái có điều kiện, hiệu suất là như nhau bất kể mẫu dữ liệu.

Bây giờ hãy xem xét kỹ hơn bằng cách điều tra x86lắp ráp mà họ tạo ra. Để đơn giản, chúng tôi sử dụng hai chức năng max1max2.

max1sử dụng nhánh có điều kiện if... else ...:

int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

max2sử dụng toán tử ternary ... ? ... : ...:

int max2(int a, int b) {
    return a > b ? a : b;
}

Trên máy x86-64, GCC -Stạo cụm bên dưới.

:max1
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax
    jle     .L2
    movl    -4(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp     .L4
.L2:
    movl    -8(%rbp), %eax
    movl    %eax, -12(%rbp)
.L4:
    movl    -12(%rbp), %eax
    leave
    ret

:max2
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    %eax, -8(%rbp)
    cmovge  -8(%rbp), %eax
    leave
    ret

max2sử dụng ít mã hơn do sử dụng hướng dẫn cmovge. Nhưng lợi ích thực sự là max2không liên quan đến các cú nhảy nhánh jmp, sẽ có một hình phạt hiệu suất đáng kể nếu kết quả dự đoán là không đúng.

Vậy tại sao một động thái có điều kiện thực hiện tốt hơn?

Trong một x86bộ xử lý điển hình , việc thực hiện một lệnh được chia thành nhiều giai đoạn. Roughly, chúng tôi có phần cứng khác nhau để đối phó với các giai đoạn khác nhau. Vì vậy, chúng ta không phải đợi một hướng dẫn kết thúc để bắt đầu một hướng dẫn mới. Điều này được gọi là đường ống .

Trong trường hợp nhánh, hướng dẫn sau được xác định bởi lệnh trước, vì vậy chúng ta không thể thực hiện đường ống. Chúng ta phải chờ đợi hoặc dự đoán.

Trong trường hợp di chuyển có điều kiện, lệnh di chuyển có điều kiện thực hiện được chia thành nhiều giai đoạn, nhưng các giai đoạn trước đó thích FetchDecodekhông phụ thuộc vào kết quả của lệnh trước đó; chỉ giai đoạn sau mới cần kết quả. Vì vậy, chúng tôi chờ một phần thời gian thực hiện của một lệnh. Đây là lý do tại sao phiên bản di chuyển có điều kiện chậm hơn so với chi nhánh khi dự đoán dễ dàng.

Cuốn sách Hệ thống máy tính: Quan điểm của lập trình viên, ấn bản thứ hai giải thích điều này một cách chi tiết. Bạn có thể kiểm tra Mục 3.6.6 để biết Hướng dẫn di chuyển có điều kiện , toàn bộ Chương 4 về Kiến trúc bộ xử lý và Phần 5.11.2 để biết cách xử lý đặc biệt đối với các hình phạt dự đoán và hiểu sai chi nhánh .

Đôi khi, một số trình biên dịch hiện đại có thể tối ưu hóa mã của chúng tôi để lắp ráp với hiệu suất tốt hơn, đôi khi một số trình biên dịch không thể (mã được đề cập là sử dụng trình biên dịch gốc của Visual Studio). Biết được sự khác biệt hiệu năng giữa nhánh di chuyển và điều kiện di chuyển khi không thể đoán trước có thể giúp chúng ta viết mã với hiệu suất tốt hơn khi kịch bản trở nên phức tạp đến mức trình biên dịch không thể tự động tối ưu hóa chúng.


7
@ BlueRaja-DannyPflughoeft Đây là phiên bản chưa được tối ưu hóa. Trình biên dịch KHÔNG tối ưu hóa toán tử ternary, nó chỉ dịch nó. GCC có thể tối ưu hóa nếu-thì nếu được cung cấp đủ mức tối ưu hóa, tuy nhiên, điều này cho thấy sức mạnh của việc di chuyển có điều kiện và tối ưu hóa thủ công tạo ra sự khác biệt.
WiSaGaN

100
@WiSaGaN Mã không biểu thị gì cả, vì hai đoạn mã của bạn được biên dịch thành cùng một mã máy. Điều cực kỳ quan trọng là mọi người không có ý tưởng rằng bằng cách nào đó câu lệnh if trong ví dụ của bạn khác với terenary trong ví dụ của bạn. Đúng là bạn sở hữu sự tương đồng trong đoạn cuối của bạn, nhưng điều đó không xóa đi sự thật rằng phần còn lại của ví dụ là có hại.
Justin L.

55
@WiSaGaN Downvote của tôi chắc chắn sẽ biến thành upvote nếu bạn sửa đổi câu trả lời của mình để xóa -O0ví dụ sai lệch và để hiển thị sự khác biệt về mã asm được tối ưu hóa trên hai testcase của bạn.
Justin L.

56
@UpAndAdam Tại thời điểm thử nghiệm, VS2010 không thể tối ưu hóa nhánh ban đầu thành một động thái có điều kiện ngay cả khi chỉ định mức tối ưu hóa cao, trong khi gcc có thể.
WiSaGaN

9
Thủ thuật toán tử ternary này hoạt động tuyệt vời cho Java. Sau khi đọc câu trả lời của Mystical, tôi đã tự hỏi có thể làm gì cho Java để tránh dự đoán nhánh sai vì Java không có gì tương đương với -O3. toán tử ternary: 2.1943s và bản gốc: 6.0303s.
Kin Cheung

2271

Nếu bạn tò mò về việc tối ưu hóa nhiều hơn nữa có thể được thực hiện cho mã này, hãy xem xét điều này:

Bắt đầu với vòng lặp ban đầu:

for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

Với trao đổi vòng lặp, chúng ta có thể thay đổi vòng lặp này thành:

for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

Sau đó, bạn có thể thấy rằng ifđiều kiện là không đổi trong suốt quá trình thực hiện ivòng lặp, vì vậy bạn có thể kéo ifra:

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

Sau đó, bạn thấy rằng vòng lặp bên trong có thể được thu gọn thành một biểu thức duy nhất, giả sử mô hình dấu phẩy động cho phép nó ( /fp:faství dụ như được ném)

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

Cái đó nhanh hơn 100.000 lần so với trước đây.


276
Nếu bạn muốn gian lận, bạn cũng có thể lấy phép nhân bên ngoài vòng lặp và thực hiện tổng * = 100000 sau vòng lặp.
Jyaif

78
@Michael - Tôi tin rằng ví dụ này thực sự là một ví dụ về tối ưu hóa vòng lặp bất biến (LIH) và KHÔNG trao đổi vòng lặp . Trong trường hợp này, toàn bộ vòng lặp bên trong độc lập với vòng lặp bên ngoài và do đó có thể được kéo ra khỏi vòng lặp bên ngoài, trong đó kết quả chỉ đơn giản là nhân với tổng icủa một đơn vị = 1e5. Nó không làm cho sự khác biệt đến kết quả cuối cùng, nhưng tôi chỉ muốn thiết lập bản ghi thẳng vì đây là một trang thường xuyên như vậy.
Yair Altman

54
Mặc dù không theo tinh thần đơn giản là hoán đổi các vòng lặp, phần bên trong iftại thời điểm này có thể được chuyển đổi thành: sum += (data[j] >= 128) ? data[j] * 100000 : 0;trình biên dịch có thể giảm xuống cmovgehoặc tương đương.
Alex North-Keys

43
Vòng lặp bên ngoài là để làm cho thời gian của vòng lặp bên trong đủ lớn để cấu hình. Vì vậy, tại sao bạn sẽ trao đổi vòng lặp. Cuối cùng, vòng lặp đó sẽ được gỡ bỏ.
saurabheights

34
@saurabheights: Câu hỏi sai: tại sao trình biên dịch KHÔNG lặp vòng lặp. Microbenchmark rất khó;)
Matthieu M.

1884

Không nghi ngờ gì, một số người trong chúng ta sẽ quan tâm đến các cách xác định mã có vấn đề đối với công cụ dự đoán nhánh của CPU. Công cụ Valgrind cachegrindcó trình giả lập dự đoán nhánh, được bật bằng cách sử dụng --branch-sim=yescờ. Chạy nó qua các ví dụ trong câu hỏi này, với số vòng lặp bên ngoài giảm xuống còn 10000 và được biên dịch với g++, cho ra các kết quả sau:

Sắp xếp

==32551== Branches:        656,645,130  (  656,609,208 cond +    35,922 ind)
==32551== Mispredicts:         169,556  (      169,095 cond +       461 ind)
==32551== Mispred rate:            0.0% (          0.0%     +       1.2%   )

Chưa sắp xếp:

==32555== Branches:        655,996,082  (  655,960,160 cond +  35,922 ind)
==32555== Mispredicts:     164,073,152  (  164,072,692 cond +     460 ind)
==32555== Mispred rate:           25.0% (         25.0%     +     1.2%   )

Đi sâu vào đầu ra từng dòng được sản xuất bởi cg_annotatechúng tôi thấy cho vòng lặp được đề cập:

Sắp xếp

          Bc    Bcm Bi Bim
      10,001      4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .      .  .   .      {
           .      .  .   .          // primary loop
 327,690,000 10,016  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .      .  .   .          {
 327,680,000 10,006  0   0              if (data[c] >= 128)
           0      0  0   0                  sum += data[c];
           .      .  .   .          }
           .      .  .   .      }

Chưa sắp xếp:

          Bc         Bcm Bi Bim
      10,001           4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .           .  .   .      {
           .           .  .   .          // primary loop
 327,690,000      10,038  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .           .  .   .          {
 327,680,000 164,050,007  0   0              if (data[c] >= 128)
           0           0  0   0                  sum += data[c];
           .           .  .   .          }
           .           .  .   .      }

Điều này cho phép bạn dễ dàng xác định dòng có vấn đề - trong phiên bản chưa được sắp xếp, if (data[c] >= 128)dòng này gây ra 164.050.007 nhánh điều kiện Bcmdự đoán sai ( ) theo mô hình dự đoán nhánh của bộ nhớ cache , trong khi nó chỉ gây ra 10,006 trong phiên bản được sắp xếp.


Ngoài ra, trên Linux, bạn có thể sử dụng hệ thống con bộ đếm hiệu suất để thực hiện cùng một tác vụ, nhưng với hiệu suất gốc sử dụng bộ đếm CPU.

perf stat ./sumtest_sorted

Sắp xếp

 Performance counter stats for './sumtest_sorted':

  11808.095776 task-clock                #    0.998 CPUs utilized          
         1,062 context-switches          #    0.090 K/sec                  
            14 CPU-migrations            #    0.001 K/sec                  
           337 page-faults               #    0.029 K/sec                  
26,487,882,764 cycles                    #    2.243 GHz                    
41,025,654,322 instructions              #    1.55  insns per cycle        
 6,558,871,379 branches                  #  555.455 M/sec                  
       567,204 branch-misses             #    0.01% of all branches        

  11.827228330 seconds time elapsed

Chưa sắp xếp:

 Performance counter stats for './sumtest_unsorted':

  28877.954344 task-clock                #    0.998 CPUs utilized          
         2,584 context-switches          #    0.089 K/sec                  
            18 CPU-migrations            #    0.001 K/sec                  
           335 page-faults               #    0.012 K/sec                  
65,076,127,595 cycles                    #    2.253 GHz                    
41,032,528,741 instructions              #    0.63  insns per cycle        
 6,560,579,013 branches                  #  227.183 M/sec                  
 1,646,394,749 branch-misses             #   25.10% of all branches        

  28.935500947 seconds time elapsed

Nó cũng có thể thực hiện chú thích mã nguồn với sự phân tách.

perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
 Percent |      Source code & Disassembly of sumtest_unsorted
------------------------------------------------
...
         :                      sum += data[c];
    0.00 :        400a1a:       mov    -0x14(%rbp),%eax
   39.97 :        400a1d:       mov    %eax,%eax
    5.31 :        400a1f:       mov    -0x20040(%rbp,%rax,4),%eax
    4.60 :        400a26:       cltq   
    0.00 :        400a28:       add    %rax,-0x30(%rbp)
...

Xem hướng dẫn hiệu suất để biết thêm chi tiết.


74
Điều này thật đáng sợ, trong danh sách chưa được sắp xếp, sẽ có 50% cơ hội trúng add. Bằng cách nào đó, dự đoán chi nhánh chỉ có tỷ lệ bỏ lỡ 25%, làm thế nào nó có thể làm tốt hơn 50% bỏ lỡ?
TallBrian

128
@ height.b.lo: 25% là của tất cả các nhánh - có hai nhánh trong vòng lặp, một nhánh cho data[c] >= 128(có tỷ lệ bỏ lỡ 50% như bạn đề xuất) và một cho điều kiện vòng lặp c < arraySizecó tỷ lệ bỏ lỡ ~ 0% .
phê

1340

Tôi chỉ đọc lên câu hỏi này và câu trả lời của nó, và tôi cảm thấy thiếu một câu trả lời.

Một cách phổ biến để loại bỏ dự đoán chi nhánh mà tôi thấy hoạt động đặc biệt tốt trong các ngôn ngữ được quản lý là tra cứu bảng thay vì sử dụng một nhánh (mặc dù tôi đã không kiểm tra nó trong trường hợp này).

Cách tiếp cận này hoạt động nói chung nếu:

  1. đó là một bảng nhỏ và có khả năng được lưu trong bộ xử lý và
  2. bạn đang chạy mọi thứ trong một vòng lặp khá chặt chẽ và / hoặc bộ xử lý có thể tải trước dữ liệu.

Bối cảnh và tại sao

Từ góc độ bộ xử lý, bộ nhớ của bạn chậm. Để bù cho sự khác biệt về tốc độ, một vài bộ nhớ cache được tích hợp vào bộ xử lý của bạn (bộ đệm L1 / L2). Vì vậy, hãy tưởng tượng rằng bạn đang thực hiện các tính toán tốt đẹp của mình và nhận ra rằng bạn cần một phần bộ nhớ. Bộ xử lý sẽ có được hoạt động 'tải' của nó và tải phần bộ nhớ vào bộ đệm - và sau đó sử dụng bộ đệm để thực hiện phần còn lại của các phép tính. Vì bộ nhớ tương đối chậm, 'tải' này sẽ làm chậm chương trình của bạn.

Giống như dự đoán nhánh, điều này đã được tối ưu hóa trong bộ xử lý Pentium: bộ xử lý dự đoán rằng nó cần tải một phần dữ liệu và cố gắng tải dữ liệu đó vào bộ đệm trước khi thao tác thực sự chạm vào bộ đệm. Như chúng ta đã thấy, dự đoán nhánh đôi khi sai lầm khủng khiếp - trong trường hợp xấu nhất bạn cần quay lại và thực sự chờ tải bộ nhớ, sẽ mất mãi mãi ( nói cách khác: thất bại dự đoán nhánh là xấu, bộ nhớ tải sau khi một dự đoán chi nhánh thất bại chỉ là khủng khiếp! ).

May mắn cho chúng ta, nếu có thể dự đoán được kiểu truy cập bộ nhớ, bộ xử lý sẽ tải nó trong bộ đệm nhanh và tất cả đều ổn.

Điều đầu tiên chúng ta cần biết là cái gì nhỏ ? Mặc dù nhỏ hơn thường tốt hơn, nhưng một nguyên tắc nhỏ là bám vào các bảng tra cứu có kích thước <= 4096 byte. Là giới hạn trên: nếu bảng tra cứu của bạn lớn hơn 64K, có lẽ bạn nên xem xét lại.

Xây dựng bảng

Vì vậy, chúng tôi đã tìm ra rằng chúng tôi có thể tạo ra một bảng nhỏ. Điều tiếp theo cần làm là có được một chức năng tra cứu tại chỗ. Các hàm tra cứu thường là các hàm nhỏ sử dụng một vài thao tác số nguyên cơ bản (và, hoặc, xor, shift, add, remove và có lẽ là nhân). Bạn muốn dịch đầu vào của mình được dịch bởi chức năng tra cứu thành một loại 'khóa duy nhất' trong bảng của bạn, sau đó chỉ cần cung cấp cho bạn câu trả lời của tất cả công việc bạn muốn nó thực hiện.

Trong trường hợp này:> = 128 có nghĩa là chúng ta có thể giữ giá trị, <128 có nghĩa là chúng ta thoát khỏi nó. Cách dễ nhất để làm điều đó là sử dụng 'VÀ': nếu chúng tôi giữ nó, chúng tôi VÀ nó với 7FFFFFFF; nếu chúng ta muốn loại bỏ nó, chúng ta VÀ nó với 0. Cũng lưu ý rằng 128 là lũy thừa của 2 - vì vậy chúng ta có thể tiếp tục và tạo một bảng gồm 32768/128 số nguyên và điền vào đó một số 0 và rất nhiều 7FFFFFFFF.

Ngôn ngữ được quản lý

Bạn có thể tự hỏi tại sao điều này hoạt động tốt trong các ngôn ngữ được quản lý. Rốt cuộc, các ngôn ngữ được quản lý kiểm tra ranh giới của các mảng với một nhánh để đảm bảo bạn không gây rối ...

Không hẳn là chính xác lắm... :-)

Đã có khá nhiều công việc loại bỏ chi nhánh này cho các ngôn ngữ được quản lý. Ví dụ:

for (int i = 0; i < array.Length; ++i)
{
   // Use array[i]
}

Trong trường hợp này, trình biên dịch rõ ràng rằng điều kiện biên sẽ không bao giờ bị ảnh hưởng. Ít nhất là trình biên dịch Microsoft JIT (nhưng tôi hy vọng Java cũng làm những điều tương tự) sẽ chú ý điều này và loại bỏ hoàn toàn việc kiểm tra. WOW, điều đó có nghĩa là không có chi nhánh. Tương tự, nó sẽ đối phó với các trường hợp rõ ràng khác.

Nếu bạn gặp rắc rối với việc tra cứu bằng các ngôn ngữ được quản lý - điều quan trọng là thêm & 0x[something]FFFchức năng tra cứu của bạn để kiểm tra ranh giới có thể dự đoán được - và xem nó sẽ nhanh hơn.

Kết quả của vụ án này

// Generate data
int arraySize = 32768;
int[] data = new int[arraySize];

Random random = new Random(0);
for (int c = 0; c < arraySize; ++c)
{
    data[c] = random.Next(256);
}

/*To keep the spirit of the code intact, I'll make a separate lookup table
(I assume we cannot modify 'data' or the number of loops)*/

int[] lookup = new int[256];

for (int c = 0; c < 256; ++c)
{
    lookup[c] = (c >= 128) ? c : 0;
}

// Test
DateTime startTime = System.DateTime.Now;
long sum = 0;

for (int i = 0; i < 100000; ++i)
{
    // Primary loop
    for (int j = 0; j < arraySize; ++j)
    {
        /* Here you basically want to use simple operations - so no
        random branches, but things like &, |, *, -, +, etc. are fine. */
        sum += lookup[data[j]];
    }
}

DateTime endTime = System.DateTime.Now;
Console.WriteLine(endTime - startTime);
Console.WriteLine("sum = " + sum);
Console.ReadLine();

57
Bạn muốn bỏ qua công cụ dự đoán chi nhánh, tại sao? Đó là một sự tối ưu hóa.
Dustin Oprea

108
Bởi vì không có chi nhánh nào tốt hơn một chi nhánh :-) Trong rất nhiều tình huống, điều này chỉ đơn giản là nhanh hơn rất nhiều ... nếu bạn đang tối ưu hóa, nó chắc chắn đáng để thử. Họ cũng sử dụng nó khá nhiều trong f.ex. Graphics.stanford.edu/~seander/bithacks.html
atlaste

36
Trong các bảng tra cứu chung có thể nhanh, nhưng bạn đã chạy thử nghiệm cho tình trạng cụ thể này chưa? Bạn vẫn sẽ có một điều kiện chi nhánh trong mã của mình, chỉ bây giờ nó được chuyển sang phần tạo bảng tra cứu. Bạn vẫn sẽ không nhận được sự tăng cường hoàn hảo của mình
Zain Rizvi

38
@Zain nếu bạn thực sự muốn biết ... Có: 15 giây với nhánh và 10 với phiên bản của tôi. Bất kể, đó là một kỹ thuật hữu ích để biết một trong hai cách.
đúng

42
Tại sao không sum += lookup[data[j]]nơi lookuplà một mảng với 256 mục, những người đầu tiên là zero và những người cuối cùng là tương đương với chỉ số?
Kris Vandermotten

1200

Vì dữ liệu được phân phối trong khoảng từ 0 đến 255 khi mảng được sắp xếp, khoảng nửa đầu lặp lại sẽ không nhập if-statement ( ifcâu lệnh được chia sẻ bên dưới).

if (data[c] >= 128)
    sum += data[c];

Câu hỏi là: Điều gì làm cho câu lệnh trên không được thực thi trong một số trường hợp nhất định như trong trường hợp dữ liệu được sắp xếp? Ở đây có "dự đoán chi nhánh". Công cụ dự đoán nhánh là một mạch kỹ thuật số cố gắng đoán xem một nhánh (ví dụ như một if-then-elsecấu trúc) sẽ đi trước khi điều này được biết chắc chắn. Mục đích của bộ dự báo nhánh là cải thiện dòng chảy trong đường ống dẫn. Chi nhánh dự đoán đóng một vai trò quan trọng trong việc đạt được hiệu suất cao!

Hãy làm một số điểm đánh dấu để hiểu nó tốt hơn

Hiệu suất của một phân tầng ifphụ thuộc vào việc tình trạng của nó có mẫu có thể dự đoán được hay không. Nếu điều kiện luôn luôn đúng hoặc luôn luôn sai, logic dự đoán nhánh trong bộ xử lý sẽ chọn mẫu. Mặt khác, nếu mô hình không thể đoán trước được, thì phần ifnày sẽ đắt hơn nhiều.

Hãy đo hiệu suất của vòng lặp này với các điều kiện khác nhau:

for (int i = 0; i < max; i++)
    if (condition)
        sum++;

Dưới đây là thời gian của vòng lặp với các mẫu đúng-sai khác nhau:

Condition                Pattern             Time (ms)
-------------------------------------------------------
(i & 0×80000000) == 0    T repeated          322

(i & 0xffffffff) == 0    F repeated          276

(i & 1) == 0             TF alternating      760

(i & 3) == 0             TFFFTFFF           513

(i & 2) == 0             TTFFTTFF           1675

(i & 4) == 0             TTTTFFFFTTTTFFFF   1275

(i & 8) == 0             8T 8F 8T 8F        752

(i & 16) == 0            16T 16F 16T 16F    490

Một “ xấu ” mô hình đúng-sai có thể làm cho một if-statement lên đến sáu lần chậm hơn so với một “ tốt mô hình”! Tất nhiên, mẫu nào tốt và xấu phụ thuộc vào các hướng dẫn chính xác được tạo bởi trình biên dịch và trên bộ xử lý cụ thể.

Vì vậy, không có nghi ngờ về tác động của dự đoán chi nhánh đến hiệu suất!


23
@MooingDuck 'Vì nó sẽ không tạo ra sự khác biệt - giá trị đó có thể là bất cứ điều gì, nhưng nó vẫn sẽ nằm trong giới hạn của các ngưỡng này. Vậy tại sao hiển thị một giá trị ngẫu nhiên khi bạn đã biết các giới hạn? Mặc dù tôi đồng ý rằng bạn có thể hiển thị một cái vì mục đích hoàn chỉnh, và 'chỉ vì cái quái quỷ đó'.
cst1992

24
@ cst1992: Hiện tại thời điểm chậm nhất của anh ấy là TTFFTTFFTTFF, dường như, đối với mắt người của tôi, khá dễ đoán. Ngẫu nhiên vốn không thể đoán trước được, vì vậy hoàn toàn có thể nó sẽ chậm hơn và do đó nằm ngoài giới hạn hiển thị ở đây. OTOH, có thể là TTFFTTFF hoàn toàn đánh trúng trường hợp bệnh lý. Không thể nói, vì anh ta không hiển thị thời gian ngẫu nhiên.
Mooing Duck

21
@MooingDuck Đối với mắt người, "TTFFTTFFTTFF" là một chuỗi có thể dự đoán được, nhưng điều chúng ta đang nói ở đây là hành vi của bộ dự đoán nhánh được tích hợp trong CPU. Công cụ dự đoán nhánh không phải là nhận dạng mẫu ở cấp độ AI; nó rất đơn giản. Khi bạn chỉ thay thế các chi nhánh, nó không dự đoán tốt. Trong hầu hết các mã, các nhánh đi theo cùng một cách gần như mọi lúc; xem xét một vòng lặp thực thi một ngàn lần. Nhánh ở cuối vòng lặp quay trở lại điểm bắt đầu của vòng lặp 999 lần, và sau đó lần thứ nghìn làm điều gì đó khác biệt. Một dự báo chi nhánh rất đơn giản hoạt động tốt, thường.
steveha

18
@steveha: Tôi nghĩ rằng bạn đang đưa ra các giả định về cách hoạt động của bộ dự đoán nhánh CPU và tôi không đồng ý với phương pháp đó. Tôi không biết công cụ dự đoán nhánh đó tiên tiến đến mức nào, nhưng dường như tôi nghĩ nó tiên tiến hơn nhiều so với bạn. Bạn có thể đúng, nhưng các phép đo chắc chắn sẽ tốt.
Vịt mướp

5
@steveha: Công cụ dự đoán thích ứng hai cấp có thể khóa vào mẫu TTFFTTFF mà không có vấn đề gì. "Các biến thể của phương pháp dự đoán này được sử dụng trong hầu hết các bộ vi xử lý hiện đại". Dự đoán chi nhánh địa phương và dự đoán chi nhánh toàn cầu dựa trên dự đoán thích ứng hai cấp độ, chúng cũng có thể. "Dự đoán nhánh toàn cầu được sử dụng trong các bộ xử lý AMD và trong các bộ xử lý Intel Pentium M, Core, Core 2 và Silvermont" Cũng thêm dự đoán Agree, dự đoán lai, Dự đoán các bước nhảy gián tiếp, vào danh sách đó. Công cụ dự đoán vòng lặp sẽ không khóa, nhưng đạt 75%. Chỉ còn lại 2 cái không thể khóa
Vịt Mooing

1126

Một cách để tránh các lỗi dự đoán nhánh là xây dựng bảng tra cứu và lập chỉ mục cho nó bằng cách sử dụng dữ liệu. Stefan de Bruijn đã thảo luận rằng trong câu trả lời của mình.

Nhưng trong trường hợp này, chúng tôi biết các giá trị nằm trong phạm vi [0, 255] và chúng tôi chỉ quan tâm đến các giá trị> = 128. Điều đó có nghĩa là chúng tôi có thể dễ dàng trích xuất một bit sẽ cho chúng tôi biết liệu chúng tôi có muốn giá trị hay không: bằng cách thay đổi dữ liệu ở bên phải 7 bit, chúng tôi chỉ còn lại 0 bit hoặc 1 bit và chúng tôi chỉ muốn thêm giá trị khi chúng tôi có 1 bit. Hãy gọi bit này là "bit quyết định".

Bằng cách sử dụng giá trị 0/1 của bit quyết định làm chỉ mục thành một mảng, chúng ta có thể tạo mã sẽ nhanh như nhau cho dù dữ liệu được sắp xếp hay không được sắp xếp. Mã của chúng tôi sẽ luôn thêm một giá trị, nhưng khi bit quyết định bằng 0, chúng tôi sẽ thêm giá trị ở đâu đó mà chúng tôi không quan tâm. Đây là mã:

// Test
clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

Mã này lãng phí một nửa số bổ sung nhưng không bao giờ có lỗi dự đoán chi nhánh. Nó nhanh hơn rất nhiều trên dữ liệu ngẫu nhiên so với phiên bản với câu lệnh if thực tế.

Nhưng trong thử nghiệm của tôi, một bảng tra cứu rõ ràng nhanh hơn một chút so với điều này, có lẽ bởi vì việc lập chỉ mục vào bảng tra cứu nhanh hơn một chút so với dịch chuyển bit. Điều này cho thấy cách mã của tôi thiết lập và sử dụng bảng tra cứu (được gọi một cách đơn giản là lut"Bảng tra cứu " trong mã). Đây là mã C ++:

// Declare and then fill in the lookup table
int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

Trong trường hợp này, bảng tra cứu chỉ có 256 byte, do đó, nó phù hợp độc đáo trong bộ đệm và tất cả đều nhanh. Kỹ thuật này sẽ không hoạt động tốt nếu dữ liệu là các giá trị 24 bit và chúng tôi chỉ muốn một nửa trong số chúng ... bảng tra cứu sẽ quá lớn để trở nên thực tế. Mặt khác, chúng ta có thể kết hợp hai kỹ thuật được hiển thị ở trên: đầu tiên dịch chuyển các bit qua, sau đó lập chỉ mục một bảng tra cứu. Đối với giá trị 24 bit mà chúng tôi chỉ muốn giá trị nửa trên cùng, chúng tôi có khả năng có thể dịch chuyển dữ liệu sang phải 12 bit và được để lại giá trị 12 bit cho chỉ mục bảng. Một chỉ mục bảng 12 bit ngụ ý một bảng gồm 4096 giá trị, có thể là thực tế.

Kỹ thuật lập chỉ mục thành một mảng, thay vì sử dụng một ifcâu lệnh, có thể được sử dụng để quyết định sử dụng con trỏ nào. Tôi thấy một thư viện triển khai cây nhị phân và thay vì có hai con trỏ được đặt tên ( pLeftpRightbất cứ thứ gì) có một mảng 2 con trỏ dài và sử dụng kỹ thuật "bit quyết định" để quyết định nên theo dõi cái nào. Ví dụ: thay vì:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;

thư viện này sẽ làm một cái gì đó như:

i = (x < node->value);
node = node->link[i];

Đây là một liên kết đến mã này: Cây đen đỏ , bị nhầm lẫn vĩnh viễn


29
Đúng, bạn cũng có thể chỉ cần sử dụng bit trực tiếp và nhân ( data[c]>>7- cũng được thảo luận ở đâu đó ở đây); Tôi cố tình bỏ giải pháp này ra, nhưng tất nhiên bạn đúng. Chỉ cần một lưu ý nhỏ: Nguyên tắc chung cho các bảng tra cứu là nếu nó phù hợp với 4KB (vì lưu vào bộ đệm), nó sẽ hoạt động - tốt nhất là làm cho bảng càng nhỏ càng tốt. Đối với các ngôn ngữ được quản lý, tôi sẽ đẩy nó lên 64KB, đối với các ngôn ngữ cấp thấp như C ++ và C, có lẽ tôi sẽ xem xét lại (đó chỉ là kinh nghiệm của tôi). Vì typeof(int) = 4, tôi sẽ cố gắng bám vào tối đa 10 bit.
thích

17
Tôi nghĩ lập chỉ mục với giá trị 0/1 có thể sẽ nhanh hơn bội số nguyên, nhưng tôi đoán nếu hiệu suất thực sự quan trọng thì bạn nên cấu hình nó. Tôi đồng ý rằng các bảng tra cứu nhỏ là điều cần thiết để tránh áp lực bộ đệm, nhưng rõ ràng nếu bạn có bộ đệm lớn hơn, bạn có thể thoát khỏi bảng tra cứu lớn hơn, vì vậy 4KB là quy tắc ngón tay cái hơn là quy tắc cứng. Tôi nghĩ bạn có ý sizeof(int) == 4gì? Điều đó sẽ đúng với 32-bit. Điện thoại di động hai tuổi của tôi có bộ đệm L1 32KB, do đó, ngay cả bảng tra cứu 4K cũng có thể hoạt động, đặc biệt nếu các giá trị tra cứu là một byte thay vì int.
steveha

12
Có thể tôi đang thiếu một cái gì đó nhưng trong jphương thức bằng 0 hoặc 1 của bạn , tại sao bạn không nhân giá trị của mình bằng cách jtrước khi thêm nó thay vì sử dụng lập chỉ mục mảng (có thể nên được nhân lên 1-jthay vì j)
Richard Tingle

6
@steveha Phép nhân sẽ nhanh hơn, tôi đã thử tìm kiếm nó trong các cuốn sách của Intel, nhưng không thể tìm thấy nó ... dù bằng cách nào, việc đo điểm chuẩn cũng mang lại cho tôi kết quả đó ở đây.
đúng lúc

10
@steveha PS: một câu trả lời khả dĩ khác sẽ int c = data[j]; sum += c & -(c >> 7);không đòi hỏi phải nhân lên.
đúng lúc

1021

Trong trường hợp được sắp xếp, bạn có thể làm tốt hơn là dựa vào dự đoán nhánh thành công hoặc bất kỳ thủ thuật so sánh không phân nhánh nào: loại bỏ hoàn toàn nhánh.

Thật vậy, mảng được phân vùng trong một vùng liền kề với data < 128và một vùng khác với data >= 128. Vì vậy, bạn nên tìm điểm phân vùng với tìm kiếm nhị phân (sử dụng Lg(arraySize) = 15so sánh), sau đó thực hiện tích lũy thẳng từ điểm đó.

Một cái gì đó như (không được kiểm tra)

int i= 0, j, k= arraySize;
while (i < k)
{
  j= (i + k) >> 1;
  if (data[j] >= 128)
    k= j;
  else
    i= j;
}
sum= 0;
for (; i < arraySize; i++)
  sum+= data[i];

hoặc, hơi khó hiểu hơn

int i, k, j= (i + k) >> 1;
for (i= 0, k= arraySize; i < k; (data[j] >= 128 ? k : i)= j)
  j= (i + k) >> 1;
for (sum= 0; i < arraySize; i++)
  sum+= data[i];

Một cách tiếp cận nhanh hơn, cung cấp một giải pháp gần đúng cho cả hai loại được sắp xếp hoặc chưa được sắp xếp là: sum= 3137536;(giả sử phân phối thực sự thống nhất, 16384 mẫu với giá trị dự kiến ​​là 191,5) :-)


23
sum= 3137536- tài giỏi. Đó rõ ràng không phải là vấn đề. Câu hỏi rõ ràng là về việc giải thích các đặc tính hiệu suất đáng ngạc nhiên. Tôi muốn nói rằng việc bổ sung làm std::partitionthay vì std::sortcó giá trị. Mặc dù câu hỏi thực tế mở rộng ra nhiều hơn chỉ là điểm chuẩn tổng hợp được đưa ra.
sehe

12
@DeadMG: đây thực sự không phải là tìm kiếm nhị phân chuẩn cho một khóa đã cho, mà là tìm kiếm chỉ mục phân vùng; nó đòi hỏi một so sánh duy nhất cho mỗi lần lặp. Nhưng đừng dựa vào mã này, tôi đã không kiểm tra nó. Nếu bạn quan tâm đến việc thực hiện đúng được đảm bảo, hãy cho tôi biết.
Yves Daoust

831

Các hành vi trên đang xảy ra vì dự đoán chi nhánh.

Để hiểu dự đoán chi nhánh, trước tiên phải hiểu Đường ống chỉ dẫn :

Bất kỳ lệnh nào được chia thành một chuỗi các bước để các bước khác nhau có thể được thực hiện đồng thời song song. Kỹ thuật này được gọi là đường ống dẫn và điều này được sử dụng để tăng thông lượng trong các bộ xử lý hiện đại. Để hiểu rõ hơn về điều này, vui lòng xem ví dụ này trên Wikipedia .

Nói chung, các bộ xử lý hiện đại có các đường ống khá dài, nhưng để dễ dàng, hãy xem xét 4 bước này.

  1. NẾU - Lấy hướng dẫn từ bộ nhớ
  2. ID - Giải mã hướng dẫn
  3. EX - Thực hiện hướng dẫn
  4. WB - Ghi lại vào thanh ghi CPU

Đường ống 4 giai đoạn nói chung cho 2 hướng dẫn. Đường ống 4 tầng nói chung

Quay trở lại câu hỏi trên, hãy xem xét các hướng dẫn sau:

                        A) if (data[c] >= 128)
                                /\
                               /  \
                              /    \
                        true /      \ false
                            /        \
                           /          \
                          /            \
                         /              \
              B) sum += data[c];          C) for loop or print().

Nếu không có dự đoán chi nhánh, điều sau đây sẽ xảy ra:

Để thực hiện lệnh B hoặc lệnh C, bộ xử lý sẽ phải đợi cho đến khi lệnh A không đạt đến giai đoạn EX trong đường ống, vì quyết định chuyển sang lệnh B hoặc lệnh C phụ thuộc vào kết quả của lệnh A. Vì vậy, đường ống sẽ trông như thế này

khi điều kiện trả về true: nhập mô tả hình ảnh ở đây

Khi điều kiện trả về false: nhập mô tả hình ảnh ở đây

Kết quả của việc chờ kết quả của lệnh A, tổng số chu kỳ CPU được sử dụng trong trường hợp trên (không có dự đoán nhánh; cho cả đúng và sai) là 7.

Vậy dự đoán chi nhánh là gì?

Chi nhánh dự đoán sẽ cố gắng đoán xem một nhánh (cấu trúc if-then-other) sẽ đi trước khi điều này được biết chắc chắn. Nó sẽ không chờ lệnh A đến giai đoạn EX của đường ống, nhưng nó sẽ đoán quyết định và đi đến hướng dẫn đó (B hoặc C trong trường hợp ví dụ của chúng tôi).

Trong trường hợp đoán đúng, đường ống trông giống như thế này: nhập mô tả hình ảnh ở đây

Nếu sau đó phát hiện ra rằng đoán sai thì các lệnh được thực thi một phần sẽ bị loại bỏ và đường ống bắt đầu với nhánh chính xác, gây ra sự chậm trễ. Thời gian bị lãng phí trong trường hợp hiểu sai chi nhánh bằng số lượng giai đoạn trong đường ống từ giai đoạn tìm nạp đến giai đoạn thực thi. Các bộ vi xử lý hiện đại có xu hướng có các đường ống khá dài do đó độ trễ sai là từ 10 đến 20 chu kỳ xung nhịp. Đường ống càng dài thì nhu cầu về một bộ dự báo nhánh tốt càng lớn .

Trong mã của OP, lần đầu tiên khi có điều kiện, bộ dự đoán nhánh không có bất kỳ thông tin nào để đưa ra dự đoán, vì vậy lần đầu tiên nó sẽ chọn ngẫu nhiên hướng dẫn tiếp theo. Sau đó trong vòng lặp for, nó có thể dựa trên dự đoán về lịch sử. Đối với một mảng được sắp xếp theo thứ tự tăng dần, có ba khả năng:

  1. Tất cả các yếu tố nhỏ hơn 128
  2. Tất cả các yếu tố đều lớn hơn 128
  3. Một số yếu tố bắt đầu mới nhỏ hơn 128 và sau đó trở thành lớn hơn 128

Chúng ta hãy giả định rằng người dự đoán sẽ luôn đảm nhận nhánh thực sự trong lần chạy đầu tiên.

Vì vậy, trong trường hợp đầu tiên, nó sẽ luôn lấy nhánh thực sự vì trong lịch sử tất cả các dự đoán của nó là chính xác. Trong trường hợp thứ 2, ban đầu nó sẽ dự đoán sai, nhưng sau một vài lần lặp, nó sẽ dự đoán chính xác. Trong trường hợp thứ 3, ban đầu nó sẽ dự đoán chính xác cho đến khi các phần tử nhỏ hơn 128. Sau đó, nó sẽ thất bại trong một thời gian và chính nó khi thấy lỗi dự đoán nhánh trong lịch sử.

Trong tất cả các trường hợp này, lỗi sẽ quá ít về số lượng và kết quả là chỉ cần vài lần cần loại bỏ các hướng dẫn được thực hiện một phần và bắt đầu lại với nhánh chính xác, dẫn đến chu kỳ CPU ít hơn.

Nhưng trong trường hợp một mảng chưa được sắp xếp ngẫu nhiên, dự đoán sẽ cần loại bỏ các hướng dẫn được thực hiện một phần và bắt đầu lại với nhánh chính xác trong hầu hết thời gian và dẫn đến nhiều chu kỳ CPU hơn so với mảng được sắp xếp.


1
Làm thế nào là hai hướng dẫn thực hiện cùng nhau? điều này được thực hiện với các lõi cpu riêng biệt hay hướng dẫn đường ống được tích hợp trong lõi cpu đơn?
M.kazem Akhÿ

1
@ M.kazemAkhÿ Tất cả nằm trong một lõi logic. Nếu bạn quan tâm, ví dụ này được mô tả độc đáo trong Hướng dẫn dành cho nhà phát triển phần mềm Intel
Sergey.quixoticaxis.Ivanov

727

Một câu trả lời chính thức sẽ là từ

  1. Intel - Tránh chi phí sai lầm của chi nhánh
  2. Intel - Sắp xếp lại chi nhánh và vòng lặp để ngăn chặn những đánh giá sai
  3. Bài báo khoa học - kiến ​​trúc máy tính dự đoán
  4. Sách: JL Hennessy, DA Patterson: Kiến trúc máy tính: một cách tiếp cận định lượng
  5. Các bài báo trong các ấn phẩm khoa học: TY Yeh, YN Patt đã thực hiện rất nhiều trong số này về dự đoán chi nhánh.

Bạn cũng có thể nhìn thấy từ sơ đồ đáng yêu này tại sao bộ dự đoán nhánh bị nhầm lẫn.

Sơ đồ trạng thái 2 bit

Mỗi phần tử trong mã gốc là một giá trị ngẫu nhiên

data[c] = std::rand() % 256;

do đó, người dự đoán sẽ thay đổi bên như std::rand()đòn.

Mặt khác, một khi đã được sắp xếp, người dự đoán trước tiên sẽ chuyển sang trạng thái không được thực hiện mạnh mẽ và khi các giá trị thay đổi thành giá trị cao, người dự đoán sẽ trong ba lần thay đổi từ cách không được thực hiện mạnh mẽ.



696

Trong cùng một dòng (tôi nghĩ rằng điều này không được làm nổi bật bởi bất kỳ câu trả lời nào), thật tốt khi đề cập rằng đôi khi (đặc biệt là trong phần mềm có hiệu năng quan trọng giống như trong nhân Linux), bạn có thể tìm thấy một số câu lệnh như sau:

if (likely( everything_is_ok ))
{
    /* Do something */
}

hoặc tương tự:

if (unlikely(very_improbable_condition))
{
    /* Do something */    
}

Cả hai likely()unlikely()trên thực tế là các macro được xác định bằng cách sử dụng một cái gì đó như GCC __builtin_expectđể giúp trình biên dịch chèn mã dự đoán để ưu tiên điều kiện có tính đến thông tin được cung cấp bởi người dùng. GCC hỗ trợ các nội dung khác có thể thay đổi hành vi của chương trình đang chạy hoặc phát ra các hướng dẫn cấp thấp như xóa bộ đệm, v.v. Xem tài liệu này đi qua các nội dung sẵn có của GCC.

Thông thường loại tối ưu hóa này chủ yếu được tìm thấy trong các ứng dụng thời gian thực hoặc hệ thống nhúng trong đó thời gian thực hiện là vấn đề quan trọng. Ví dụ: nếu bạn đang kiểm tra một số điều kiện lỗi chỉ xảy ra 1/10000000 lần, thì tại sao không thông báo cho trình biên dịch về điều này? Theo cách này, theo mặc định, dự đoán nhánh sẽ cho rằng điều kiện là sai.


678

Các hoạt động Boolean thường được sử dụng trong C ++ tạo ra nhiều nhánh trong chương trình được biên dịch. Nếu các nhánh này nằm trong các vòng lặp và khó dự đoán thì chúng có thể làm chậm quá trình thực thi đáng kể. Biến Boolean được lưu dưới dạng số nguyên 8 bit với giá trị 0cho false1chotrue .

Các biến Boolean được xác định quá mức theo nghĩa là tất cả các toán tử có biến Boolean là kiểm tra đầu vào nếu đầu vào có bất kỳ giá trị nào khác 0hoặc 1, nhưng các toán tử có Booleans là đầu ra không thể tạo ra giá trị nào khác hơn 0hoặc 1. Điều này làm cho các hoạt động với các biến Boolean là đầu vào kém hiệu quả hơn mức cần thiết. Xem xét ví dụ:

bool a, b, c, d;
c = a && b;
d = a || b;

Điều này thường được trình biên dịch triển khai theo cách sau:

bool a, b, c, d;
if (a != 0) {
    if (b != 0) {
        c = 1;
    }
    else {
        goto CFALSE;
    }
}
else {
    CFALSE:
    c = 0;
}
if (a == 0) {
    if (b == 0) {
        d = 0;
    }
    else {
        goto DTRUE;
    }
}
else {
    DTRUE:
    d = 1;
}

Mã này là xa tối ưu. Các chi nhánh có thể mất một thời gian dài trong trường hợp dự đoán sai. Các hoạt động Boolean có thể được thực hiện hiệu quả hơn nhiều nếu được biết chắc chắn rằng các toán hạng không có giá trị nào khác hơn 01. Lý do tại sao trình biên dịch không đưa ra giả định như vậy là các biến có thể có các giá trị khác nếu chúng chưa được khởi tạo hoặc đến từ các nguồn không xác định. Mã trên có thể được tối ưu hóa nếu abđã được khởi tạo thành các giá trị hợp lệ hoặc nếu chúng đến từ các toán tử tạo ra đầu ra Boolean. Mã được tối ưu hóa trông như thế này:

char a = 0, b = 1, c, d;
c = a & b;
d = a | b;

charđược sử dụng thay vì boolđể có thể sử dụng các toán tử bitwise ( &|) thay vì các toán tử Boolean ( &&||). Các toán tử bitwise là các lệnh đơn chỉ mất một chu kỳ xung nhịp. Toán tử OR ( |) hoạt động ngay cả khi abcó các giá trị khác hơn 0hoặc 1. Toán tử AND ( &) và toán tử EXCLUSIVE OR ( ^) có thể cho kết quả không nhất quán nếu các toán hạng có các giá trị khác 01.

~không thể được sử dụng cho KHÔNG. Thay vào đó, bạn có thể tạo Boolean KHÔNG trên một biến được biết đến 0hoặc 1bằng XOR'ing với 1:

bool a, b;
b = !a;

có thể được tối ưu hóa để:

char a = 0, b;
b = a ^ 1;

a && bkhông thể được thay thế bằng a & bif blà một biểu thức không nên được đánh giá nếu afalse( &&sẽ không đánh giá b, &sẽ). Tương tự như vậy, a || bkhông thể được thay thế bằng a | bif blà một biểu thức không nên được đánh giá nếu atrue.

Sử dụng toán tử bitwise sẽ thuận lợi hơn nếu toán hạng là biến so với nếu toán hạng là so sánh:

bool a; double x, y, z;
a = x > y && z < 5.0;

là tối ưu trong hầu hết các trường hợp (trừ khi bạn mong muốn &&biểu thức tạo ra nhiều dự đoán sai chi nhánh).


341

Chắc chắn rồi!...

Dự đoán chi nhánh làm cho logic chạy chậm hơn, vì việc chuyển đổi xảy ra trong mã của bạn! Giống như bạn đang đi trên một con đường thẳng hoặc một con đường có rất nhiều ngã rẽ, chắc chắn con đường thẳng sẽ được thực hiện nhanh hơn! ...

Nếu mảng được sắp xếp, điều kiện của bạn là sai ở bước đầu tiên: data[c] >= 128 , sau đó trở thành giá trị thực cho toàn bộ đường đến cuối đường. Đó là cách bạn đi đến cuối logic nhanh hơn. Mặt khác, bằng cách sử dụng một mảng chưa được sắp xếp, bạn cần rất nhiều thao tác xoay và xử lý khiến mã của bạn chạy chậm hơn chắc chắn ...

Nhìn vào hình ảnh tôi tạo ra cho bạn dưới đây. Con đường nào sẽ được hoàn thành nhanh hơn?

Dự đoán chi nhánh

Vì vậy, lập trình, dự đoán chi nhánh khiến quá trình chậm hơn ...

Cuối cùng, thật tốt khi biết chúng tôi có hai loại dự đoán nhánh mà mỗi loại sẽ ảnh hưởng đến mã của bạn khác nhau:

1. Tĩnh

2. Năng động

Dự đoán chi nhánh

Dự đoán nhánh tĩnh được sử dụng bởi bộ vi xử lý khi lần đầu tiên gặp nhánh có điều kiện và dự đoán nhánh động được sử dụng để thực hiện thành công mã nhánh có điều kiện.

Để viết mã của bạn một cách hiệu quả để tận dụng các quy tắc này, khi viết câu lệnh if-other hoặc chuyển đổi , trước tiên hãy kiểm tra các trường hợp phổ biến nhất và làm việc dần dần xuống mức thấp nhất. Các vòng lặp không nhất thiết yêu cầu bất kỳ thứ tự mã đặc biệt nào cho dự đoán nhánh tĩnh, vì chỉ điều kiện của trình vòng lặp thường được sử dụng.


304

Câu hỏi này đã được trả lời xuất sắc nhiều lần. Tuy nhiên, tôi vẫn muốn thu hút sự chú ý của nhóm vào một phân tích thú vị khác.

Gần đây, ví dụ này (được sửa đổi một chút) cũng được sử dụng như một cách để chứng minh làm thế nào một đoạn mã có thể được định hình trong chính chương trình trên Windows. Đồng thời, tác giả cũng chỉ ra cách sử dụng kết quả để xác định nơi mã dành phần lớn thời gian của nó trong cả trường hợp được sắp xếp & chưa sắp xếp. Cuối cùng, tác phẩm cũng cho thấy cách sử dụng một tính năng ít được biết đến của HAL (Lớp trừu tượng phần cứng) để xác định mức độ sai lầm của chi nhánh đang xảy ra trong trường hợp chưa được sắp xếp.

Liên kết ở đây: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htmlm


3
Đó là một bài viết rất thú vị (trên thực tế, tôi mới đọc tất cả về nó), nhưng làm thế nào để trả lời câu hỏi?
Peter Mortensen

2
@PeterMortensen Tôi hơi bối rối trước câu hỏi của bạn. Ví dụ ở đây là một dòng có liên quan từ đoạn đó: When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping. Tác giả đang cố gắng thảo luận về hồ sơ trong bối cảnh mã được đăng ở đây và trong quá trình cố gắng giải thích tại sao trường hợp được sắp xếp nhanh hơn nhiều.
Mãi mãi học tập

260

Như những gì đã được đề cập bởi những người khác, những gì đằng sau bí ẩn là Chi nhánh dự đoán .

Tôi không cố gắng thêm một cái gì đó nhưng giải thích khái niệm theo một cách khác. Có một giới thiệu ngắn gọn trên wiki có chứa văn bản và sơ đồ. Tôi thích cách giải thích dưới đây sử dụng sơ đồ để xây dựng Dự đoán chi nhánh theo trực giác.

Trong kiến ​​trúc máy tính, bộ dự báo nhánh là một mạch kỹ thuật số cố gắng đoán xem nhánh nào (ví dụ cấu trúc if-then-other) sẽ đi trước khi điều này được biết chắc chắn. Mục đích của bộ dự báo nhánh là cải thiện dòng chảy trong đường ống dẫn. Các bộ dự báo nhánh đóng một vai trò quan trọng trong việc đạt được hiệu suất cao trong nhiều kiến ​​trúc bộ vi xử lý hiện đại như x86.

Phân nhánh hai chiều thường được thực hiện với một lệnh nhảy có điều kiện. Bước nhảy có điều kiện có thể "không được thực hiện" và tiếp tục thực hiện với nhánh mã đầu tiên ngay sau bước nhảy có điều kiện hoặc có thể được "lấy" và nhảy đến một vị trí khác trong bộ nhớ chương trình nơi có nhánh mã thứ hai lưu trữ. Người ta không biết chắc chắn liệu một bước nhảy có điều kiện sẽ được thực hiện hay không được thực hiện cho đến khi điều kiện đã được tính toán và bước nhảy có điều kiện đã qua giai đoạn thực hiện trong đường dẫn lệnh (xem hình 1).

Hình 1

Dựa trên kịch bản được mô tả, tôi đã viết một bản demo hoạt hình để cho thấy cách các hướng dẫn được thực thi trong một đường ống dẫn trong các tình huống khác nhau.

  1. Nếu không có Dự đoán chi nhánh.

Nếu không có dự đoán nhánh, bộ xử lý sẽ phải đợi cho đến khi lệnh nhảy có điều kiện vượt qua giai đoạn thực thi trước khi lệnh tiếp theo có thể vào giai đoạn tìm nạp trong đường ống.

Ví dụ chứa ba hướng dẫn và hướng dẫn đầu tiên là hướng dẫn nhảy có điều kiện. Hai lệnh sau có thể đi vào đường ống cho đến khi lệnh nhảy có điều kiện được thực thi.

không có dự báo chi nhánh

Sẽ mất 9 chu kỳ đồng hồ để hoàn thành 3 hướng dẫn.

  1. Sử dụng Chi nhánh dự đoán và không thực hiện bước nhảy có điều kiện. Chúng ta hãy giả sử rằng dự đoán không có bước nhảy có điều kiện.

nhập mô tả hình ảnh ở đây

Sẽ mất 7 chu kỳ đồng hồ để hoàn thành 3 hướng dẫn.

  1. Sử dụng Chi nhánh dự đoán và thực hiện một bước nhảy có điều kiện. Chúng ta hãy giả sử rằng dự đoán không có bước nhảy có điều kiện.

nhập mô tả hình ảnh ở đây

Sẽ mất 9 chu kỳ đồng hồ để hoàn thành 3 hướng dẫn.

Thời gian bị lãng phí trong trường hợp hiểu sai chi nhánh bằng số lượng giai đoạn trong đường ống từ giai đoạn tìm nạp đến giai đoạn thực thi. Các bộ vi xử lý hiện đại có xu hướng có các đường ống khá dài do đó độ trễ sai là từ 10 đến 20 chu kỳ xung nhịp. Kết quả là, làm cho một đường ống dài hơn làm tăng nhu cầu về một công cụ dự đoán nhánh tiên tiến hơn.

Như bạn có thể thấy, có vẻ như chúng ta không có lý do gì để không sử dụng Chi nhánh dự đoán.

Đây là một bản demo khá đơn giản làm rõ phần rất cơ bản của Chi nhánh dự đoán. Nếu những gifs đó gây phiền nhiễu, xin vui lòng xóa chúng khỏi câu trả lời và khách truy cập cũng có thể lấy mã nguồn demo trực tiếp từ BranchPredictorDemo


1
Gần như tốt như các hoạt hình tiếp thị của Intel, và chúng bị ám ảnh không chỉ với dự đoán chi nhánh mà còn không thực hiện theo thứ tự, cả hai chiến lược đều là "đầu cơ". Đọc trước trong bộ nhớ và lưu trữ (tìm nạp trước tuần tự vào bộ đệm) cũng là suy đoán. Nó tất cả cho biết thêm.
mckenzm

@mckenzm: người thực hiện đầu cơ không theo thứ tự làm cho dự đoán chi nhánh thậm chí còn có giá trị hơn; cũng như ẩn các bong bóng tìm nạp / giải mã, dự đoán nhánh + thực thi đầu cơ sẽ loại bỏ các phụ thuộc điều khiển khỏi độ trễ đường dẫn quan trọng. Mã bên trong hoặc sau một if()khối có thể thực thi trước khi điều kiện nhánh được biết đến. Hoặc cho một vòng tìm kiếm như strlenhoặc memchr, các tương tác có thể chồng lấp. Nếu bạn phải đợi kết quả khớp hoặc không được biết trước khi chạy bất kỳ lần lặp tiếp theo nào, bạn sẽ bị tắc nghẽn khi tải bộ đệm + độ trễ ALU thay vì thông lượng.
Peter Cordes

209

Chi nhánh dự đoán tăng!

Điều quan trọng là phải hiểu rằng việc hiểu sai chi nhánh không làm chậm các chương trình. Chi phí của một dự đoán bị bỏ lỡ cũng giống như dự đoán nhánh không tồn tại và bạn chờ đợi đánh giá biểu thức để quyết định chạy mã nào (giải thích thêm trong đoạn tiếp theo).

if (expression)
{
    // Run 1
} else {
    // Run 2
}

Bất cứ khi nào có câu lệnh if-else\ switch, biểu thức phải được ước tính để xác định khối nào sẽ được thực thi. Trong mã lắp ráp được tạo bởi trình biên dịch, các lệnh rẽ nhánh có điều kiện được chèn vào.

Lệnh rẽ nhánh có thể khiến máy tính bắt đầu thực hiện một chuỗi lệnh khác và do đó lệch khỏi hành vi mặc định của lệnh thực thi theo thứ tự (nghĩa là nếu biểu thức là sai, chương trình bỏ qua mã của ifkhối) tùy thuộc vào một số điều kiện, đó là đánh giá biểu hiện trong trường hợp của chúng tôi.

Điều đó đang được nói, trình biên dịch cố gắng dự đoán kết quả trước khi nó thực sự được đánh giá. Nó sẽ tìm nạp các hướng dẫn từ ifkhối, và nếu biểu thức hóa ra là đúng, thì thật tuyệt vời! Chúng tôi đã đạt được thời gian cần thiết để đánh giá nó và đạt được tiến bộ trong mã; nếu không thì chúng ta đang chạy mã sai, đường ống bị tuôn ra và khối chính xác được chạy.

Hình dung:

Giả sử bạn cần chọn tuyến đường 1 hoặc tuyến đường 2. Chờ đối tác kiểm tra bản đồ, bạn đã dừng tại ## và chờ đợi hoặc bạn chỉ có thể chọn tuyến đường 1 và nếu bạn may mắn (tuyến đường 1 là tuyến đường chính xác), thật tuyệt vời khi bạn không phải đợi đối tác kiểm tra bản đồ (bạn đã tiết kiệm thời gian để anh ấy kiểm tra bản đồ), nếu không bạn sẽ quay lại.

Trong khi xả đường ống là siêu nhanh, ngày nay việc đánh bạc này là xứng đáng. Dự đoán dữ liệu được sắp xếp hoặc dữ liệu thay đổi chậm luôn dễ dàng và tốt hơn so với dự đoán thay đổi nhanh.

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------

Trong khi xả đường ống là siêu nhanh Không thực sự. Nó nhanh so với bộ nhớ cache hoàn toàn bỏ qua DRAM, nhưng trên một x86 hiệu năng cao hiện đại (như gia đình Intel Sandybridge), nó có khoảng một chục chu kỳ. Mặc dù phục hồi nhanh không cho phép nó tránh việc chờ đợi tất cả các hướng dẫn độc lập cũ hơn đến lúc nghỉ hưu trước khi bắt đầu phục hồi, bạn vẫn mất rất nhiều chu kỳ front-end khi dự đoán sai. Chính xác thì chuyện gì sẽ xảy ra khi CPU skylake đánh giá sai một nhánh? . (Và mỗi chu kỳ có thể có khoảng 4 hướng dẫn công việc.) Xấu đối với mã thông lượng cao.
Peter Cordes

153

Trên ARM, không có nhánh cần thiết, bởi vì mọi lệnh đều có trường điều kiện 4 bit, kiểm tra (với chi phí bằng 0) bất kỳ 16 điều kiện khác nhau khác nhau có thể phát sinh trong Thanh ghi trạng thái bộ xử lý và nếu điều kiện trên lệnh là sai, hướng dẫn bị bỏ qua. Điều này giúp loại bỏ sự cần thiết của các nhánh ngắn và sẽ không có dự đoán chi nhánh nào cho thuật toán này. Do đó, phiên bản được sắp xếp của thuật toán này sẽ chạy chậm hơn phiên bản chưa được sắp xếp trên ARM, do có thêm chi phí sắp xếp.

Vòng lặp bên trong của thuật toán này sẽ trông giống như sau trong ngôn ngữ lắp ráp ARM:

MOV R0, #0     // R0 = sum = 0
MOV R1, #0     // R1 = c = 0
ADR R2, data   // R2 = addr of data array (put this instruction outside outer loop)
.inner_loop    // Inner loop branch label
    LDRB R3, [R2, R1]     // R3 = data[c]
    CMP R3, #128          // compare R3 to 128
    ADDGE R0, R0, R3      // if R3 >= 128, then sum += data[c] -- no branch needed!
    ADD R1, R1, #1        // c++
    CMP R1, #arraySize    // compare c to arraySize
    BLT inner_loop        // Branch to inner_loop if c < arraySize

Nhưng đây thực sự là một phần của bức tranh lớn hơn:

CMPopcodes luôn cập nhật các bit trạng thái trong Thanh ghi trạng thái bộ xử lý (PSR), vì đó là mục đích của chúng, nhưng hầu hết các hướng dẫn khác không chạm vào PSR trừ khi bạn thêm một Shậu tố tùy chọn vào hướng dẫn, chỉ định rằng PSR nên được cập nhật dựa trên kết quả của hướng dẫn. Giống như hậu tố điều kiện 4 bit, có thể thực hiện các lệnh mà không ảnh hưởng đến PSR là một cơ chế giúp giảm nhu cầu của các nhánh trên ARM và cũng tạo điều kiện cho việc gửi lệnh ở cấp phần cứng , vì sau khi thực hiện một số thao tác X cập nhật các bit trạng thái, sau đó (hoặc song song) bạn có thể thực hiện một loạt các công việc khác rõ ràng không ảnh hưởng đến các bit trạng thái, sau đó bạn có thể kiểm tra trạng thái của các bit trạng thái được đặt trước đó bởi X.

Có thể kết hợp trường kiểm tra điều kiện và trường "thiết lập bit trạng thái" tùy chọn:

  • ADD R1, R2, R3thực hiện R1 = R2 + R3mà không cập nhật bất kỳ bit trạng thái.
  • ADDGE R1, R2, R3 chỉ thực hiện thao tác tương tự nếu một lệnh trước đó ảnh hưởng đến các bit trạng thái dẫn đến điều kiện Lớn hơn hoặc Bằng.
  • ADDS R1, R2, R3Thực hiện việc bổ sung và sau đó cập nhật N, Z, CVcờ trong tình trạng thanh ghi dựa trên việc kết quả là âm tính, Zero, Carried (ví Ngoài unsigned), hoặc tràn (bổ sung đã ký).
  • ADDSGE R1, R2, R3chỉ thực hiện bổ sung nếu GEthử nghiệm là đúng và sau đó cập nhật các bit trạng thái dựa trên kết quả của phép cộng.

Hầu hết các kiến ​​trúc bộ xử lý không có khả năng này để xác định xem các bit trạng thái có nên được cập nhật cho một hoạt động nhất định hay không, có thể phải viết mã bổ sung để lưu và khôi phục các bit trạng thái hoặc có thể yêu cầu các nhánh bổ sung hoặc có thể giới hạn bộ xử lý về hiệu quả thực hiện đơn hàng: một trong những tác dụng phụ của hầu hết các cấu trúc tập lệnh CPU buộc phải cập nhật các bit trạng thái sau hầu hết các lệnh là khó khăn hơn nhiều để tách ra các lệnh có thể chạy song song mà không can thiệp lẫn nhau. Cập nhật bit trạng thái có tác dụng phụ, do đó có tác dụng tuyến tính hóa mã.Khả năng kết hợp và kiểm tra điều kiện không có nhánh của ARM trên bất kỳ lệnh nào với tùy chọn cập nhật hoặc không cập nhật các bit trạng thái sau khi bất kỳ lệnh nào là cực kỳ mạnh mẽ, cho cả lập trình viên và trình biên dịch ngôn ngữ lắp ráp, và tạo mã rất hiệu quả.

Nếu bạn đã từng thắc mắc tại sao ARM lại thành công đến mức khó tin, thì hiệu quả và khả năng tương tác tuyệt vời của hai cơ chế này là một phần lớn của câu chuyện, bởi vì chúng là một trong những nguồn lớn nhất của hiệu quả của kiến ​​trúc ARM. Sự xuất sắc của các nhà thiết kế ban đầu của ARM ARM vào năm 1983, Steve Furber và Roger (nay là Sophie) Wilson, không thể bị cường điệu hóa.


1
Sự đổi mới khác trong ARM là bổ sung hậu tố lệnh S, cũng là tùy chọn trên (gần như) tất cả các lệnh, nếu vắng mặt, sẽ ngăn các lệnh thay đổi bit trạng thái (ngoại trừ lệnh CMP, công việc của nó là đặt các bit trạng thái, vì vậy nó không cần hậu tố S). Điều này cho phép bạn tránh các lệnh CMP trong nhiều trường hợp, miễn là so sánh bằng 0 hoặc tương tự (ví dụ: SUBS R0, R0, # 1 sẽ đặt bit Z (Zero) khi R0 bằng 0). Điều kiện và hậu tố S không có chi phí. Đó là một ISA đẹp.
Luke Hutchison

2
Không thêm hậu tố S cho phép bạn có một số hướng dẫn có điều kiện liên tiếp mà không phải lo lắng rằng một trong số chúng có thể thay đổi các bit trạng thái, điều này có thể có tác dụng phụ là bỏ qua phần còn lại của các hướng dẫn có điều kiện.
Luke Hutchison

Lưu ý rằng OP không bao gồm thời gian để sắp xếp trong phép đo của họ. Đây có thể là một mất mát tổng thể để sắp xếp đầu tiên trước khi chạy một vòng lặp x86 nhánh, mặc dù trường hợp không được sắp xếp làm cho vòng lặp chạy chậm hơn rất nhiều. Nhưng sắp xếp một mảng lớn đòi hỏi rất nhiều công việc.
Peter Cordes

BTW, bạn có thể lưu một lệnh trong vòng lặp bằng cách lập chỉ mục liên quan đến cuối mảng. Trước vòng lặp, thiết lập R2 = data + arraySize, sau đó bắt đầu với R1 = -arraySize. Phần dưới của vòng lặp trở thành adds r1, r1, #1/ bnz inner_loop. Các trình biên dịch không sử dụng tối ưu hóa này vì một số lý do: / Nhưng dù sao, việc thực thi bổ sung được cung cấp không khác biệt cơ bản trong trường hợp này so với những gì bạn có thể làm với mã không phân nhánh trên các ISA khác, như x86 cmov. Mặc dù nó không đẹp bằng: cờ tối ưu hóa gcc -O3 làm cho mã chậm hơn -O2
Peter Cordes

1
(ARM xác thực thực sự nops hướng dẫn, vì vậy bạn thậm chí có thể sử dụng nó trên tải hoặc các cửa hàng đó sẽ lỗi, không giống như x86 cmovvới một nguồn bộ nhớ toán hạng. Hầu hết ISA, bao gồm AArch64, chỉ có ALU chọn hoạt động. Vì vậy, ARM sự truyền giáo có thể mạnh mẽ, và có thể sử dụng hiệu quả hơn mã không phân nhánh trên hầu hết các ISA.)
Peter Cordes

146

Đó là về dự đoán chi nhánh. Nó là gì?

  • Công cụ dự đoán nhánh là một trong những kỹ thuật cải tiến hiệu suất cổ xưa mà vẫn tìm thấy sự liên quan đến các kiến ​​trúc hiện đại. Trong khi các kỹ thuật dự đoán đơn giản cung cấp tra cứu nhanh và hiệu quả năng lượng, họ phải chịu tỷ lệ sai lầm cao.

  • Mặt khác, các dự đoán nhánh phức tạp, dựa trên thần kinh hoặc các biến thể của dự đoán nhánh hai cấp độ chính xác dự đoán tốt hơn, nhưng chúng tiêu thụ nhiều năng lượng hơn và độ phức tạp tăng theo cấp số nhân.

  • Thêm vào đó, trong các kỹ thuật dự đoán phức tạp, thời gian để dự đoán các nhánh rất cao, từ 2 đến 5 chu kỳ, có thể so sánh với thời gian thực hiện của các nhánh thực tế.

  • Dự đoán chi nhánh về cơ bản là một vấn đề tối ưu hóa (tối thiểu hóa) trong đó nhấn mạnh vào việc đạt được tỷ lệ bỏ lỡ thấp nhất có thể, tiêu thụ điện năng thấp và độ phức tạp thấp với các nguồn lực tối thiểu.

Thực sự có ba loại khác nhau:

Chuyển tiếp các nhánh có điều kiện - dựa trên điều kiện thời gian chạy, PC (bộ đếm chương trình) được thay đổi để trỏ đến một địa chỉ chuyển tiếp trong luồng lệnh.

Các nhánh có điều kiện lạc hậu - PC được thay đổi thành điểm lùi trong luồng lệnh. Nhánh dựa trên một số điều kiện, chẳng hạn như phân nhánh ngược về điểm bắt đầu của vòng lặp chương trình khi kiểm tra ở cuối vòng lặp cho biết vòng lặp sẽ được thực hiện lại.

Các nhánh vô điều kiện - bao gồm các bước nhảy, các thủ tục gọi và trả về không có điều kiện cụ thể. Ví dụ, một lệnh nhảy vô điều kiện có thể được mã hóa bằng ngôn ngữ lắp ráp đơn giản là "jmp" và luồng lệnh phải ngay lập tức được hướng đến vị trí đích được chỉ ra bởi lệnh nhảy, trong khi bước nhảy có điều kiện có thể được mã hóa là "jmpne" sẽ chỉ chuyển hướng luồng lệnh nếu kết quả so sánh hai giá trị trong hướng dẫn "so sánh" trước đó cho thấy các giá trị không bằng nhau. (Lược đồ địa chỉ được phân đoạn được sử dụng bởi kiến ​​trúc x86 làm tăng thêm độ phức tạp, vì các bước nhảy có thể là "gần" (trong một phân đoạn) hoặc "xa" (bên ngoài phân khúc). Mỗi loại có tác dụng khác nhau đối với thuật toán dự đoán nhánh.)

Dự đoán nhánh tĩnh / động : Dự đoán nhánh tĩnh được sử dụng bởi bộ vi xử lý khi lần đầu tiên gặp nhánh có điều kiện và dự đoán nhánh động được sử dụng để thực hiện thành công mã nhánh có điều kiện.

Người giới thiệu:


145

Bên cạnh thực tế là dự đoán nhánh có thể làm bạn chậm lại, một mảng được sắp xếp có một lợi thế khác:

Bạn có thể có một điều kiện dừng thay vì chỉ kiểm tra giá trị, bằng cách này bạn chỉ lặp qua các dữ liệu liên quan và bỏ qua phần còn lại.
Dự đoán chi nhánh sẽ bỏ lỡ một lần.

 // sort backwards (higher values first), may be in some other part of the code
 std::sort(data, data + arraySize, std::greater<int>());

 for (unsigned c = 0; c < arraySize; ++c) {
       if (data[c] < 128) {
              break;
       }
       sum += data[c];               
 }

1
Đúng, nhưng chi phí thiết lập để sắp xếp mảng là O (N log N), vì vậy việc phá vỡ sớm không giúp ích gì cho bạn nếu lý do duy nhất bạn sắp xếp mảng là có thể phá vỡ sớm. Tuy nhiên, nếu bạn có các lý do khác để sắp xếp trước mảng, thì có, điều này rất có giá trị.
Luke Hutchison

Phụ thuộc vào số lần bạn sắp xếp dữ liệu so với số lần bạn lặp trên đó. Sắp xếp trong ví dụ này chỉ là một ví dụ, nó không phải ở ngay trước vòng lặp
Yochai Timmer

2
Vâng, đó chính xác là điểm tôi đã đưa ra trong nhận xét đầu tiên của mình :-) Bạn nói "Dự đoán chi nhánh sẽ chỉ bỏ lỡ một lần." Nhưng bạn không tính sai dự đoán nhánh O (N log N) bên trong thuật toán sắp xếp, thực sự lớn hơn dự đoán nhánh O (N) bỏ lỡ trong trường hợp chưa sắp xếp. Vì vậy, bạn sẽ cần sử dụng toàn bộ thời gian dữ liệu được sắp xếp O (log N) để hòa vốn (có thể thực sự gần với O (10 log N), tùy thuộc vào thuật toán sắp xếp, ví dụ như quicksort, do lỗi bộ nhớ cache - sáp nhập là kết hợp bộ nhớ cache nhiều hơn, vì vậy bạn sẽ cần sử dụng O (2 log N) để hòa vốn.)
Luke Hutchison

Mặc dù vậy, một tối ưu hóa đáng kể sẽ chỉ là "một nửa quicksort", chỉ sắp xếp các mục nhỏ hơn giá trị trục mục tiêu là 127 (giả sử mọi thứ nhỏ hơn hoặc bằng trục được sắp xếp sau trục). Khi bạn đạt đến trục, tổng hợp các yếu tố trước trục. Điều này sẽ chạy trong thời gian khởi động O (N) thay vì O (N log N), mặc dù vẫn sẽ có rất nhiều dự đoán sai nhánh, có thể là thứ tự của O (5 N) dựa trên các số tôi đã đưa ra trước đó, kể từ đó đó là một nửa quicksort.
Luke Hutchison

132

Các mảng được sắp xếp được xử lý nhanh hơn một mảng chưa được sắp xếp, do một hiện tượng gọi là dự đoán nhánh.

Bộ dự báo nhánh là một mạch kỹ thuật số (trong kiến ​​trúc máy tính) đang cố gắng dự đoán một nhánh sẽ đi theo hướng nào, cải thiện dòng chảy trong đường dẫn lệnh. Mạch / máy tính dự đoán bước tiếp theo và thực hiện nó.

Đưa ra một dự đoán sai dẫn đến việc quay lại bước trước đó và thực hiện với một dự đoán khác. Giả sử dự đoán là chính xác, mã sẽ tiếp tục bước tiếp theo. Một dự đoán sai dẫn đến lặp lại cùng một bước, cho đến khi một dự đoán đúng xảy ra.

Câu trả lời cho câu hỏi của bạn rất đơn giản.

Trong một mảng chưa được sắp xếp, máy tính đưa ra nhiều dự đoán, dẫn đến tăng khả năng xảy ra lỗi. Trong khi đó, trong một mảng được sắp xếp, máy tính đưa ra ít dự đoán hơn, giảm khả năng xảy ra lỗi. Đưa ra nhiều dự đoán đòi hỏi nhiều thời gian hơn.

Mảng được sắp xếp: Đường thẳng ____________________________________________________________________________________ - - - - - - - - - - - - - - - - - - - - - - - - TTT

Mảng không được sắp xếp: Đường cong

______   ________
|     |__|

Dự đoán chi nhánh: Đoán / dự đoán đường nào là thẳng và đi theo đường mà không cần kiểm tra

___________________________________________ Straight road
 |_________________________________________|Longer road

Mặc dù cả hai con đường đều đến cùng một đích, nhưng con đường thẳng lại ngắn hơn và con đường kia dài hơn. Nếu sau đó bạn chọn nhầm người khác, không có quay đầu lại, và vì vậy bạn sẽ lãng phí thêm thời gian nếu bạn chọn con đường dài hơn. Điều này tương tự với những gì xảy ra trong máy tính và tôi hy vọng điều này sẽ giúp bạn hiểu rõ hơn.


Ngoài ra tôi muốn trích dẫn @Simon_Weaver từ các bình luận:

Nó không tạo ra ít dự đoán hơn - nó tạo ra ít dự đoán không chính xác hơn. Nó vẫn phải dự đoán cho mỗi lần qua vòng lặp ...


123

Tôi đã thử mã tương tự với MATLAB 2011b với MacBook Pro của tôi (Intel i7, 64 bit, 2,4 GHz) cho mã MATLAB sau:

% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);


%Sort the data
data1= sort(data); % data1= data  when no sorting done


%Start a stopwatch timer to measure the execution time
tic;

for i=1:100000

    for j=1:arraySize

        if data1(j)>=128
            sum=sum + data1(j);
        end
    end
end

toc;

ExeTimeWithSorting = toc - tic;

Các kết quả cho mã MATLAB ở trên như sau:

  a: Elapsed time (without sorting) = 3479.880861 seconds.
  b: Elapsed time (with sorting ) = 2377.873098 seconds.

Kết quả của mã C như trong @GManNickG tôi nhận được:

  a: Elapsed time (without sorting) = 19.8761 sec.
  b: Elapsed time (with sorting ) = 7.37778 sec.

Dựa trên điều này, có vẻ như MATLAB chậm hơn gần 175 lần so với triển khai C mà không cần sắp xếp và chậm hơn 350 lần khi sắp xếp. Nói cách khác, hiệu ứng (của dự đoán nhánh) là 1,46 lần đối với triển khai MATLAB và 2,7 lần đối với triển khai C.


7
Chỉ vì mục đích hoàn chỉnh, đây có lẽ không phải là cách bạn thực hiện điều đó trong Matlab. Tôi cá là nó sẽ nhanh hơn nhiều nếu được thực hiện sau khi véc tơ vấn đề.
ysap

1
Matlab thực hiện song song hóa / vector hóa tự động trong nhiều tình huống nhưng vấn đề ở đây là kiểm tra hiệu quả của dự đoán nhánh. Matlab dù sao cũng không được miễn dịch!
Shan

1
Matlab có sử dụng số nguyên gốc hoặc triển khai cụ thể trong phòng thí nghiệm mat (số lượng chữ số vô hạn hay không?)
Thorbjørn Ravn Andersen

54

Giả định bởi các câu trả lời khác mà người ta cần sắp xếp dữ liệu là không chính xác.

Đoạn mã sau không sắp xếp toàn bộ mảng, mà chỉ phân đoạn 200 phần tử của nó, và do đó chạy nhanh nhất.

Chỉ sắp xếp các phần tử k hoàn thành quá trình tiền xử lý trong thời gian tuyến tính O(n), thay vì O(n.log(n))thời gian cần thiết để sắp xếp toàn bộ mảng.

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

    for (unsigned c = 0; c < l; ++c)
        data[c] = std::rand() % 256;

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i) {
        for (unsigned c = 0; c < sizeof data / sizeof(int); ++c) {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

Điều này cũng "chứng minh" rằng nó không liên quan gì đến bất kỳ vấn đề thuật toán nào, chẳng hạn như thứ tự sắp xếp, và nó thực sự là dự đoán nhánh.


4
Tôi không thực sự thấy điều này chứng tỏ điều gì? Điều duy nhất bạn đã chỉ ra là "không thực hiện tất cả công việc sắp xếp toàn bộ mảng mất ít thời gian hơn so với sắp xếp toàn bộ mảng". Yêu cầu của bạn rằng "điều này cũng chạy nhanh nhất" phụ thuộc rất nhiều vào kiến ​​trúc. Xem câu trả lời của tôi về cách thức hoạt động trên ARM. PS, bạn có thể làm cho mã của mình nhanh hơn trên các kiến ​​trúc không phải ARM bằng cách đặt tổng kết bên trong vòng lặp khối 200 phần tử, sắp xếp ngược lại, sau đó sử dụng đề xuất phá vỡ của Yochai Timmer khi bạn nhận được giá trị ngoài phạm vi. Bằng cách đó, mỗi tổng kết khối 200 phần tử có thể được chấm dứt sớm.
Luke Hutchison

Nếu bạn chỉ muốn triển khai thuật toán một cách hiệu quả trên dữ liệu chưa được sắp xếp, bạn sẽ thực hiện thao tác đó một cách không phân nhánh (và với SIMD, ví dụ với x86 pcmpgtbđể tìm các phần tử với tập bit cao của chúng, sau đó AND thành 0 phần tử nhỏ hơn). Dành bất cứ lúc nào thực sự sắp xếp khối sẽ chậm hơn. Một phiên bản không phân nhánh sẽ có hiệu suất độc lập với dữ liệu, cũng chứng minh rằng chi phí đến từ việc đánh giá sai chi nhánh. Hoặc chỉ cần sử dụng bộ đếm hiệu suất để quan sát trực tiếp, như Skylake int_misc.clear_resteer_cycleshoặc int_misc.recovery_cyclesđể đếm các chu kỳ nhàn rỗi ở phía trước từ những người dự đoán sai
Peter Cordes

Cả hai ý kiến ​​trên dường như bỏ qua các vấn đề chung và phức tạp về thuật toán, ủng hộ việc ủng hộ phần cứng chuyên dụng với các hướng dẫn máy đặc biệt. Tôi thấy điều đầu tiên đặc biệt nhỏ nhặt ở chỗ nó hoàn toàn bác bỏ những hiểu biết chung quan trọng trong câu trả lời này theo hướng có lợi cho các hướng dẫn máy móc chuyên dụng.
dùng2297550

36

Câu trả lời của Bjarne Stroustrup cho câu hỏi này:

Nghe có vẻ như một câu hỏi phỏng vấn. Có đúng không Sao bạn biết? Đó là một ý tưởng tồi để trả lời các câu hỏi về hiệu quả mà không thực hiện một số phép đo, vì vậy điều quan trọng là phải biết cách đo.

Vì vậy, tôi đã thử với một vectơ một triệu số nguyên và nhận được:

Already sorted    32995 milliseconds
Shuffled          125944 milliseconds

Already sorted    18610 milliseconds
Shuffled          133304 milliseconds

Already sorted    17942 milliseconds
Shuffled          107858 milliseconds

Tôi đã chạy nó một vài lần để chắc chắn. Vâng, hiện tượng này là có thật. Mã khóa của tôi là:

void run(vector<int>& v, const string& label)
{
    auto t0 = system_clock::now();
    sort(v.begin(), v.end());
    auto t1 = system_clock::now();
    cout << label 
         << duration_cast<microseconds>(t1  t0).count() 
         << " milliseconds\n";
}

void tst()
{
    vector<int> v(1'000'000);
    iota(v.begin(), v.end(), 0);
    run(v, "already sorted ");
    std::shuffle(v.begin(), v.end(), std::mt19937{ std::random_device{}() });
    run(v, "shuffled    ");
}

Ít nhất hiện tượng này là có thật với trình biên dịch, thư viện tiêu chuẩn và cài đặt tối ưu hóa này. Thực hiện khác nhau có thể và làm cho câu trả lời khác nhau. Trên thực tế, ai đó đã thực hiện một nghiên cứu có hệ thống hơn (tìm kiếm trên web nhanh sẽ tìm thấy nó) và hầu hết các triển khai đều cho thấy hiệu quả đó.

Một lý do là dự đoán nhánh: thao tác chính trong thuật toán sắp xếp là “if(v[i] < pivot]) …”hoặc tương đương. Đối với một chuỗi được sắp xếp mà kiểm tra luôn luôn đúng trong khi đối với một chuỗi ngẫu nhiên, nhánh được chọn thay đổi ngẫu nhiên.

Một lý do khác là khi vectơ đã được sắp xếp, chúng ta không bao giờ cần di chuyển các phần tử đến vị trí chính xác của chúng. Hiệu quả của những chi tiết nhỏ này là hệ số năm hoặc sáu mà chúng ta đã thấy.

Quicksort (và sắp xếp nói chung) là một nghiên cứu phức tạp đã thu hút một số bộ óc vĩ đại nhất của khoa học máy tính. Một chức năng sắp xếp tốt là kết quả của cả việc chọn một thuật toán tốt và chú ý đến hiệu suất phần cứng trong quá trình thực hiện.

Nếu bạn muốn viết mã hiệu quả, bạn cần biết một chút về kiến ​​trúc máy.


28

Câu hỏi này bắt nguồn từ Mô hình Dự đoán Chi nhánh trên CPU. Tôi khuyên bạn nên đọc bài viết này:

Tăng tỷ lệ tìm nạp lệnh thông qua dự đoán nhiều chi nhánh và bộ đệm địa chỉ chi nhánh

Khi bạn đã sắp xếp các phần tử, IR không thể bận tâm tìm nạp tất cả các hướng dẫn CPU, lặp đi lặp lại, Nó tìm nạp chúng từ bộ đệm.


Các hướng dẫn luôn nóng trong bộ đệm hướng dẫn L1 của CPU bất kể dự đoán sai. Vấn đề là tìm nạp chúng vào đường ống theo đúng thứ tự, trước khi các hướng dẫn trước đó ngay lập tức được giải mã và thực hiện xong.
Peter Cordes

15

Một cách để tránh các lỗi dự đoán nhánh là xây dựng bảng tra cứu và lập chỉ mục cho nó bằng cách sử dụng dữ liệu. Stefan de Bruijn đã thảo luận rằng trong câu trả lời của mình.

Nhưng trong trường hợp này, chúng tôi biết các giá trị nằm trong phạm vi [0, 255] và chúng tôi chỉ quan tâm đến các giá trị> = 128. Điều đó có nghĩa là chúng tôi có thể dễ dàng trích xuất một bit sẽ cho chúng tôi biết liệu chúng tôi có muốn giá trị hay không: bằng cách thay đổi dữ liệu ở bên phải 7 bit, chúng tôi chỉ còn lại 0 bit hoặc 1 bit và chúng tôi chỉ muốn thêm giá trị khi chúng tôi có 1 bit. Hãy gọi bit này là "bit quyết định".

Bằng cách sử dụng giá trị 0/1 của bit quyết định làm chỉ mục thành một mảng, chúng ta có thể tạo mã sẽ nhanh như nhau cho dù dữ liệu được sắp xếp hay không được sắp xếp. Mã của chúng tôi sẽ luôn thêm một giá trị, nhưng khi bit quyết định bằng 0, chúng tôi sẽ thêm giá trị ở đâu đó mà chúng tôi không quan tâm. Đây là mã:

// Kiểm tra

clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

Mã này lãng phí một nửa số bổ sung nhưng không bao giờ có lỗi dự đoán chi nhánh. Nó nhanh hơn rất nhiều trên dữ liệu ngẫu nhiên so với phiên bản với câu lệnh if thực tế.

Nhưng trong thử nghiệm của tôi, một bảng tra cứu rõ ràng nhanh hơn một chút so với điều này, có lẽ bởi vì việc lập chỉ mục vào bảng tra cứu nhanh hơn một chút so với dịch chuyển bit. Điều này cho thấy cách mã của tôi thiết lập và sử dụng bảng tra cứu (được gọi một cách đơn giản là lut cho "Bảng tra cứu" trong mã). Đây là mã C ++:

// Khai báo và sau đó điền vào bảng tra cứu

int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

Trong trường hợp này, bảng tra cứu chỉ có 256 byte, do đó, nó phù hợp độc đáo trong bộ đệm và tất cả đều nhanh. Kỹ thuật này sẽ không hoạt động tốt nếu dữ liệu là các giá trị 24 bit và chúng tôi chỉ muốn một nửa trong số chúng ... bảng tra cứu sẽ quá lớn để trở nên thực tế. Mặt khác, chúng ta có thể kết hợp hai kỹ thuật được hiển thị ở trên: đầu tiên dịch chuyển các bit qua, sau đó lập chỉ mục một bảng tra cứu. Đối với giá trị 24 bit mà chúng tôi chỉ muốn giá trị nửa trên cùng, chúng tôi có khả năng có thể dịch chuyển dữ liệu sang phải 12 bit và được để lại giá trị 12 bit cho chỉ mục bảng. Một chỉ mục bảng 12 bit ngụ ý một bảng gồm 4096 giá trị, có thể là thực tế.

Kỹ thuật lập chỉ mục thành một mảng, thay vì sử dụng câu lệnh if, có thể được sử dụng để quyết định sử dụng con trỏ nào. Tôi thấy một thư viện triển khai cây nhị phân và thay vì có hai con trỏ được đặt tên (pLeft và pRight hoặc bất cứ thứ gì) có một mảng dài 2 con trỏ và sử dụng kỹ thuật "bit quyết định" để quyết định nên theo dõi cái nào. Ví dụ: thay vì:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;
this library would do something like:

i = (x < node->value);
node = node->link[i];

đó là một giải pháp tốt có thể nó sẽ hoạt động


Trình biên dịch / phần cứng C ++ nào bạn đã kiểm tra điều này với, và với các tùy chọn trình biên dịch nào? Tôi ngạc nhiên khi phiên bản gốc không tự động chuyển sang mã SIMD không phân nhánh. Bạn đã kích hoạt tối ưu hóa đầy đủ?
Peter Cordes

Một bảng tra cứu mục 4096 nghe có vẻ điên rồ. Nếu bạn chuyển ra bất kỳ bit nào , bạn không thể chỉ sử dụng kết quả LUT nếu bạn muốn thêm số gốc. Tất cả đều giống như những thủ thuật ngớ ngẩn để làm việc xung quanh trình biên dịch của bạn không dễ dàng sử dụng các kỹ thuật không phân nhánh. Đơn giản hơn sẽ là mask = tmp < 128 : 0 : -1UL;/total += tmp & mask;
Peter Cordes
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.