Hai nghiên cứu về tối ưu hóa trình biên dịch
Two studies in compiler optimisations
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:
hình>
(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 cur và count để 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:
- 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.
- 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[là ? 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:
-
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.
-
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.
-
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:
- Tài liệu LLVM chính thức, cụ thể là:
- Cụ thể, blog của Nikita Popov:
- Chuyến tham quan nhanh chóng về trình tối ưu hóa LLVM (YouTube)
-
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. ↩
-
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 sangnội tại llvm.assume(). ↩ -
Tôi đếm được tám lần, ở cả
-O1và-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. ↩ -
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.
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. ↩
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. ↩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 != 0và tiến hành chuyển đổi. Hiện tại, tại ít nhất, LLVM không quá tàn nhẫn. ↩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à
%remsau lần lặp đầu tiên. ↩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. ↩
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. ↩
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. ↩
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