Tin tức chung·Hacker News·0 lượt xem

Hai nghiên cứu về tối ưu hóa trình biên dịch

Two studies in compiler optimisations

AI Summary

Bài viết này khám phá cách những thay đổi nhỏ trong code, tưởng chừng không đáng kể, lại có thể ảnh hưởng lớn đến các tối ưu hóa của compiler. Tác giả đưa ra hai ví dụ cụ thể: modular increment và endianness conversion, để minh họa điều này. Qua đó, bài viết nhấn mạnh rằng các optimization passes của LLVM, ví dụ như peephole optimizations hay loop invariant code motion, có thể phản ứng khác nhau với những biến thể nhỏ trong cấu trúc code. Điều này cho thấy hành vi của compiler không phải lúc nào cũng dễ đoán và đôi khi, việc "cố gắng giúp đỡ" compiler lại có thể làm giảm hiệu năng. Do đó, các developer nên hiểu rõ điểm này và việc kiểm tra assembly được sinh ra là rất quan trọng để thực sự nắm bắt được compiler đang làm gì.

Trong khi nhiều lập trình viên thiên về hiệu suất đã quen thuộc với những thứ gần như phi thường. Khả năng tối ưu hóa mã của các trình biên dịch hiện đại và nhiều người trong chúng ta đã dành vô số thời gian cho...

Mục lục

Giới thiệu

Trong khi nhiều lập trình viên thiên về hiệu suất đã quen thuộc với những thứ gần như phi thường. khả năng tối ưu hóa mã của các trình biên dịch hiện đại và nhiều người trong chúng ta đã dành vô số thời gian cho Trình khám phá trình biên dịch kiểm tra sự khác biệt giữa Hội được tạo bởi các phiên bản khác nhau của gcc và clang, hầu hết có thể chưa xem xét kỹ lưỡng để xem cách phép thuật xảy ra. Đó là một minh chứng cho chất lượng của chúng mà hầu hết chúng ta chỉ đơn giản coi trình biên dịch là màu đen. hộp: ít nhiều mã có thể đọc được, các tệp nhị phân nhanh xuất hiện. Tuy nhiên, đôi khi, dường như những thay đổi vô hại—thậm chí có thể nhằm mục đích trợ giúp trình biên dịch—có thể gây ra hiệu suất đáng ngạc nhiên những vấn đề mà chúng tôi khó có thể giải thích nếu không hiểu sâu hơn về nguyên nhân cơ bản máy móc.

Trong bài đăng này, chúng ta sẽ đi sâu vào cách triển khai một số LLVM1 tối ưu hóa vượt qua bằng cách sử dụng Tuy nhiên, hai ví dụ đơn giản sẽ giúp chúng ta vén bức màn về sự phức tạp liên quan trong việc tạo ra mã được tối ưu hóa cao. Chúng ta sẽ thấy những thay đổi nhỏ về nguồn có thể kích hoạt các đường dẫn trong quá trình xử lý nội bộ của trình biên dịch với những hậu quả không mong muốn, chứng tỏ cách đạt được hiệu suất cao có thể vừa là một nghệ thuật vừa là một môn khoa học đối với cả nhà phát triển trình biên dịch và người dùng. Tôi cũng đưa ra một số bài tập dành cho những ai muốn làm bẩn tay mình, nhưng họ không bắt buộc phải theo dõi văn bản chính.

Tôi sử dụng LLVM 22.1.0 làm phương pháp triển khai tham khảo trong suốt bài đăng này. các các ví dụ được viết bằng C++23 (rất cơ bản) và đích x86-64, và mã hội sử dụng Intel cú pháp. Kiến thức trước về LLVM IR là không bắt buộc nhưng nó có thể hữu ích (Tôi khuyên bạn nên Giới thiệu nhẹ nhàng về LLVM IR).

Trường hợp 1: Tăng mô-đun

Kịch bản

Xem xét hàm C++ sau đây để lấy chỉ mục tiếp theo vào một mảng hoặc vectơ các phần tử được truy cập theo kiểu vòng tròn, với cur là chỉ mục hiện tại và đếm số lượng phần tử:

chưa ký next_naive(chưa ký cur, chưa ký đếm)
{
    trở lại (cur + 1) % count;

Như đã viết, mã này yêu cầu lệnh chia 32 bit đắt tiền (6 chu kỳ với 12 chu kỳ độ trễ trên lõi Intel IceLake, tệ hơn ở các thế hệ trước). Tất nhiên là có rất nhiều các thủ thuật để thay thế phép chia bằng hằng số bằng các phép tính số học rẻ hơn—lũy thừa của hai là trường hợp nổi tiếng nhất—nhưng vì ở đây count là giá trị thời gian chạy động nên trình biên dịch không thể giúp chúng ta:

next_naive(unsigned int, unsigned int):
        lea eax , [rdi + 1]
        xor edx, edx
        div esi
        mov eax, edx
        ret

Tuy nhiên, chúng tôi biết một điều mà trình biên dịch không biết: bởi vì cur là một chỉ mục, nó phải luôn là hoàn toàn nhỏ hơn count. Chúng ta có thể tận dụng điều này để thay thế phép chia bằng nhiều di chuyển có điều kiện rẻ hơn bằng cách viết:

chưa ký next_cmov(chưa ký cur, chưa ký đếm)
{
    tự động n = cur + 1;
    trở lại n == đếm ? 0 : n;

next_cmov(unsigned int, unsigned int):
        lea ecx, [rdi + 1]
        xor eax, eax
        cmp ecx, esi
        cmovne eax, ecx
        ret

Trình biên dịch có thể tự mình thực hiện việc chuyển đổi này không? Hãy thử dùng thử C++23 mới giả định thuộc tính2:

chưa ký next_assume(chưa ký cur, chưa ký đếm)
{
    [[giả sử(cur <  đếm)]];
    trở lại (cur + 1) % count;

next_assume(unsigned int, unsigned int):
        lea ecx, [ rdi + 1]
        xor eax, eax
        cmp ecx, esi
        cmovne eax, ecx
        ret

Mã hoàn toàn giống với next_cmov()! Vậy clang đã tìm ra nó như thế nào?

Tối ưu hóa lỗ nhìn trộm

Khi cố gắng tìm hiểu xem một sự tối ưu hóa nhất định đến từ đâu (hoặc có thể thiếu nó ở đâu), Bước đầu tiên là xác định đường chuyền nơi nó xuất hiện. Hiện nay, cách thuận tiện nhất để điều tra tác động của các bước tối ưu hóa LLVM khác nhau là sử dụng Opt của Compiler Explorer Chế độ xem đường ống:

Ảnh chụp màn hình của giao diện Compiler Explorer, hiển thị bảng trình biên dịch. Menu 'Thêm mới' đang mở, với tùy chọn 'Đường dẫn lựa chọn' được tô sáng.

(Bạn có thể nhận được thông tin tương tự cục bộ bằng cách chuyển các tùy chọn -mllvm -print-changed tới clang, nhưng không có sự khác biệt nổi bật.)

Khi xem qua sự phát triển của LLVM IR dành cho next_assume() , chúng ta nhanh chóng nhận thấy rằng việc chuyển đổi từ lệnh urem (đại diện cho hoạt động modulo) thành cặp icmp+select (cuối cùng được dịch sang x86 cmp+cmov) xảy ra ở InstCombine:

 %cmp = icmp ult i32 %cur, %count
   gọi void @llvm.assume(i1 %cmp)
   %add = thêm i32 %cur, 1
- %rem = urem i32 %add, %count
+ %0 = icmp eq i32 %add, %count
+ %rem = chọn i1 %0, i32 0, i32 %add
 ret i32 %rem

InstCombine chịu trách nhiệm về nhiều lỗ nhìn trộm tối ưu hóa không sửa đổi điều khiển đồ thị luồng, chẳng hạn như chuyển đổi phép nhân lũy thừa hai thành phép dịch trái. Nó tiến hành bằng cách truy cập tất cả các hướng dẫn trong một hàm và khớp chúng với một trong hàng trăm mẫu bằng cách sử dụng khớp mẫu chung cơ chế. Thẻ thực sự chạy nhiều lần trong quá trình biên dịch 3, vì các loại thẻ khác thường khám phá các cơ hội tối ưu hóa mới bằng cách thực hiện các thao tác đơn giản hóa khác nhau, bỏ vòng lặp, v.v.

Trong trường hợp này, logic chuyển đổi chính khá đơn giản và khép kín. Nếu chúng ta nhìn qua vào thư mục lib/Transforms/InstCombine trong kho LLVM, chúng ta sẽ thấy đoạn code được sắp xếp gọn gàng được sắp xếp thành các tệp khác nhau cho các loại hướng dẫn khác nhau phù hợp với thẻ, mỗi tệp chứa các phương thức của khách truy cập cho các mã quan tâm. Một trong những tập tin này là InstCombineMulDivRem.cpp, nơi chúng ta có thể tìm thấy phương thức visitURem() tương đối ngắn và tại kết thúc:4

 // Đối với "(X + 1) % Op1" và nếu (X u< Op1) => (X + 1) == Op1 ? 0 : X + 1 .
  nếu (khớp (Op0, m_Add(m_Value(X), m_One()))) {
    Giá trị *Giá trị =
        simplifyICmpInst(ICmpInst::ICMP_ULT, X, Op1,  SQ.getWithInstruction(&I));
    if (Val && match(Val, m_One())) {
      Giá trị *FrozenOp0 = Op0;
      nếu ( !được đảm bảoNotToBeUndef(Op0))
        FrozenOp0 = Builder.CreateFreeze(Op0, Op0->getName() + ".frozen");
      Giá trị *Cmp =  Trình tạo.CreateICmpEQ(FrozenOp0, Op1);
      return createSelectInstWithUnknownProfile(
          Cmp, ConstantInt::getNullValue(Ty), FrozenOp0);
    
   

Như nhận xét trước khối mô tả, đoạn mã này khớp chính xác với mẫu chúng tôi đã sử dụng trong next_assume() and generates the same optimisation we wrote by hand in next_cmov() for cases trong đó nó có thể chứng minh rằng giá trị tăng trước nhỏ hơn môđun.5 Để làm điều này đảm bảo, trình biên dịch khéo léo sử dụng lại phân tích tương tự được sử dụng để gấp kết quả của icmp hướng dẫn, cụ thể là simplifyICmpInst() chức năng . Cuộn xuống phần triển khai của nó, bạn có thể thấy nhiều mẫu phù hợp; đặc biệt Điều mà chúng tôi quan tâm ở đây là trình trợ giúp simplifyICmpWithDominatingAssume, nó kiểm tra tất cả các giả định áp dụng cho cả hai bên của phép so sánh (dưới dạng gọi tới llvm.assume() nội tại) để xem liệu chúng có hàm ý điều kiện mà chúng tôi quan tâm hay không. 6 Cuối cùng, điều này giải thích làm thế nào thông tin chúng tôi mã hóa trong thuộc tính giả sử đã cho phép trình biên dịch loại bỏ phiền toái đó phép chia.

Xem qua việc triển khai simplifyICmpInst() cũng cho chúng ta thấy các tùy chọn khác để truyền đạt điều kiện tiên quyết này. Ví dụ: phiên bản chức năng này của chúng tôi được tối ưu hóa theo cách tương tự cách khi xác nhận được bật:

chưa ký next_assert(chưa ký cur, chưa ký đếm)
{
    khẳng định(cur < đếm);
    trở lại ( cur + 1) % đếm;

Ở đây, phân tích đơn giản hóa sử dụng điều kiện ẩn trong macro assert() để tìm ra rằng chúng tôi chỉ thực thi lệnh urem nếu cur < count (xem isImpliedByDomCondition() phương pháp). Điều này có thể gây ra hậu quả không mong muốn khi tạo các bản dựng có xác nhận được bật nhanh hơn trong một số trường hợp; chúng tôi có thể khôi phục hiệu suất bằng cách thay thế các xác nhận bị vô hiệu hóa bằng các thuộc tính assume, không có tác động trong thời gian chạy.

Bài tập 1: Nếu chúng ta cố gắng khéo léo và viết lại hàm của mình bằng cách sử dụng các phép toán theo bit để tránh nhánh tiềm năng, InstCombine nhìn thấu được mánh khóe của chúng tôi và tạo ra chính xác đầu ra:

chưa ký next_bitwise(chưa ký cur, chưa ký đếm)
{
    tự động n =  hiện tại + 1;
    return n & -(n != count);

 %add = thêm i32 %cur, 1
- %cmp = icmp ne i32 %add, %count
- %conv = zext i1 %cmp tới i32
- %sub = sub nsw i32 0, %conv
- %and = và i32 %add, %sub
 + %cmp.not = icmp eq i32 %add, %count
+ %and = chọn i1 %cmp.not, i32 0, i32 %add
 ret i32% và

Theo dõi quá trình thực thi thẻ để tìm ra mẫu phù hợp để thực hiện việc này. Bắt đầu với khách truy cập để xem hướng dẫn sub. (rr có thể rất hữu ích cho việc này.)

Bất biến vòng lặp

Cho đến nay, tất cả các ví dụ mà chúng tôi đã xem đều yêu cầu chúng tôi chỉ ra rõ ràng, bằng cách này hay cách khác, những gì mối quan hệ giữa curcount để mã có thể được tối ưu hóa cho phù hợp. Ở nơi khác trường hợp mối quan hệ này có thể ẩn, chẳng hạn như khi lặp qua các giá trị modulo trong một vòng lặp:

void iterate_1(unsigned upto, unsigned count)
{
    for (chưa ký i = 0, r  = 0; i < upto; ++i, r = (r + 1) % đếm)
        // buộc trình biên dịch tính toán r thay vì loại bỏ toàn bộ vòng lặp
        asm("" :: "r" (r));

Nếu chúng ta nhìn vào Assembly được tạo ra, có vẻ như trình biên dịch không đủ thông minh để suy luận tự tối ưu hóa:

iterate_1(unsigned int, unsigned int):
        kiểm tra edi , edi
        je .LBB4_3
        xor edx, edx
.LBB4_2:
        inc edx
        mov eax, edx
        xor edx, edx
        div esi
        tháng 12 edi
        jne .LBB4_2
.LBB4_3:
        ret

Tuy nhiên, như thường lệ, trình biên dịch đã nhận thấy một điều mà chúng ta đã bỏ qua: việc tối ưu hóa phụ thuộc vào việc có r < count, nhưng nếu count bằng 0 thì điều kiện này không thể được đáp ứng. 7 Bạn nên xem chính xác cách người tối ưu hóa đưa ra kết luận này. Nếu chúng ta nhìn vào IR tại đầu vào của lượt InstCombine đầu tiên, chúng ta sẽ thấy rằng mẫu (X + 1) % Op1 từ visitURem() hiện khớp X với hướng dẫn phi sau8:

 %r.0 = phi i32 [  0, %entry ], [ %rem, %for.body ]

Khi tìm thấy lệnh phi, simplifyICmpInst() kiểm tra rằng việc so sánh có thể được đơn giản hóa đối với tất cả các giá trị đầu vào có thể có của phi (trong trường hợp này là 0 và giá trị cuối cùng được tính cho r) và nếu vậy thì nó sẽ tạo ra cùng một kết quả cho tất cả chúng. Với mã của chúng tôi ở trên, không có cách nào để xác định liệu 0 < count và việc đơn giản hóa có thất bại hay không. Nếu chúng ta bao gồm các cơ sở của mình bằng cách thêm if (count == 0) return; câu lệnh trước vòng lặp, chúng ta có thể đóng khoảng trống và nhận được kết quả như mong đợi:

.LBB4_2:
        inc ecx
        cmp ecx, esi
        cmove ecx, eax
        tháng 12 edi
        jne .LBB4_2

Nhưng điều đó không giải thích được cách trình biên dịch xác định rằng %rem < %count trong đầu vào thứ hai của phi, khi chúng ta không còn có một giả định hoặc điều kiện rõ ràng nào về tác động đó nữa. Trường hợp đó là được xử lý bởi một cái khác mẫu nằm sâu trong các quy trình hướng dẫn gấp, xử lý cụ thể các phép so sánh giữa phần còn lại của một mô đun nhất định và chính mô đun đó:

 // icmp pred (urem X, Y), Y
  nếu  (khớp(LBO, m_URem(m_Value(), m_Specific(RHS)))) {
    chuyển đổi (Dự đoán) {
    // ...
    trường hợp ICmpInst::ICMP_NE:
    trường hợp ICmpInst::ICMP_ULT:
    trường hợp ICmpInst::ICMP_ULE:
      return getTrue(ITy);
    
  

Đúng vậy, đó là tất cả các mẫu.

Bài tập 2: Nếu chúng ta thay đổi điều kiện vòng lặp thành i < std::min(upto, count) trình biên dịch không còn có thể thay thế lệnh urem nữa. Bắt đầu từ isImpliedByDomCondition() phương pháp được đề cập trước đó, hãy thử tìm ra nơi bạn có thể thêm hỗ trợ để xem qua mức tối thiểu/tối đa thành ngữ khi đánh giá các điều kiện ngụ ý và liệu bạn có thể sử dụng lại bất kỳ mã phân tích hiện có nào hay không. Điều gì sẽ xảy ra nếu chúng ta muốn loại bỏ hoàn toàn phần còn lại hiện đang dư thừa?

Tất cả các con đường

Có vẻ như không cần thiết phải xác định một biến phụ bổ sung để giữ phần còn lại khi chúng ta có thể chỉ cần tính toán nó từ chính bộ đếm vòng lặp, ngay cả khi mã được tạo ra có hiệu quả. Hãy bỏ nó đi:

void iterate_2(unsigned upto, unsigned count)
{
    cho (chưa ký i = 0; i < upto; ++i) asm("" :: "r" (i % count));

iterate_2(unsigned int, unsigned int):
        kiểm tra edi, edi
         je .LBB5_3
        xor eax, eax
        xor ecx, ecx
        xor edx, edx
.LBB5_2:
        inc ecx
        cmp ecx, esi
        cmove  ecx, eax
        inc edx
        cmp edi, edx
        jne .LBB5_2
.LBB5_3:
        ret

Có vẻ như trình biên dịch nhận ra cơ hội tương tự ở đây: phép chia vẫn được thay thế bằng một động thái có điều kiện tốt, sử dụng một thanh ghi riêng để giữ phần còn lại. Nhưng điều này trông không giống giống như hoạt động của mã InstCombine mà chúng ta đã thấy trước đó; và tại sao chúng ta tăng vòng lặp bộ đếm, yêu cầu so sánh bổ sung, thay vì giảm dần và sử dụng cờ 0, như trước đây?

Lần này, nếu kiểm tra kết quả của các lần tối ưu hóa khác nhau, chúng ta sẽ thấy rằng quá trình chuyển đổi không đến từ InstCombine mà đến từ một đường chuyền khác, CodeGenPrepare, chạy trong thời gian ngắn trước khi lựa chọn lệnh nhưng sau khi giảm cường độ vòng lặp (LSR) chịu trách nhiệm chuyển đổi bộ đếm tăng thành số giảm. InstCombine không còn có thể khớp với mẫu mà nó mong đợi nữa, vì nó không chứa i < count; và bởi vì i được sử dụng bên trong phần thân của vòng lặp để tính toán phần còn lại, LSR không còn có thể thay thế nó bằng bộ đếm xuống nữa. CodeGenPrepare sau đó hành động như một phương sách cuối cùng, xác định một đối tượng cụ thể mẫu mà nó có thể chuyển đổi thành mã gần như tối ưu.

Điều này minh họa hai điểm mà chúng ta sẽ khám phá sâu hơn trong phần tiếp theo:

  1. Các trình biên dịch (và mã của chúng) có một lượng dư thừa khá lớn như một sự thỏa hiệp kỹ thuật đối với tối đa hóa mức độ phù hợp.
  2. Thứ tự của các bước, kể cả thời điểm và vị trí chúng được lặp lại, có thể có ảnh hưởng đáng kể về kết quả cuối cùng.

Bài tập 3: LLVM gửi trình tối ưu hóa của nó, được đặt tên phù hợp là opt, dưới dạng tệp nhị phân mô-đun tách biệt với giao diện người dùng của trình biên dịch. Lưu cách triển khai iterate_2() ở trên vào tệp iterate.cpp và thử chạy lệnh sau bằng cách sử dụng cài đặt clang cục bộ của bạn:

clang++ -O2 -S -emit-llvm -fno-discard-value-names -o- iterate.cpp \
    | opt -S -pass= "require,loop-reduce,codegenprepare" -

Bạn sẽ nhận được kết quả tương tự như những gì bạn có thể quan sát sau khi vượt qua CodeGenPrepare trong Chế độ xem đường ống Opt của trình biên dịch Explorer. Điều gì sẽ xảy ra nếu bạn thêm một thẻ loop-reduce khác vào cuối?

Bài tập 4: Nếu chúng ta thực hiện thay đổi tương tự đối với điều kiện vòng lặp trong iterate_2() như trong Bài tập 2, lệnh còn lại bị loại khỏi mã được tạo. Pass nào làm được điều này và tại sao không trước đây nó có hoạt động không?

Trường hợp 2: Chuyển đổi độ bền

Kịch bản

Hầu hết các định dạng nhị phân, cho dù để lưu trữ, IPC hay kết nối mạng, đều chỉ định một thứ tự byte cụ thể cho trường số; ví dụ: các tệp cơ sở dữ liệu SQLite sử dụng các trường big endian, trong khi Protobuf i32 và Các trường i64 có dạng endian nhỏ. Cách thông thường để viết giải mã bất khả tri về độ bền của máy chủ chức năng là nối rõ ràng các byte đầu vào riêng lẻ. Đối với các giá trị 32-bit trông giống như thế nào đó như (giả sử int 32-bit nên chúng ta không cần ép kiểu):

uint32_t load_le32(const uint8_t* dữ liệu)
{
    trả về dữ liệu [0] | (dữ liệu[1] << 8) | (dữ liệu[2] << 16) | (dữ liệu[3] << 24 );

uint32_t load_be32(const uint8_t* dữ liệu)
{
    trả về dữ liệu[3] | (dữ liệu[2] << 8) | ( dữ liệu[1] << 16) | (dữ liệu[0] << 24);

Trên x86, hỗ trợ các tải không được phân bổ từ bộ nhớ, điều này tạo ra Tập hợp tối ưu ở -O1 và ở trên:

load_le32 (chưa ký char const*):
        mov eax, dword ptr [rdi]
        ret
load_be32(chưa ký char const*):
        di chuyển  eax, dword ptr [rdi]
        bswap eax
        ret

Một số nhà phát triển mắc chứng DRY mãn tính và phải đối mặt với viễn cảnh phải viết những thứ này các hàm và các hàm tương đương 64-bit của chúng lần thứ mười một hoặc có thể hoạt động với các kích thước nguyên ngoài 4 và 8 byte, thay vào đó, bạn nên viết một phiên bản chung duy nhất:

static uint64_t tải(const uint8_t* dữ liệu , size_t sz, bool be)
{
    uint64_t val = 0;
    for (size_t i = 0; i < sz; ++i)
         val |= static_cast<uint64_t>(dữ liệu[be ? sz - i - 1 : i]) << (8 * i);
    trả về giá trị ;

uint32_t load_le32(const uint8_t* dữ liệu) { return tải(dữ liệu, 4, false); 
uint32_t load_be32(const uint8_t * dữ liệu) { return tải(dữ liệu, 4, true); 
uint64_t load_le64(const uint8_t* dữ liệu) { return tải(dữ liệu , 8, false); 
uint64_t load_be64(const uint8_t* dữ liệu) { return tải(dữ liệu, 8, true); 

Khi đó, người đánh giá mã có thể lo lắng một cách hợp lý về việc liệu có bất kỳ sự hồi quy hiệu suất nào không so với các chức năng truyền thống hơn. Hãy xem điều gì xảy ra khi chúng tôi biên dịch:

load_le32(unsigned char const*):
        mov eax, dword ptr [rdi]
        ret
load_be32(chưa ký char const*):
        mov eax, dword ptr [rdi]
        bswap eax
        ret
load_le64(chưa ký char const*):
        mov rax , qword ptr [rdi]
        ret
load_be64(chưa ký char const*):
        mov rax, qword ptr [rdi]
        bswap rax ret

Có vẻ như nhà phát triển lười biếng (hoặc có lẽ có tầm nhìn xa) của chúng tôi đã rõ ràng—ít nhất là nếu họ không làm vậy cần sử dụng gcc, ít nhất là ở phiên bản 15.2, không thể tối ưu hóa mã này. Hãy kiểm tra tiến trình của quy trình tối ưu hóa một lần nữa để hiểu rõ hơn về cách trình biên dịch thực hiện điều này tắt.

Lựa chọn hướng dẫn

Dựa trên những gì chúng ta đã thấy ở nửa đầu của bài đăng, có thể hơi ngạc nhiên khi tìm hiểu kỹ Trình xem đường dẫn Opt đáng tin cậy của chúng tôi rằng phải mất tất cả cho đến khi Giai đoạn lựa chọn lệnh (còn được gọi là ISel) cho các tải liên tiếp được xếp thành tải đơn 32 hoặc 64 bit. Chúng ta sẽ xem trong phần tiếp theo cách chúng ta có thể kích hoạt tính năng đơn giản hóa sớm hơn trong một số trường hợp, nhưng chúng ta hãy dành chút thời gian để hiểu ISel là gì và nó thực hiện thao tác gấp như thế nào.

Lựa chọn lệnh là giai đoạn đầu tiên của back-end trình biên dịch, chạy sau tất cả các lệnh chung. tối ưu hóa đã vượt qua (trong LLVM, sự khác biệt này cũng được phản ánh trong sự tách biệt giữa opt và các tệp nhị phân llc). Mục đích của nó là chuyển đổi LLVM IR do trình tối ưu hóa tạo ra thành Máy IR (MIR), một cách thể hiện cụ thể theo mục tiêu của luồng lệnh phù hợp với các lượt truyền xuống chẳng hạn như lập kế hoạch hoặc phân bổ đăng ký. Hầu hết các mục tiêu LLVM, bao gồm x86, đều sử dụng SelectionDAG làm khung lựa chọn hướng dẫn của họ. Chúng tôi sẽ không đi sâu vào SelectionDAG tại đây9; vì mục đích của chúng tôi, chỉ cần biết rằng nó bao gồm trình tối ưu hóa lỗ nhìn trộm của riêng nó được gọi là DAGCombiner, tương tự như InstCombine, có thể nắm bắt các cơ hội đơn giản hóa do tạo mã hoặc cụ thể cho mục tiêu đó.

Giống như InstCombiner truy cập từng lệnh trong một khối cơ bản, DAGCombiner truy cập từng nút trong khối DAG được xây dựng từ những hướng dẫn tương tự. Nó ở đây, trong số rất nhiều phép biến đổi được thực hiện bởi hoặc khách truy cập, chúng tôi tìm thấy MatchLoadCombine() phương pháp:

/// Khớp một mẫu trong đó giá trị vô hướng loại rộng được tải bởi nhiều loại hẹp
 /// tải và kết hợp bằng ca và ors. Gấp nó thành một tải hoặc một tải
/// và BSWAP nếu mục tiêu hỗ trợ nó.

Tất nhiên, đây là mẫu chính xác mà chúng ta thấy trong LLVM IR ở đầu vào SelectionDAG, sau load() được nội tuyến, các đối số không đổi được truyền đi và các vòng lặp được giải phóng hoàn toàn. Cái gì Tuy nhiên, điều thú vị về phương pháp này là nó không thực hiện khớp mẫu đơn giản như chúng ta đã làm. thấy trước đây trong InstCombine, nhưng thay vào đó theo dõi nguồn gốc của từng byte ở đầu ra của Lệnh hoặc đệ quy thông qua DAG (sử dụng tínhByteProvider() chức năng), không chỉ từ tải mà còn thông qua các hoạt động theo bit, hoán đổi byte và phần mở rộng 0/dấu. Cái này làm cho nó đủ mạnh để xử lý các biểu thức như:

static uint64_t load_alt(const uint8_t* dữ liệu, size_t sz, bool được)
{
    uint64_t val = 0; for (size_t i = 0; i < sz; ++i)
        val = (val << 8) | dữ liệu[ ? i : sz - i - 1];
    trả về giá trị;

Tuy nhiên, trong trường hợp này, chúng tôi nhận thấy chỉ có các chức năng 32-bit mới được tối ưu hóa hoàn toàn. Mặc dù chúng ta có cùng số lượng lệnh LLVM IR như trước đây, nhưng phiên bản mới yêu cầu đệ quy sâu hơn vào DAG để khám phá nguồn byte được tải sớm nhất (vì chúng tôi thực hiện hai thao tác trên biến đầu ra trong mỗi lần lặp, thay vì một), nhấn giới hạn được mã hóa cứng của mười cấp độ đệ quy. Đây là một ví dụ điển hình về cách có một bản đồ tư duy quá trình xử lý nội bộ của trình biên dịch, cũng như việc bám sát các mẫu phổ biến hơn, có thể giúp chúng ta tránh những trường hợp bệnh lý như vậy.10

Trợ giúp trình biên dịch

Một số nhà phát triển C++ dày dạn kinh nghiệm có thể phản đối ví dụ này theo bản năng. Rốt cuộc, nếu chúng ta chỉ have a few possible argument values known at compile-time, why wouldn’t we just template our thay vì dựa vào trình biên dịch để truyền các hằng số?

mẫu <size_t sz, bool be>
static uint64_t tải(const  uint8_t* dữ liệu)
{
    uint64_t val = 0;
    for (size_t i = 0; i < sz; ++i)
        val |= static_cast <uint64_t>(dữ liệu[be ? sz - i - 1 : i]) << (8 * i);
    trả về giá trị;

uint32_t  Load_le32(const uint8_t* dữ liệu) { return tải<4, false>(dữ liệu); 
uint32_t load_be32(const uint8_t* dữ liệu ) { trở lại tải<4, true>(dữ liệu); 
uint64_t load_le64(const uint8_t* dữ liệu) { return tải<8, false>(dữ liệu); 
uint64_t load_be64(const uint8_t* dữ liệu) { return tải<8, true>(dữ liệu); 

đầu ra không thay đổi, nhưng lần này IR cho các hàm little-endian được tối ưu hóa thành một lần tải duy nhất rất lâu trước khi chúng ta đạt được lệnh giai đoạn lựa chọn, trong một đường chuyền được gọi là AggressiveInstCombine . Giống như InstCombine, thẻ này thực hiện nhiều cách tối ưu hóa lỗ nhìn trộm khác nhau nhưng nó tập trung vào những thứ đó có xu hướng ít cần thiết hơn hoặc mất nhiều thời gian hơn để chạy, đó là lý do tại sao nó chỉ chạy một lần ở -O2 (và hoàn toàn không có ở -O1). Một trong những tối ưu hóa này được thực hiện trong foldConsecutiveLoads() chức năng, khớp với các mẫu có dạng “bitwise-hoặc (tùy chọn) dịch trái, mở rộng bằng 0, các lần tải liên tiếp”, chẳng hạn như những lần chúng tôi tìm thấy trong các hàm của mình.

Vì nó dựa vào việc khớp mẫu đơn giản nên AggressiveInstCombine không mạnh bằng DAGCombiner; đặc biệt, nó không thể tối ưu hóa màn hình gấp được sử dụng trong load_alt() ở trên ngay cả đối với 32 bit. Nó cũng có không ảnh hưởng đến tải trọng lớn của chúng tôi bởi vì, với tư cách là bước tối ưu hóa cấp trung (không giống như DAGCombiner, là một phần của back-end của trình biên dịch), nó cố gắng tạo ra IR độc lập với mục tiêu nhiều nhất có thể. có thể. Việc gấp tải lượng endian lớn lên mục tiêu endian nhỏ và ngược lại sẽ yêu cầu thêm nội tại trao đổi byte dành riêng cho mục tiêu sẽ làm phức tạp việc phân tích và tối ưu hóa tiếp theo vượt qua.

Bài tập 5: Tạo mẫu cho hàm load_alt() như trong phần này và kiểm tra kết quả đầu ra. Nó có phù hợp với kết quả trước đó của chúng tôi không? Điều này cho chúng ta biết điều gì về sự tương tác giữa tạo khuôn mẫu và quy trình tối ưu hóa?

Bài tập 6: Thay vì tải byte theo thứ tự khác tùy thuộc vào độ bền, chúng ta cũng có thể sửa thứ tự và tùy ý sử dụng nội tại __buildin_bswap64() khi trả về từ load() . Điều này ảnh hưởng như thế nào đến khả năng tối ưu hóa mã của AggressiveInstCombine?

Đặt hàng, chuyển đổi

Tại sao AggressiveInstCombine không hoạt động trong phiên bản không có khuôn mẫu ban đầu của chúng tôi? Nếu chúng ta kiểm tra trong quy trình tối ưu hóa, chúng tôi sẽ thấy rằng thẻ chạy trước đó sau load() được nội tuyến nhưng, điều quan trọng là trước khi vòng lặp được trải ra, nên chưa có mẫu nào phù hợp. Mẫu chức năng, mặt khác, được khởi tạo riêng biệt cho từng tổ hợp tham số duy nhất, do đó, theo thời điểm chúng được nội tuyến, các vòng lặp đã được giải phóng hoàn toàn (vì số lần lặp là đã biết) và có thể được so khớp bởi foldConsecutiveLoads(). Chúng ta có thể thu được kết quả tương tự với mã ban đầu bằng cách chạy thủ công:

clang++ -S -emit-llvm -O2 -o- endianness.cpp \
    | opt -S -passes='aggressive-instcombine' - \
    | llc --x86-asm-syntax=intel

Điều này cũng minh họa một nguyên tắc quan trọng của nội tuyến hàm trong LLVM (Tôi không chắc chắn về các nguyên tắc khác trình biên dịch): bất cứ khi nào có thể, callees được đơn giản hóa trước người gọi để cải thiện tính chính xác của phương pháp phỏng đoán chi phí nội tuyến. Càng có nhiều thông tin cho trình tối ưu hóa trước nội tuyến thì nó càng có thể đưa ra những quyết định đó tốt hơn. Nói chung, trên thực tế, càng sớm trong quy trình chúng ta có thể kích hoạt các đơn giản hóa cụ thể thì kết quả cuối cùng sẽ càng được tối ưu hóa hơn (xem bài tập để biết thêm ví dụ về điều này).

Bài tập 7: Nếu chúng ta nhận xét tất cả các hàm chuyên dụng nhưng load_le32() thì chúng ta sẽ nhận được tương tự kết quả như khi chúng tôi tạo mẫu load(), tức là AggressiveInstCombine gấp thành công tải liên tiếp. Thẻ tối ưu hóa nào cho phép điều này? Làm thế nào chúng ta có thể tái cơ cấu mã quan trọng về hiệu suất để tận dụng lợi thế của nó?

Bài tập 8: Nếu bây giờ chúng ta chỉ giữ lại load_le64() nhưng thay thế load() bằng load_alt() ở trên thì DAGCombiner có thể tối ưu hóa hoàn toàn LLVM IR thu được. So sánh với kết quả trước đó của chúng tôi để hiểu tại sao lại như vậy.

Tự bắn vào chân mình

Nếu đã đọc đến đây, bạn có thể có ấn tượng rằng, mặc dù chúng có thể không phải là phép thuật, nhưng những người lạc quan ít nhất cũng là siêu nhân. Hãy nhanh chóng xua tan quan niệm đó bằng cách nhìn vào hai ví dụ trong đó điều hiển nhiên cần làm hóa ra lại không quá rõ ràng đối với trình biên dịch.

Chúng ta sẽ bắt đầu bằng cách thêm một hàm nhỏ (và khá vô dụng) vào ví dụ ban đầu:

uint32_t load_le32(const uint8_t* dữ liệu) { return tải (dữ liệu, 4, false); 
uint32_t load_add_le32(const uint8_t* dữ liệu)
{
    trở lại load_le32(dữ liệu) + dữ liệu[0 ;

Hãy dành chút thời gian trước khi tiếp tục xem xét cách bạn mong đợi hàm này được biên dịch. Hai tải và thêm? Có lẽ là tải, phần mở rộng bằng 0 của byte thấp và phần bổ sung?

Bây giờ so sánh với kết quả thực tế:

load_add_le32(chưa ký char const*):
        movzx ecx, byte ptr  [rdi]
        movzx edx, word ptr [rdi + 1]
        shl edx, 8
        movzx eax, byte ptr [rdi + 3 
        shl eax, 24
        hoặc eax, edx
        hoặc eax, ecx
        thêm eax, ecx
        ret

Hừ. Chuyện gì đã xảy ra ở đây?

Hãy nhớ rằng các tải không được gấp lại với nhau cho đến khi chúng ta đạt đến giai đoạn ISel, vì vậy đến lúc đó load_le32() được nội tuyến nên chưa được tối ưu hóa hoàn toàn. Hóa ra DAGCombiner, khi theo dõi nguồn gốc của từng byte trong tải, cụ thể là các quy tắc ra ngoài byte được sử dụng ở nơi khác trong hàm để ngăn chặn việc tải trùng lặp. Đây có thể là nói chung là hợp lý (dù sao thì ngay cả các lần truy cập bộ nhớ đệm cũng phải trả giá), nhưng rõ ràng là nó chưa tối ưu trong trường hợp này trường hợp.

Chúng ta có thể sử dụng AggressiveInstCombine để “ẩn” tải trùng lặp khỏi DAGCombiner không? câu trả lời hóa ra là có, nhưng chúng ta phải cẩn thận. Ví dụ: điều này sẽ không hoạt động:

uint32_t load_add_le32(const uint8_t* dữ liệu)
{
    return tải<4, false>(dữ liệu) + dữ liệu[0 ;

Mặc dù AggressiveInstCombine chạy sau khi load<4, false>() được đưa vào đây, nhưng nó cũng từ chối để tạo các tải trùng lặp 11. Tuy nhiên, như người ta vẫn nói, không có vấn đề gì mà không thể được giải quyết bằng một lớp gián tiếp khác: thay vào đó, nếu chúng ta gọi mẫu hàm từ load_le32(), chúng ta có thể lấy AggressiveInstCombine để tối ưu hóa vòng lặp không được kiểm soát trước nó được nội tuyến trong load_add_le32() , tạo ra kết quả mong muốn ( sự thông minh bổ sung để tránh lượt tải thứ hai nhờ có thẻ GVN).

Bài tập 9: Điều gì sẽ xảy ra nếu chúng ta sử dụng val & 0xFF thay vì data[0] trong các ví dụ trên và tại sao? Bạn có thể thực hiện một thay đổi nhỏ để làm cho tính năng này hoạt động bất kể chúng tôi gọi hàm nào không? 12

Đối với hành động thứ hai và cũng là hành động cuối cùng, chúng ta hãy xem xét lại những gì chúng ta đang tối ưu hóa ngay từ đầu. Chúng tôi đã tập trung vào hiệu suất theo nghĩa thời gian thực thi bằng cách sử dụng cờ -O. Nhưng còn việc tối ưu hóa kích thước bằng -Os thì sao? Trong trường hợp này, chúng trùng hợp: chắc chắn bạn không thể nhận được bất kỳ hoặc nào nhanh hơn bất kỳ tải 32 hoặc 64 bit nào. Nhưng trình biên dịch có đồng ý không?

Thật không may, nó không. Với ngoại lệ tò mò của load_be32() (hay chính xác hơn là load<4, true>()), nó đã quyết định rằng việc hủy kiểm soát các vòng lặp sẽ đi ngược lại nhiệm vụ của nó là giảm thiểu kích thước mã, khiến các đường truyền xuống không thể gấp được tải. Giải pháp ở đây là thúc đẩy nó một cách lịch sự bằng cách sử dụng #pragma unroll trước vòng lặp, cho phép AggressiveInstCombine hoặc DAGCombiner để thực hiện đúng công việc của mình. (You can also avoid pragmas by viết một mẫu hàm đệ quy nếu điều đó phù hợp với sở thích của bạn hơn.)

Kết luận

Chúng ta có thể học được gì từ cuộc khám phá có phần quanh co này? Có lẽ chủ đề bao quát là: làm quen với trình biên dịch của bạn và những gì nó có thể và không thể làm cho bạn. Lời khuyên hữu ích hơn:

  1. Sử dụng phương tiện ngôn ngữ để thể hiện ý định của bạn một cách rõ ràng. Nếu bạn biết điều gì đó vào thời gian biên dịch, tất cả nếu không thì bằng nhau, bạn có thể trợ giúp người tối ưu hóa bằng cách nêu rõ điều đó thông qua ví dụ: mẫu C++ hoặc macro Rust. Trong một số trường hợp, thậm chí có thể thích hợp hơn khi chọn trong thời gian chạy từ một số nhỏ tập hợp các chức năng chuyên biệt, thay vì sử dụng một chức năng chung duy nhất không thể thực hiện được được tối ưu hóa.

  2. Tối đa hóa tiềm năng tối ưu hóa sớm thông qua những việc như tạo khuôn mẫu (lại), phụ trợ các biến, ràng buộc rõ ràng hoặc các hàm chuyên biệt. Có ít nhất một ý tưởng sơ bộ về các bước tối ưu hóa khác nhau và thứ tự của chúng có thể giúp giải quyết vấn đề này. Trình biên dịch càng sớm có thể tối ưu hóa thứ gì đó thì cơ hội đạt được hiệu ứng kích thích có lợi càng lớn.

  3. Bám sát các khuôn mẫu nổi tiếng. Cấu trúc càng phổ biến thì càng có nhiều khả năng ai đó người khác đã làm công việc khó khăn là làm cho trình biên dịch nhận ra và tối ưu hóa nó. Mã đơn giản đánh bại thông minh chín lần trong số mười lần.

Appendix: other resources

Vẫn chưa có đủ sao? Dưới đây là một số tài nguyên hữu ích nếu bạn muốn tìm hiểu sâu hơn về LLVM nói chung và trình tối ưu hóa nói riêng:

  1. Tôi đang sử dụng LLVM thay vì gcc chủ yếu vì gcc không thực hiện tối ưu hóa Tôi phân tích ở đây, nhưng cũng vì LLVM cung cấp công cụ xem xét nội tâm tốt hơn xung quanh nội bộ của nó. đại diện. Cá nhân tôi cũng thấy cơ sở mã LLVM được ghi chép tốt hơn và dễ sử dụng hơn. điều hướng và hiểu. 

  2. Chúng ta cũng có thể sử dụng __buildin_assume() hoặc __buildin_unreachable() (cũng hoạt động với gcc) để có tác dụng tương tự; tất cả những thứ này đều được dịch sang nội tại llvm.assume()

  3. Tôi đếm được tám lần, ở cả -O1-O2. Bạn có thể chuyển -mllvm -print-pipeline-passes đối số cho clang để xem chính xác nó sẽ chạy đường chuyền nào. 

  4. Khi đọc qua mã LLVM, bạn nên nhớ sau đây:

    Một khía cạnh quan trọng của LLVM là không có sự phân biệt giữa biến SSA và biến hoạt động tạo ra nó. Vì điều này, bất kỳ tham chiếu nào đến giá trị được tạo ra bởi một lệnh (hoặc giá trị có sẵn dưới dạng đối số đến chẳng hạn) được biểu diễn dưới dạng một con trỏ trực tiếp tới thể hiện của lớp đại diện cho giá trị này.

  5. Có một số logic bổ sung để làm cho việc tối ưu hóa hoạt động chính xác cho các trường hợp không xác định giá trị; bạn có thể đọc thêm về điều này trong LLVM tài liệu

  6. Thật là thú vị, mặc dù khá lặp đi lặp lại, khi đi xuống hố thỏ của triển khai cho isImpliedCondition() để xem nó phải xử lý bao nhiêu trường hợp khác nhau. 

  7. Vì việc lấy phần dư của phép chia cho 0 là không xác định hành vi, trình biên dịch sẽ hoạt động tốt trong phạm vi của nó quyền cho rằng count != 0 và tiến hành chuyển đổi. Hiện tại, tại ít nhất, LLVM không quá tàn nhẫn. 

  8. Nếu bạn không quen với biểu mẫu SSA, nút phi là một cách để hợp nhất việc thực thi khác nhau đường dẫn được giới thiệu bởi các điều kiện hoặc vòng lặp; đầu ra của lệnh phi là giá trị được gắn nhãn bởi khối cơ bản mà từ đó quá trình thực thi hiện tại đã được tiến hành. Trong trường hợp này giá trị đó là 0 ở đầu vòng lặp và %rem sau lần lặp đầu tiên. 

  9. Tôi khuyên bạn nên xem Hướng dẫn cho người mới bắt đầu Lựa chọnDAG (YouTube) nếu bạn muốn tìm hiểu thêm. 

  10. Các kỹ sư phần cứng từng viết HDL cho FPGA có thể thấy điều này quen thuộc. Chuỗi công cụ như AMD Vivado có những yêu cầu nghiêm ngặt về loại cấu trúc mà họ có thể nhận ra và tổng hợp như mong đợi (một quá trình được gọi là suy luận từ rất lâu trước khi thuật ngữ này được tiếp quản bởi học máy), ví dụ: vào trạng thái máy hoặc khối IP cứng chẳng hạn như RAM

  11. Trước PR #176101 nó đã từ chối gấp tải ngay cả khi chỉ kết quả cuối cùng được sử dụng nhiều lần, nhưng sự thay đổi đó không được bao gồm trong nhánh phát hành LLVM 22.x. 

  12. Gợi ý: iterate_1() chức năng từ nửa đầu của bài viết sử dụng một thủ thuật tương tự. 

Tác giả: hmpc

#discussion