การใช้ตัวชี้นี้ทำให้เกิดการลดประสิทธิภาพแบบแปลก ๆ ใน hot loop


122

ฉันเพิ่งเจอการลดประสิทธิภาพแบบแปลก ๆ (หรือค่อนข้างพลาดโอกาสในการเพิ่มประสิทธิภาพ)

พิจารณาฟังก์ชันนี้เพื่อการคลายอาร์เรย์ของจำนวนเต็ม 3 บิตเป็นจำนวนเต็ม 8 บิตอย่างมีประสิทธิภาพ มันคลาย 16 ints ในการวนซ้ำแต่ละครั้ง:

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;
   }
}

นี่คือชุดประกอบที่สร้างขึ้นสำหรับส่วนต่างๆของรหัส:

 ...
 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
 ...

มันดูมีประสิทธิภาพมากทีเดียว เพียงshift rightตามด้วยandและจากนั้นstoreไปยังtargetบัฟเฟอร์ แต่ตอนนี้ดูว่าจะเกิดอะไรขึ้นเมื่อฉันเปลี่ยนฟังก์ชันเป็นวิธีการในโครงสร้าง:

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;
        }
}

ฉันคิดว่าแอสเซมบลีที่สร้างขึ้นควรจะเหมือนกัน แต่มันไม่ใช่ นี่คือส่วนหนึ่งของมัน:

...
 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!
 ...

อย่างที่คุณเห็นเราได้แนะนำการสำรองเพิ่มเติมloadจากหน่วยความจำก่อนการเปลี่ยนแต่ละครั้ง ( mov rdx,QWORD PTR [rdi]) ดูเหมือนว่าtargetตัวชี้ (ซึ่งตอนนี้เป็นสมาชิกแทนที่จะเป็นตัวแปรในเครื่อง) จะต้องโหลดใหม่เสมอก่อนที่จะจัดเก็บลงในนั้น สิ่งนี้ทำให้โค้ดช้าลงอย่างมาก (ประมาณ 15% ในการวัดของฉัน)

ก่อนอื่นฉันคิดว่าโมเดลหน่วยความจำ C ++ อาจบังคับใช้ว่าตัวชี้สมาชิกอาจไม่ถูกเก็บไว้ในรีจิสเตอร์ แต่ต้องโหลดใหม่ แต่ดูเหมือนว่าจะเป็นทางเลือกที่น่าอึดอัดใจเนื่องจากจะทำให้การปรับให้เหมาะสมเป็นไปไม่ได้มากนัก ดังนั้นฉันจึงแปลกใจมากที่คอมไพเลอร์ไม่ได้จัดเก็บtargetในรีจิสเตอร์ที่นี่

ฉันพยายามแคชตัวชี้สมาชิกในตัวแปรท้องถิ่น:

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;
    }
}

รหัสนี้ยังให้แอสเซมเบลอร์ที่ "ดี" โดยไม่มีร้านค้าเพิ่มเติม ดังนั้นการคาดเดาของฉันคือ: คอมไพเลอร์ไม่ได้รับอนุญาตให้ยกโหลดของตัวชี้สมาชิกของโครงสร้างดังนั้น "ตัวชี้ร้อน" ดังกล่าวควรถูกเก็บไว้ในตัวแปรโลคัลเสมอ

  • เหตุใดคอมไพเลอร์จึงไม่สามารถปรับโหลดเหล่านี้ให้เหมาะสมได้?
  • มันเป็นแบบจำลองหน่วยความจำ C ++ ที่ห้ามสิ่งนี้หรือไม่? หรือเป็นเพียงข้อบกพร่องของคอมไพเลอร์ของฉัน?
  • การคาดเดาของฉันถูกต้องหรืออะไรคือสาเหตุที่แท้จริงที่ทำให้การเพิ่มประสิทธิภาพไม่สามารถทำได้

คอมไพเลอร์ที่ใช้อยู่g++ 4.8.2-19ubuntu1มี-O3การเพิ่มประสิทธิภาพ ฉันลองแล้วได้clang++ 3.4-1ubuntu3ผลลัพธ์ที่คล้ายกัน: เสียงดังยังสามารถกำหนดวิธีการเป็นเวกเตอร์ด้วยtargetตัวชี้ท้องถิ่นได้ อย่างไรก็ตามการใช้พอยน์เตอร์this->targetจะให้ผลลัพธ์เดียวกัน: การโหลดตัวชี้เพิ่มเติมก่อนแต่ละร้านค้า

ฉันตรวจสอบแอสเซมเบลอร์ของวิธีการบางอย่างที่คล้ายกันและผลลัพธ์ก็เหมือนกัน: ดูเหมือนว่าสมาชิกของthisจะต้องโหลดซ้ำก่อนร้านค้าเสมอแม้ว่าโหลดดังกล่าวจะสามารถยกออกนอกลูปได้ก็ตาม ฉันจะต้องเขียนโค้ดใหม่จำนวนมากเพื่อกำจัดร้านค้าเพิ่มเติมเหล่านี้ส่วนใหญ่โดยการแคชตัวชี้ตัวเองลงในตัวแปรท้องถิ่นที่ประกาศไว้เหนือรหัสร้อน แต่ฉันคิดเสมอว่าการเล่นซอกับรายละเอียดเช่นการแคชพอยน์เตอร์ในตัวแปรโลคัลจะมีสิทธิ์ได้รับการปรับให้เหมาะสมก่อนเวลาอันควรในสมัยนี้ที่คอมไพเลอร์ฉลาดมาก แต่ดูเหมือนว่าฉันผิดที่นี่ การแคชตัวชี้สมาชิกใน hot loop ดูเหมือนจะเป็นเทคนิคการเพิ่มประสิทธิภาพด้วยตนเองที่จำเป็น


5
ไม่แน่ใจว่าเหตุใดจึงมีการโหวตลดลง - เป็นคำถามที่น่าสนใจ FWIW ฉันเคยเห็นปัญหาการเพิ่มประสิทธิภาพที่คล้ายกันกับตัวแปรสมาชิกที่ไม่ใช่ตัวชี้ซึ่งการแก้ปัญหาคล้ายกันนั่นคือแคชตัวแปรสมาชิกในตัวแปรภายในตลอดอายุการใช้งานของวิธีการ ฉันเดาว่ามันเกี่ยวข้องกับกฎนามแฝงหรือไม่?
Paul R

1
ดูเหมือนว่าคอมไพลเลอร์จะไม่ปรับให้เหมาะสมเนื่องจากเขาไม่สามารถมั่นใจได้ว่าสมาชิกจะไม่ได้รับการเข้าถึงผ่านโค้ด "ภายนอก" บางอย่าง ดังนั้นหากสามารถแก้ไขสมาชิกภายนอกได้ก็ควรโหลดใหม่ทุกครั้งที่เข้าถึง ดูเหมือนจะถูกมองว่าเป็นสิ่งที่ผันผวน ...
Jean-Baptiste Yunès

ไม่ใช้this->เป็นเพียงแค่น้ำตาลซินแทติก ปัญหาเกี่ยวข้องกับลักษณะของตัวแปร (local vs member) และสิ่งที่คอมไพเลอร์อนุมานจากข้อเท็จจริงนี้
Jean-Baptiste Yunès

จะทำอะไรกับนามแฝงตัวชี้?
Yves Daoust

3
ในฐานะที่เป็นเรื่องเชิงความหมายมากขึ้น "การเพิ่มประสิทธิภาพก่อนกำหนด" จะใช้กับการเพิ่มประสิทธิภาพที่เกิดก่อนกำหนดเท่านั้นกล่าวคือก่อนที่การทำโปรไฟล์จะพบว่าเป็นปัญหา ในกรณีนี้คุณได้ทำโปรไฟล์และเรียบเรียงข้อมูลอย่างรอบคอบและพบที่มาของปัญหาและกำหนดและจัดทำโปรไฟล์วิธีแก้ปัญหา การใช้น้ำยานั้นไม่ "แก่ก่อนวัย" อย่างแน่นอน
raptortech97

คำตอบ:


107

ชี้ aliasing น่าจะเป็นปัญหาที่เกิดขึ้นระหว่างแดกดันและthis this->targetคอมไพเลอร์คำนึงถึงความเป็นไปได้ที่ค่อนข้างหยาบโลนที่คุณเริ่มต้น:

this->target = &this

ในกรณีนั้นการเขียนถึงthis->target[0]จะเปลี่ยนแปลงเนื้อหาของthis(และด้วยเหตุนี้this->target)

ปัญหาการตั้งนามแฝงของหน่วยความจำไม่ได้ จำกัด เฉพาะข้างต้น ในหลักการการใช้งานใด ๆthis->target[XX]ที่กำหนด (ใน) มูลค่าที่เหมาะสมของจุดอาจจะXXthis

ฉันมีความเชี่ยวชาญในภาษา C ดีกว่าซึ่งสามารถแก้ไขได้โดยการประกาศตัวแปรตัวชี้ด้วย__restrict__คำหลัก


18
ยืนยันได้เลย! การเปลี่ยนtargetจากuint8_tเป็นuint16_t(เพื่อให้กฎการใช้นามแฝงที่เข้มงวดเริ่มต้นขึ้น) เปลี่ยนมัน ด้วยuint16_tการโหลดจะถูกปรับให้เหมาะสมเสมอ
gexicide

1
เกี่ยวข้อง: stackoverflow.com/questions/16138237/…
user541686

3
การเปลี่ยนเนื้อหาthisไม่ใช่สิ่งที่คุณหมายถึง (ไม่ใช่ตัวแปร) คุณหมายถึงการเปลี่ยนเนื้อหาของ*this.
Marc van Leeuwen

@gexicide ใจอธิบายว่านามแฝงที่เข้มงวดเข้ามาและแก้ไขปัญหาได้อย่างไร?
HCSF

33

กฎการใช้นามแฝงที่เข้มงวดอนุญาตให้char*ใช้แทนตัวชี้อื่น ๆ ดังนั้นthis->targetอาจใช้นามแฝงด้วยthisและในวิธีการรหัสของคุณส่วนแรกของรหัส

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

เป็นความจริง

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

ตามที่thisอาจแก้ไขได้เมื่อคุณแก้ไขthis->targetเนื้อหา

เมื่อthis->targetถูกแคชลงในตัวแปรภายในแล้วนามแฝงจะใช้กับตัวแปรโลคัลไม่ได้อีกต่อไป


1
ดังนั้นเราสามารถพูดได้ตามกฎทั่วไป: เมื่อใดก็ตามที่คุณมีchar*หรือvoid*อยู่ในโครงสร้างของคุณอย่าลืมแคชไว้ในตัวแปรท้องถิ่นก่อนที่จะเขียนถึงมัน?
gexicide

5
ในความเป็นจริงเมื่อคุณใช้ a char*ไม่จำเป็นในฐานะสมาชิก
จรด 42

24

ปัญหาที่นี่คือนามแฝงที่เข้มงวดซึ่งระบุว่าเราได้รับอนุญาตให้ใช้นามแฝงผ่านchar *และเพื่อป้องกันการเพิ่มประสิทธิภาพคอมไพเลอร์ในกรณีของคุณ เราไม่ได้รับอนุญาตให้ใช้นามแฝงผ่านตัวชี้ประเภทอื่นซึ่งจะเป็นพฤติกรรมที่ไม่ได้กำหนดโดยปกติใน SO เราจะพบปัญหานี้ซึ่งเป็นผู้ใช้ที่พยายามใช้นามแฝงผ่านประเภทตัวชี้ที่เข้ากันไม่ได้

ดูเหมือนจะสมเหตุสมผลที่จะใช้uint8_tเป็นถ่านที่ไม่ได้ลงชื่อและถ้าเราดูที่cstdint บน Coliruมันจะรวมstdint.hซึ่งพิมพ์uint8_tดังนี้:

typedef unsigned char       uint8_t;

หากคุณใช้ประเภทอื่นที่ไม่ใช่ถ่านคอมไพเลอร์ควรจะปรับให้เหมาะสมได้

สิ่งนี้ครอบคลุมอยู่ในร่างมาตรฐาน C ++ 3.10 Lvalues ​​และ rvaluesซึ่งระบุว่า:

หากโปรแกรมพยายามเข้าถึงค่าที่เก็บไว้ของอ็อบเจ็กต์ผ่านค่า glvalue ของประเภทอื่นที่ไม่ใช่ประเภทใดประเภทหนึ่งต่อไปนี้จะไม่มีการกำหนดลักษณะการทำงาน

และรวมหัวข้อย่อยต่อไปนี้:

  • ประเภทถ่านหรือถ่านที่ไม่ได้ลงชื่อ

หมายเหตุฉันโพสต์ความคิดเห็นเกี่ยวกับการแก้ไขปัญหาที่เป็นไปได้ในคำถามที่ถามว่าเมื่อไหร่ uint8_t ≠ถ่านที่ไม่ได้ลงนาม? และคำแนะนำคือ:

อย่างไรก็ตามวิธีแก้ปัญหาเล็กน้อยคือการใช้คีย์เวิร์ด จำกัด หรือคัดลอกตัวชี้ไปยังตัวแปรโลคัลที่ไม่เคยใช้แอดเดรสเพื่อให้คอมไพลเลอร์ไม่จำเป็นต้องกังวลว่าอ็อบเจ็กต์ uint8_t สามารถแทนได้หรือไม่

เนื่องจาก C ++ ไม่รองรับคีย์เวิร์ดที่จำกัดคุณจึงต้องพึ่งพาส่วนขยายของคอมไพเลอร์ตัวอย่างเช่นgcc ใช้ __restrict__ดังนั้นจึงไม่สามารถพกพาได้ทั้งหมด แต่ควรเป็นคำแนะนำอื่น ๆ


นี่คือตัวอย่างของสถานที่ที่ Standard แย่กว่าสำหรับเครื่องมือเพิ่มประสิทธิภาพมากกว่าที่จะเป็นกฎที่อนุญาตให้คอมไพเลอร์สมมติว่าระหว่างสองการเข้าถึงอ็อบเจ็กต์ประเภท T หรือการเข้าถึงดังกล่าวและจุดเริ่มต้นหรือจุดสิ้นสุดของลูป / ฟังก์ชัน นั้นมันเกิดขึ้นการเข้าถึงทั้งหมดเพื่อจัดเก็บข้อมูลที่จะใช้วัตถุเดียวกันเว้นแต่จะมีการแทรกแซงการใช้ดำเนินการที่วัตถุ (หรือตัวชี้ / อ้างอิงไป) จะได้รับตัวชี้หรือการอ้างอิงไปยังวัตถุอื่น กฎดังกล่าวจะขจัดความจำเป็นใน "ข้อยกเว้นประเภทอักขระ" ซึ่งสามารถฆ่าประสิทธิภาพของโค้ดที่ทำงานกับลำดับไบต์ได้
supercat
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.