Sử dụng con trỏ này gây ra hiện tượng khử ôxy hóa kỳ lạ trong vòng lặp nóng


122

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 righttiế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 loadtừ bộ nhớ trước mỗi shift ( mov rdx,QWORD PTR [rdi]). Có vẻ như targetcon 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ữ targettrong 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-19ubuntu1với -O3tối ưu hóa. Tôi cũng đã thử clang++ 3.4-1ubuntu3với kết quả tương tự: Clang thậm chí có thể vectơ hóa phương thức bằng targetcon trỏ cục bộ . Tuy nhiên, việc sử dụng this->targetcon 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 thisluô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.


5
Không chắc tại sao điều này lại nhận được một phiếu giảm - đó là một câu hỏi thú vị. FWIW Tôi đã gặp các vấn đề tối ưu hóa tương tự với các biến thành viên không phải là con trỏ trong đó giải pháp tương tự, tức là bộ nhớ cache biến thành viên trong một biến cục bộ trong suốt thời gian tồn tại của phương pháp. Tôi đoán đó là điều gì đó để làm với các quy tắc răng cưa?
Paul R

1
Có vẻ như trình biên dịch không tối ưu hóa vì anh ta không thể đảm bảo rằng thành viên không được truy cập thông qua một số mã "bên ngoài". Vì vậy, nếu thành viên có thể được sửa đổi bên ngoài, thì nó nên được tải lại mỗi lần được truy cập. Dường như được coi như một loại dễ bay hơi ...
Jean-Baptiste Yunès

Không sử dụng 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.
Jean-Baptiste Yunès

Bất cứ điều gì để làm với bí danh con trỏ?
Yves Daoust

3
Như một vấn đề ngữ nghĩa hơn, "tối ưu hóa quá sớm" chỉ áp dụng cho việc tối ưu hóa quá sớm, tức là, trước khi lập hồ sơ đã phát hiện ra nó là một vấn đề. Trong trường hợp này, bạn cần mẫn lập hồ sơ và dịch ngược và tìm ra nguồn gốc của vấn đề, đồng thời xây dựng và lập hồ sơ một giải pháp. Việc áp dụng giải pháp đó hoàn toàn không phải là “chết yểu”.
raptortech97

Câu trả lời:


107

Răng cưa con trỏ dường như là vấn đề, trớ trêu thay giữa thisthis->target. Trình biên dịch đang tính đến khả năng khá khó hiểu mà bạn đã khởi tạo:

this->target = &this

Trong trường hợp đó, việc viết thư tới this->target[0]sẽ thay đổi nội dung của this(và do đó, this->target).

Vấn đề răng cưa bộ nhớ không bị hạn chế ở trên. Về nguyên tắc, bất kỳ việc sử dụng this->target[XX]giá trị (trong) thích hợp nào của đều XXcó thể trỏ đến this.

Tôi thông thạo hơn về C, nơi điều này có thể được khắc phục bằng cách khai báo các biến con trỏ với __restrict__từ khóa.


18
Tôi có thể xác nhận điều này! Thay đổi targettừ uint8_tthành uint16_t(để các quy tắc răng cưa nghiêm ngặt bắt đầu) đã thay đổi nó. Với uint16_t, tải luôn được tối ưu hóa.
gexicide


3
Thay đổi nội dung của thiskhông phải ý bạn (nó không phải là một biến); ý bạn là thay đổi nội dung của *this.
Marc van Leeuwen

@gexicide tâm trí giải thích chi tiết bí danh nghiêm ngặt bắt đầu và khắc phục sự cố như thế nào?
HCSF

33

Quy tắc char*bí danh nghiêm ngặt cho phép đặt bí danh cho bất kỳ con trỏ nào khác. Vì vậy, this->targetcó thể bí danh với thisvà trong phương thức mã của bạn, phần đầu tiên của mã,

target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;

là trong thực tế

this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;

như thiscó thể được sửa đổi khi bạn sửa đổi this->targetnội dung.

Sau khi this->targetđược lưu trong bộ nhớ cache vào một biến cục bộ, bí danh đó không còn khả dụng với biến cục bộ.


1
Vì vậy, chúng ta có thể nói như một quy tắc chung: Bất cứ khi nào bạn có một char*hoặc void*trong cấu trúc của mình, hãy nhớ lưu nó vào bộ nhớ cache trong một biến cục bộ trước khi ghi vào nó?
gexicide

5
Trong thực tế, đó là khi bạn sử dụng một char*, không cần thiết như một thành viên.
Jarod42

24

Vấn đề ở đây là bí danh nghiêm ngặt nói rằng chúng tôi được phép đặt bí danh thông qua ký tự * và do đó ngăn cản tối ưu hóa trình biên dịch trong trường hợp của bạn. Chúng tôi không được phép đặt bí danh thông qua một con trỏ thuộc loại khác, đây sẽ là hành vi không xác định, thông thường trên SO, chúng tôi thấy vấn đề này là người dùng cố đặt bí danh thông qua các loại con trỏ không tương thích .

Có vẻ hợp lý nếu triển khai uint8_t dưới dạng char không dấu và nếu chúng ta nhìn vào cstdint trên Coliru, nó bao gồm stdint.h mà typedefs uint8_t như sau:

typedef unsigned char       uint8_t;

nếu bạn đã sử dụng một kiểu không phải ký tự khác thì trình biên dịch sẽ có thể tối ưu hóa.

Điều này được đề cập trong phần dự thảo tiêu chuẩn C ++ 3.10 Giá trị và giá trị cho biết:

Nếu một chương trình cố gắng truy cập giá trị được lưu trữ của một đối tượng thông qua một glvalue khác với một trong các kiểu sau, hành vi đó là không xác định

và bao gồm dấu đầu dòng sau:

  • kiểu char hoặc không dấu.

Lưu ý, tôi đã đăng một nhận xét về các cách giải quyết có thể xảy ra trong một câu hỏi hỏi Khi nào uint8_t ≠ unsigned char? và khuyến nghị là:

Tuy nhiên, giải pháp đơn giản là sử dụng từ khóa hạn chế hoặc sao chép con trỏ vào một biến cục bộ có địa chỉ không bao giờ được lấy để trình biên dịch không cần phải lo lắng về việc liệu các đối tượng uint8_t có thể đặt bí danh cho nó hay không.

Vì C ++ không hỗ trợ từ khóa hạn chế, bạn phải dựa vào phần mở rộng của trình biên dịch, ví dụ: gcc sử dụng __restrict__ vì vậy điều này không hoàn toàn di động nhưng đề xuất khác nên có.


Đây là một ví dụ về nơi mà Tiêu chuẩn tồi tệ hơn đối với trình tối ưu hóa hơn là một quy tắc sẽ cho phép trình biên dịch giả định rằng giữa hai quyền truy cập vào một đối tượng kiểu T, hoặc một quyền truy cập như vậy và điểm bắt đầu hoặc kết thúc của một vòng lặp / hàm trong đó nó xảy ra, tất cả các truy cập vào bộ nhớ sẽ sử dụng cùng một đối tượng trừ khi một thao tác can thiệp sử dụng đối tượng đó (hoặc một con trỏ / tham chiếu đến nó) để dẫn xuất một con trỏ hoặc tham chiếu đến một số đối tượng khác . Quy tắc như vậy sẽ loại bỏ nhu cầu về "ngoại lệ kiểu ký tự" có thể giết chết hiệu suất của mã hoạt động với chuỗi byte.
supercat
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.