Gần đây, tôi đã gặp phải một sự hủy tối ưu hóa kỳ lạ (hay đúng hơn là bỏ lỡ cơ hội tối ưu hóa).
Hãy xem xét hàm này để giải nén hiệu quả mảng số nguyên 3 bit sang số nguyên 8 bit. Nó giải nén 16 int trong mỗi lần lặp vòng lặp:
void unpack3bit(uint8_t* target, char* source, int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
target+=16;
}
}
Đây là lắp ráp được tạo cho các phần của mã:
...
367: 48 89 c1 mov rcx,rax
36a: 48 c1 e9 09 shr rcx,0x9
36e: 83 e1 07 and ecx,0x7
371: 48 89 4f 18 mov QWORD PTR [rdi+0x18],rcx
375: 48 89 c1 mov rcx,rax
378: 48 c1 e9 0c shr rcx,0xc
37c: 83 e1 07 and ecx,0x7
37f: 48 89 4f 20 mov QWORD PTR [rdi+0x20],rcx
383: 48 89 c1 mov rcx,rax
386: 48 c1 e9 0f shr rcx,0xf
38a: 83 e1 07 and ecx,0x7
38d: 48 89 4f 28 mov QWORD PTR [rdi+0x28],rcx
391: 48 89 c1 mov rcx,rax
394: 48 c1 e9 12 shr rcx,0x12
398: 83 e1 07 and ecx,0x7
39b: 48 89 4f 30 mov QWORD PTR [rdi+0x30],rcx
...
Nó trông khá hiệu quả. Đơn giản chỉ cần một shift right
tiếp theo là một and
, và sau đó một store
đến target
đệm. Nhưng bây giờ, hãy xem điều gì sẽ xảy ra khi tôi thay đổi hàm thành một phương thức trong cấu trúc:
struct T{
uint8_t* target;
char* source;
void unpack3bit( int size);
};
void T::unpack3bit(int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
target+=16;
}
}
Tôi nghĩ rằng lắp ráp được tạo ra sẽ khá giống nhau, nhưng không phải vậy. Đây là một phần của nó:
...
2b3: 48 c1 e9 15 shr rcx,0x15
2b7: 83 e1 07 and ecx,0x7
2ba: 88 4a 07 mov BYTE PTR [rdx+0x7],cl
2bd: 48 89 c1 mov rcx,rax
2c0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
2c3: 48 c1 e9 18 shr rcx,0x18
2c7: 83 e1 07 and ecx,0x7
2ca: 88 4a 08 mov BYTE PTR [rdx+0x8],cl
2cd: 48 89 c1 mov rcx,rax
2d0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
2d3: 48 c1 e9 1b shr rcx,0x1b
2d7: 83 e1 07 and ecx,0x7
2da: 88 4a 09 mov BYTE PTR [rdx+0x9],cl
2dd: 48 89 c1 mov rcx,rax
2e0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
2e3: 48 c1 e9 1e shr rcx,0x1e
2e7: 83 e1 07 and ecx,0x7
2ea: 88 4a 0a mov BYTE PTR [rdx+0xa],cl
2ed: 48 89 c1 mov rcx,rax
2f0: 48 8b 17 mov rdx,QWORD PTR [rdi] // Load, BAD!
...
Như bạn thấy, chúng tôi đã giới thiệu một phần bổ sung dự phòng load
từ bộ nhớ trước mỗi shift ( mov rdx,QWORD PTR [rdi]
). Có vẻ như target
con trỏ (hiện là một thành viên thay vì một biến cục bộ) phải luôn được tải lại trước khi lưu trữ vào nó. Điều này làm chậm mã đáng kể (khoảng 15% trong các phép đo của tôi).
Đầu tiên, tôi nghĩ có thể mô hình bộ nhớ C ++ thực thi rằng một con trỏ thành viên có thể không được lưu trữ trong một thanh ghi mà phải được tải lại, nhưng điều này có vẻ là một lựa chọn khó xử, vì nó sẽ làm cho rất nhiều tối ưu hóa khả thi không thể thực hiện được. Vì vậy, tôi rất ngạc nhiên rằng trình biên dịch không lưu trữ target
trong một sổ đăng ký ở đây.
Tôi đã thử tự mình lưu con trỏ thành viên vào một biến cục bộ:
void T::unpack3bit(int size) {
while(size > 0){
uint64_t t = *reinterpret_cast<uint64_t*>(source);
uint8_t* target = this->target; // << ptr cached in local variable
target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;
target[3] = (t >> 9) & 0x7;
target[4] = (t >> 12) & 0x7;
target[5] = (t >> 15) & 0x7;
target[6] = (t >> 18) & 0x7;
target[7] = (t >> 21) & 0x7;
target[8] = (t >> 24) & 0x7;
target[9] = (t >> 27) & 0x7;
target[10] = (t >> 30) & 0x7;
target[11] = (t >> 33) & 0x7;
target[12] = (t >> 36) & 0x7;
target[13] = (t >> 39) & 0x7;
target[14] = (t >> 42) & 0x7;
target[15] = (t >> 45) & 0x7;
source+=6;
size-=6;
this->target+=16;
}
}
Mã này cũng mang lại trình lắp ráp "tốt" mà không cần thêm cửa hàng. Vì vậy, suy đoán của tôi là: Trình biên dịch không được phép tải một con trỏ thành viên của một cấu trúc, vì vậy một "con trỏ nóng" như vậy phải luôn được lưu trữ trong một biến cục bộ.
- Vì vậy, tại sao trình biên dịch không thể tối ưu hóa các tải này?
- Có phải mô hình bộ nhớ C ++ cấm điều này không? Hay nó chỉ đơn giản là một thiếu sót của trình biên dịch của tôi?
- Suy đoán của tôi có chính xác không hay lý do chính xác khiến việc tối ưu hóa không thể được thực hiện là gì?
Trình biên dịch được sử dụng là g++ 4.8.2-19ubuntu1
với -O3
tối ưu hóa. Tôi cũng đã thử clang++ 3.4-1ubuntu3
với kết quả tương tự: Clang thậm chí có thể vectơ hóa phương thức bằng target
con trỏ cục bộ . Tuy nhiên, việc sử dụng this->target
con trỏ sẽ mang lại kết quả tương tự: Con trỏ tăng thêm trước mỗi cửa hàng.
Tôi đã kiểm tra trình hợp dịch của một số phương pháp tương tự và kết quả là giống nhau: Có vẻ như một thành viên của this
luôn phải được tải lại trước một cửa hàng, ngay cả khi tải như vậy có thể đơn giản được đưa ra bên ngoài vòng lặp. Tôi sẽ phải viết lại rất nhiều mã để loại bỏ các cửa hàng bổ sung này, chủ yếu bằng cách tự lưu con trỏ vào bộ nhớ đệm vào một biến cục bộ được khai báo phía trên mã nóng. Nhưng tôi luôn nghĩ rằng việc loay hoay với những chi tiết như vào bộ nhớ đệm một con trỏ trong một biến cục bộ chắc chắn sẽ đủ điều kiện để tối ưu hóa sớm trong những ngày này, nơi mà các trình biên dịch đã quá thông minh. Nhưng có vẻ như tôi đã nhầm ở đây . Lưu vào bộ đệm một con trỏ thành viên trong một vòng lặp nóng dường như là một kỹ thuật tối ưu hóa thủ công cần thiết.
this->
chỉ là đường cú pháp. Vấn đề liên quan đến bản chất của các biến (cục bộ vs thành viên) và những thứ mà trình biên dịch suy ra từ thực tế này.