กระโดดแพงด้วย GCC 5.4.0


171

ฉันมีฟังก์ชั่นที่มีลักษณะเช่นนี้ (แสดงเฉพาะส่วนที่สำคัญ):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) && (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

เขียนแบบนี้ฟังก์ชั่นนี้ใช้เวลาประมาณ 34ms บนเครื่องของฉัน หลังจากเปลี่ยนเงื่อนไขเป็นการคูณแบบบูล (ทำให้โค้ดมีลักษณะดังนี้):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) * (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

เวลาดำเนินการลดลงเหลือ ~ 19ms

คอมไพเลอร์ที่ใช้คือ GCC 5.4.0 ด้วย -O3 และหลังจากตรวจสอบรหัส asm ที่สร้างขึ้นโดยใช้ godbolt.org ฉันพบว่าตัวอย่างแรกสร้างการกระโดดในขณะที่สองไม่ได้ ฉันตัดสินใจลอง GCC 6.2.0 ซึ่งสร้างคำสั่งการกระโดดเมื่อใช้ตัวอย่างแรก แต่ GCC 7 ดูเหมือนจะไม่สร้างอีกต่อไป

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

แก้ไข: ลิงก์ไปยัง godbolt https://godbolt.org/g/5lKPF3


17
ทำไมคอมไพเลอร์ทำงานแบบนี้ทำไม คอมไพเลอร์สามารถทำตามที่เขาต้องการตราบใดที่รหัสที่สร้างนั้นถูกต้อง คอมไพเลอร์บางตัวดีกว่าการเพิ่มประสิทธิภาพมากกว่าคนอื่น ๆ
Jabberwocky

26
ฉันเดาว่าการประเมินการลัดวงจรของ&&สาเหตุนี้
Jens

9
&โปรดทราบว่านี่คือเหตุผลที่เรายังมี
rubenvb

7
@Jakub การเรียงลำดับมันน่าจะเพิ่มความเร็วในการประมวลผลมากที่สุดดูคำถามนี้
rubenvb

8
@rubenvb "ต้องไม่ถูกประเมิน" ไม่ได้หมายถึงอะไรจริงๆ สำหรับนิพจน์ที่ไม่มีผลข้างเคียง ฉันสงสัยว่าเวกเตอร์ทำการตรวจสอบขอบเขตและ GCC ไม่สามารถพิสูจน์ได้ว่าจะไม่ออกนอกขอบเขต แก้ไข: จริง ๆ แล้วฉันไม่คิดว่าคุณกำลังทำอะไรเพื่อหยุดฉัน + เปลี่ยนจากการออกนอกขอบเขต
Random832

คำตอบ:


263

ตรรกะและตัวดำเนินการ ( &&) ใช้การประเมินการลัดวงจรซึ่งหมายความว่าการทดสอบครั้งที่สองจะกระทำก็ต่อเมื่อการเปรียบเทียบครั้งแรกประเมินเป็นจริง นี่เป็นความหมายที่คุณต้องการ ตัวอย่างเช่นพิจารณารหัสต่อไปนี้:

if ((p != nullptr) && (p->first > 0))

คุณต้องตรวจสอบให้แน่ใจว่าตัวชี้นั้นไม่เป็นโมฆะก่อนที่จะทำการตรวจสอบอีกครั้ง ถ้านี่ไม่ใช่การประเมินผลการลัดวงจรคุณจะมีพฤติกรรมที่ไม่ได้กำหนดเนื่องจากคุณกำลังทำการยกเลิกตัวชี้ null

นอกจากนี้ยังเป็นไปได้ว่าการประเมินการลัดวงจรทำให้ได้รับประสิทธิภาพในกรณีที่การประเมินเงื่อนไขเป็นกระบวนการที่มีราคาแพง ตัวอย่างเช่น:

if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))

หากDoLengthyCheck1ล้มเหลวจะไม่มีการโทรออกDoLengthyCheck2ออก

อย่างไรก็ตามในไบนารี่ที่ได้ผลการดำเนินการลัดวงจรมักจะส่งผลให้เกิดสองสาขาเนื่องจากวิธีนี้เป็นวิธีที่ง่ายที่สุดสำหรับคอมไพเลอร์ในการรักษาซีแมนทิกส์เหล่านี้ (ซึ่งเป็นเหตุผลว่าทำไมในอีกด้านหนึ่งของเหรียญการประเมินการลัดวงจรบางครั้งสามารถทำได้ยับยั้งศักยภาพการเพิ่มประสิทธิภาพ) คุณสามารถดูสิ่งนี้ได้โดยดูที่ส่วนที่เกี่ยวข้องของรหัสวัตถุที่สร้างขึ้นสำหรับifคำสั่งของคุณโดย GCC 5.4:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L5

    cmp     ax, 478           ; (l[i + shift] < 479)
    ja      .L5

    add     r8d, 1            ; nontopOverlap++

คุณจะเห็นการเปรียบเทียบสองรายการ ( cmpคำแนะนำ) ที่นี่ที่นี่แต่ละรายการตามด้วยการกระโดด / สาขาแบบมีเงื่อนไขแยกต่างหาก (jaหรือการกระโดดถ้าด้านบน)

เป็นกฎทั่วไปของหัวแม่มือที่กิ่งช้าและดังนั้นจึงควรหลีกเลี่ยงในลูปแน่น สิ่งนี้เป็นจริงกับตัวประมวลผล x86 เกือบทั้งหมดจากผู้ต่ำต้อย 8088 (ซึ่งมีเวลาการดึงข้อมูลช้าและคิว prefetch ขนาดเล็กมาก [เปรียบได้กับแคชคำสั่ง] รวมกับการขาดการคาดเดาสาขาซึ่งหมายความว่าสาขาต้องใช้แคชเพื่อทิ้ง ) กับการใช้งานที่ทันสมัย ​​(ซึ่งท่อยาวทำให้สาขาที่มีการตัดสินผิดมีราคาแพงในทำนองเดียวกัน) สังเกตคำเตือนเล็กน้อยที่ฉันแอบเข้าไป โปรเซสเซอร์ที่ทันสมัยตั้งแต่ Pentium Pro มีเครื่องมือการทำนายสาขาขั้นสูงที่ออกแบบมาเพื่อลดต้นทุนของสาขา หากสามารถทำนายทิศทางของสาขาได้อย่างเหมาะสมค่าใช้จ่ายจะน้อยที่สุด ส่วนใหญ่แล้วมันใช้งานได้ดี แต่ถ้าคุณเข้าสู่กรณีทางพยาธิวิทยาที่ตัวพยากรณ์สาขาไม่ได้อยู่ข้างคุณรหัสของคุณอาจช้ามาก นี่น่าจะเป็นที่ที่คุณอยู่ที่นี่เนื่องจากคุณบอกว่าอาเรย์ของคุณไม่ได้เรียงลำดับ

คุณบอกว่ามาตรฐานยืนยันว่าแทนที่&&ด้วย*จะทำให้โค้ดเร็วขึ้นอย่างเห็นได้ชัด เหตุผลนี้เห็นได้ชัดเมื่อเราเปรียบเทียบส่วนที่เกี่ยวข้องของรหัสวัตถุ:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    xor     r15d, r15d        ; (curr[i] < 479)
    cmp     r13w, 478
    setbe   r15b

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     ax, 478
    setbe   r14b

    imul    r14d, r15d        ; meld results of the two comparisons

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

มันค่อนข้างตอบโต้ได้ง่ายซึ่งอาจเร็วกว่าเนื่องจากมีคำแนะนำเพิ่มเติมที่นี่ แต่นั่นเป็นวิธีที่การเพิ่มประสิทธิภาพทำงานได้บางครั้ง คุณจะเห็นการเปรียบเทียบเดียวกัน ( cmp) ถูกทำนี่ แต่ตอนนี้แต่ละคนจะนำหน้าด้วยและตามมาด้วยxor setbeXOR เป็นเพียงกลลวงมาตรฐานสำหรับการล้างทะเบียน setbeเป็นคำสั่ง x86 ที่กำหนดเล็กน้อยขึ้นอยู่กับมูลค่าของธงและมักจะถูกนำมาใช้ในการดำเนินการรหัสสาขา นี่setbeเป็นสิ่งที่ตรงกันข้ามของjaเป็นสิ่งที่ตรงกันข้ามของมันตั้งค่าการลงทะเบียนปลายทางเป็น 1 หากการเปรียบเทียบต่ำกว่าหรือเท่ากับ (เนื่องจากการลงทะเบียนเป็นศูนย์ล่วงหน้ามันจะเป็น 0 อย่างอื่น) ในขณะที่jaแยกหากการเปรียบเทียบข้างต้น เมื่อได้รับค่าทั้งสองนี้ในr15bและr14bimulลงทะเบียนที่พวกเขาจะถูกคูณด้วยกันโดยใช้ การคูณนั้นเป็นการดำเนินการที่ค่อนข้างช้า แต่มันรวดเร็วในโปรเซสเซอร์ที่ทันสมัยและจะเร็วเป็นพิเศษเพราะมันเป็นการคูณค่าสองไบต์เท่านั้น

คุณสามารถแทนที่การคูณได้อย่างง่ายดายด้วยตัวดำเนินการ bitwise AND ( &) ซึ่งไม่ทำการประเมินการลัดวงจร ทำให้โค้ดมีความชัดเจนมากขึ้นและเป็นรูปแบบที่คอมไพเลอร์รับรู้โดยทั่วไป แต่เมื่อคุณทำเช่นนี้ด้วยรหัสของคุณและรวบรวมด้วย GCC 5.4 มันจะยังคงปล่อยสาขาแรก:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L4

    cmp     ax, 478           ; (l[i + shift] < 479)
    setbe   r14b

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

ไม่มีเหตุผลทางเทคนิคที่จะต้องปล่อยโค้ดด้วยวิธีนี้ แต่ด้วยเหตุผลบางอย่างฮิวริสติกภายในของมันบอกว่ามันเร็วกว่า มันจะอาจจะเร็วขึ้นถ้าทำนายสาขาเป็นในด้านของคุณ แต่ก็มีแนวโน้มที่จะลดลงหากทำนายสาขาล้มเหลวบ่อยกว่าจะประสบความสำเร็จ

คอมไพเลอร์รุ่นใหม่กว่า (และคอมไพเลอร์อื่น ๆ เช่น Clang) รู้กฎนี้และบางครั้งจะใช้เพื่อสร้างรหัสเดียวกันกับที่คุณจะต้องค้นหาด้วยการเพิ่มประสิทธิภาพด้วยมือ ฉันเป็นประจำเห็นเสียงดังกราวแปลแสดงออกรหัสเดียวกันกับที่จะได้รับการปล่อยออกมาว่าฉันได้ใช้&& &ต่อไปนี้เป็นผลลัพธ์ที่เกี่ยวข้องจาก GCC 6.2 ด้วยรหัสของคุณโดยใช้&&โอเปอเรเตอร์ปกติ:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L7

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r14b

    add     esi, r14d         ; nontopOverlap++

หมายเหตุวิธีการที่ชาญฉลาดนี้เป็น! มันใช้เงื่อนไขที่ลงนาม ( jgและsetle) ซึ่งตรงข้ามกับเงื่อนไขที่ไม่ได้ลงชื่อ ( jaและsetbe) แต่สิ่งนี้ไม่สำคัญ คุณสามารถเห็นได้ว่ามันยังคงทำการเปรียบเทียบและสาขาสำหรับเงื่อนไขแรกเช่นรุ่นเก่าและใช้setCCคำสั่งเดียวกันเพื่อสร้างรหัส branchless สำหรับเงื่อนไขที่สอง แต่มันมีประสิทธิภาพมากขึ้นในการเพิ่มขึ้น . แทนการทำสองเปรียบเทียบซ้ำซ้อนในการตั้งธงสำหรับsbbการดำเนินการจะใช้ความรู้ที่r14dจะเป็น 1 หรือ 0 nontopOverlapถึงเพียงแค่ไม่มีเงื่อนไขเพิ่มค่านี้ ถ้าr14dเป็น 0 แสดงว่าการเพิ่มคือไม่มี มิฉะนั้นจะเพิ่ม 1 เหมือนกับที่ควรจะทำ

GCC 6.2 สร้างรหัสที่มีประสิทธิภาพมากขึ้นเมื่อคุณใช้ตัว&&ดำเนินการลัดวงจรกว่าตัวดำเนินการระดับบิต&:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L6

    cmp     eax, 478          ; (l[i + shift] < 479)
    setle   r14b

    cmp     r14b, 1           ; nontopOverlap++
    sbb     esi, -1

สาขาและชุดเงื่อนไขยังคงอยู่ที่นั่น แต่ตอนนี้กลับเป็นวิธีเพิ่มค่าที่ชาญฉลาดน้อยลง nontopOverlapแต่ตอนนี้มันกลับไปวิธีที่น้อยฉลาดของการเพิ่ม นี่เป็นบทเรียนที่สำคัญว่าทำไมคุณควรระวังเมื่อพยายามทำให้คอมไพเลอร์ของคุณฉลาดขึ้น!

แต่ถ้าคุณสามารถพิสูจน์ด้วยเกณฑ์มาตรฐานว่าโค้ดการแบรนช์ช้าลงจริง ๆ แล้วมันอาจจ่ายเพื่อลองคอมไพเลอร์ของคุณ คุณต้องทำเช่นนั้นด้วยการตรวจสอบอย่างระมัดระวังของการถอดชิ้นส่วน - และเตรียมที่จะประเมินการตัดสินใจของคุณอีกครั้งเมื่อคุณอัพเกรดคอมไพเลอร์รุ่นที่ใหม่กว่า ตัวอย่างเช่นรหัสที่คุณสามารถเขียนใหม่เป็น:

nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));

ไม่มีifคำสั่งใด ๆเลยและคอมไพเลอร์ส่วนใหญ่จะไม่คิดถึงการเปล่งรหัสการแยกสาขาสำหรับสิ่งนี้ GCC ไม่มีข้อยกเว้น ทุกรุ่นสร้างสิ่งที่คล้ายกับต่อไปนี้:

    movzx   r14d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r14d, 478         ; (curr[i] < 479)
    setle   r15b

    xor     r13d, r13d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r13b

    and     r13d, r15d        ; meld results of the two comparisons
    add     esi, r13d         ; nontopOverlap++

หากคุณได้รับการติดตามพร้อมกับตัวอย่างก่อนหน้านี้สิ่งนี้น่าจะคุ้นเคยกับคุณ การเปรียบเทียบทั้งสองเสร็จสิ้นในลักษณะที่ไม่มีสาขาผลลัพธ์กลางจะถูกandรวมเข้าด้วยกันและจากนั้นผลลัพธ์นี้ (ซึ่งจะเป็น 0 หรือ 1) จะถูกaddเอ็ดnontopOverlapเอ็ดหากคุณต้องการโค้ดไร้สาขามันจะช่วยให้มั่นใจได้ว่าคุณจะได้รับมัน

GCC 7 มีความฉลาดยิ่งขึ้น ตอนนี้มันจะสร้างรหัสที่เหมือนกัน (ยกเว้นการจัดเรียงคำสั่งเล็กน้อย) สำหรับเคล็ดลับข้างต้นเป็นรหัสดั้งเดิม ดังนั้นคำตอบสำหรับคำถามของคุณ"ทำไมคอมไพเลอร์ทำงานแบบนี้ทำไม"อาจเป็นเพราะพวกเขาไม่สมบูรณ์แบบ! พวกเขาพยายามใช้ฮิวริสติกเพื่อสร้างรหัสที่ดีที่สุดเท่าที่จะเป็นไปได้ แต่พวกเขาไม่ได้ทำการตัดสินใจที่ดีที่สุดเสมอไป แต่อย่างน้อยพวกเขาก็สามารถฉลาดขึ้นเมื่อเวลาผ่านไป!

วิธีการหนึ่งในการมองสถานการณ์นี้คือรหัสที่แตกแขนงมีที่ดีกว่ากรณีที่ดีที่สุดประสิทธิภาพ หากการคาดคะเนสาขาประสบความสำเร็จการข้ามการทำงานที่ไม่จำเป็นจะทำให้เวลาใช้งานเร็วขึ้นเล็กน้อย อย่างไรก็ตามรหัส branchless มีประสิทธิภาพกรณีที่เลวร้ายที่สุดที่ดีกว่า ถ้าคำทำนายสาขาล้มเหลวรันคำแนะนำเพิ่มเติมไม่กี่เท่าที่จำเป็นเพื่อหลีกเลี่ยงสาขาจะแน่นอนจะเร็วกว่าสาขา mispredicted แม้แต่คอมไพเลอร์ที่ฉลาดและฉลาดที่สุดก็ยังมีปัญหาในการตัดสินใจเลือก

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


3
ดีไม่มีสากล "ดีกว่า" ทุกอย่างขึ้นอยู่กับสถานการณ์ของคุณซึ่งเป็นสาเหตุที่ทำให้คุณต้องทำการวัดประสิทธิภาพเมื่อคุณทำการเพิ่มประสิทธิภาพระดับต่ำแบบนี้ ดังที่ฉันได้อธิบายไว้ในคำตอบหากคุณมีขนาดที่สูญเสียการทำนายสาขากิ่งที่ผิดพลาดจะทำให้โค้ดของคุณช้าลงมากมากบิตสุดท้ายของรหัสไม่ได้ใช้สาขาใด ๆ (สังเกตการขาดj*คำแนะนำ) ดังนั้นมันจะเร็วขึ้นในกรณีนั้น [ดำเนินการต่อ]
โคดี้เกรย์


2
@ 8bit Bob ถูกต้อง ฉันหมายถึงคิว prefetch ฉันอาจจะไม่ได้เรียกมันว่าแคช แต่ก็ไม่ได้กังวลอย่างมากกับการใช้ถ้อยคำและไม่ได้ใช้เวลานานในการพยายามจดจำข้อมูลเฉพาะเนื่องจากฉันไม่ได้คิดว่าจะใส่ใจใครมากนักยกเว้นความอยากรู้ทางประวัติศาสตร์ ถ้าคุณต้องการรายละเอียดภาษาของแอสเซมบลีเซนของ Michael Abrash นั้นมีค่ามาก หนังสือทั้งเล่มมีอยู่ในสถานที่ต่างๆทางออนไลน์ นี่คือส่วนที่เกี่ยวข้องกับการแตกแขนงแต่คุณควรอ่านและทำความเข้าใจส่วนต่าง ๆ ในการดึงข้อมูลล่วงหน้าเช่นกัน
Cody Gray

6
@ Hurkyl ฉันรู้สึกเหมือนคำตอบทั้งหมดพูดถึงคำถามนั้น คุณพูดถูกฉันไม่ได้เรียกมันออกมาอย่างชัดเจน แต่ดูเหมือนว่ามันจะนานพอแล้ว :-) ใครก็ตามที่ใช้เวลาในการอ่านสิ่งทั้งหมดควรได้รับความเข้าใจที่เพียงพอในจุดนั้น แต่ถ้าคุณคิดว่ามีบางอย่างขาดหายไปหรือต้องการคำชี้แจงเพิ่มเติมโปรดอย่าลังเลที่จะแก้ไขคำตอบเพื่อรวมไว้ด้วย บางคนไม่ชอบสิ่งนี้ แต่ฉันไม่รังเกียจ ฉันได้เพิ่มความคิดเห็นสั้น ๆ เกี่ยวกับสิ่งนี้พร้อมกับดัดแปลงถ้อยคำของฉันตามที่แนะนำโดย 8bittree
Cody Gray

2
ฮะขอบคุณสำหรับส่วนประกอบ @green ฉันไม่มีอะไรพิเศษที่จะแนะนำ เช่นเดียวกับทุกสิ่งคุณกลายเป็นผู้เชี่ยวชาญโดยทำดูและประสบ ฉันได้อ่านทุกอย่างที่ฉันสามารถทำได้เมื่อพูดถึงสถาปัตยกรรม x86, การเพิ่มประสิทธิภาพ, คอมไพเลอร์ภายในและสิ่งอื่น ๆ ในระดับต่ำและฉันยังรู้เพียงเศษเสี้ยวของทุกสิ่งที่รู้ วิธีที่ดีที่สุดในการเรียนรู้คือการทำให้มือของคุณสกปรกขณะขุด แต่ก่อนที่คุณจะสามารถเริ่มหวังได้คุณจะต้องเข้าใจ C (หรือ C ++) พอยน์เตอร์ภาษาแอสเซมบลีและพื้นฐานระดับต่ำอื่น ๆ ทั้งหมด
Cody Grey

23

สิ่งหนึ่งที่สำคัญที่ควรทราบคือ

(curr[i] < 479) && (l[i + shift] < 479)

และ

(curr[i] < 479) * (l[i + shift] < 479)

ไม่เทียบเท่าความหมาย! โดยเฉพาะอย่างยิ่งถ้าคุณเคยมีสถานการณ์ที่:

  • 0 <= iและi < curr.size()เป็นจริงทั้งคู่
  • curr[i] < 479 เป็นเท็จ
  • i + shift < 0หรือi + shift >= l.size()เป็นเรื่องจริง

ดังนั้นนิพจน์(curr[i] < 479) && (l[i + shift] < 479)จะรับประกันว่าเป็นค่าบูลีนที่กำหนดไว้อย่างดี ตัวอย่างเช่นจะไม่ทำให้เกิดความผิดพลาดในการแบ่งส่วน

อย่างไรก็ตามภายใต้สถานการณ์เหล่านี้นิพจน์(curr[i] < 479) * (l[i + shift] < 479)คือพฤติกรรมที่ไม่ได้กำหนด ; มันจะได้รับอนุญาตที่จะทำให้เกิดความผิดพลาดในการแบ่งส่วน

ซึ่งหมายความว่าสำหรับตัวอย่างโค้ดต้นฉบับคอมไพเลอร์ไม่สามารถเขียนลูปที่ดำเนินการเปรียบเทียบและทำการandดำเนินการได้เว้นแต่คอมไพเลอร์สามารถพิสูจน์ได้ว่าl[i + shift]จะไม่ทำให้เกิด segfault ในสถานการณ์ที่ไม่จำเป็นต้องทำ

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

คุณอาจแก้ไขฉบับดั้งเดิมโดยทำแทน

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...

นี้! ขึ้นอยู่กับมูลค่าของshift(และmax) มี UB อยู่ที่นี่ ...
Matthieu M.

18

&&ผู้ประกอบการดำเนินการประเมินผลการลัดวงจร นี่หมายความว่าตัวถูกดำเนินการตัวที่สองจะถูกประเมินเฉพาะเมื่อตัวแรกถูกประเมินtrueที่นี้หมายถึงว่าตัวถูกดำเนินการที่สองคือการประเมินเฉพาะในกรณีที่แรกที่จะประเมินซึ่งจะส่งผลให้เกิดการกระโดดอย่างแน่นอน

คุณสามารถสร้างตัวอย่างเล็ก ๆ เพื่อแสดงสิ่งนี้:

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

เอาต์พุตแอสเซมเบลอร์สามารถพบได้ที่นี่เอาท์พุทประกอบสามารถพบได้ที่นี่

คุณสามารถดูรหัสที่สร้างขึ้นก่อนการโทรf(x)จากนั้นตรวจสอบผลลัพธ์และข้ามไปที่การประเมินผลg(x)เมื่อเป็นtrueเช่นนี้ มิฉะนั้นจะออกจากฟังก์ชั่น

การใช้การคูณ "บูลีน" แทนการบังคับให้มีการประเมินตัวถูกดำเนินการทั้งสองทุกครั้งและไม่จำเป็นต้องกระโดด

การกระโดดอาจทำให้ช้าลงเนื่องจากรบกวนการส่งข้อมูลของ CPU และสิ่งอื่น ๆ เช่นการดำเนินการเก็งกำไร ปกติการคาดคะเนสาขาจะช่วย แต่ถ้าข้อมูลของคุณเป็นแบบสุ่มจะมีไม่มากที่สามารถทำนายได้


1
ทำไมคุณถึงกล่าวว่าการคูณนั้นบังคับให้ประเมินทั้งตัวถูกดำเนินการทุกครั้ง? 0 * x = x * 0 = 0 โดยไม่คำนึงถึงค่าของ x ในการปรับให้เหมาะสมคอมไพเลอร์อาจ "shortcircuit" การคูณเช่นกัน ดูstackoverflow.com/questions/8145894/…เป็นต้น ยิ่งไปกว่านั้นไม่เหมือนกับตัว&&ดำเนินการการคูณอาจถูกประเมินแบบขี้เกียจไม่ว่าจะด้วยอาร์กิวเมนต์แรกหรืออาร์กิวเมนต์ที่สองทำให้มีอิสระมากขึ้นสำหรับการปรับให้เหมาะสม
SomeWittyUsername

@Jens - "โดยปกติการคาดคะเนสาขาจะช่วยได้ แต่ถ้าข้อมูลของคุณเป็นแบบสุ่มมีไม่มากที่สามารถทำนายได้" - ทำให้คำตอบที่ดี
SChepurin

1
@SomeWittyUsername ตกลงคอมไพเลอร์มีอิสระที่จะทำการเพิ่มประสิทธิภาพใด ๆ ที่ทำให้พฤติกรรมที่สังเกตได้ สิ่งนี้อาจจะเปลี่ยนหรือไม่ใช้การคำนวณก็ได้ ถ้าคุณคำนวณ0 * f()และfมีพฤติกรรมที่สังเกตได้คอมไพเลอร์จะต้องเรียกมันว่า แตกต่างก็คือการประเมินผลการลัดวงจรมีผลบังคับใช้สำหรับแต่อนุญาตให้ถ้ามันสามารถแสดงให้เห็นว่ามันเป็นเทียบเท่า&& *
Jens

@SomeWittyUsername เฉพาะในกรณีที่ค่า 0 สามารถทำนายได้จากตัวแปรหรือค่าคงที่ ฉันเดาว่ากรณีเหล่านี้มีน้อยมาก แน่นอนว่าการปรับให้เหมาะสมไม่สามารถทำได้ในกรณีของ OP เนื่องจากการเข้าถึงอาร์เรย์มีส่วนเกี่ยวข้อง
Diego Sevilla

3
@Jens: การประเมินการลัดวงจรไม่บังคับ รหัสจะต้องทำตัวราวกับว่ามันเป็นวงจรสั้น คอมไพเลอร์ได้รับอนุญาตให้ใช้วิธีการใด ๆ ที่มันชอบเพื่อให้บรรลุผล

-2

อาจเป็นเพราะเมื่อคุณใช้ตัวดำเนินการทางตรรกะ&&คอมไพเลอร์จะต้องตรวจสอบสองเงื่อนไขเพื่อให้คำสั่ง if ประสบความสำเร็จ อย่างไรก็ตามในกรณีที่สองเนื่องจากคุณแปลงค่า int ไปเป็นบูลโดยปริยายคอมไพเลอร์สร้างข้อสันนิษฐานบางอย่างขึ้นอยู่กับชนิดและค่าที่ถูกส่งผ่านพร้อมกับ (อาจ) เงื่อนไขการกระโดดเดี่ยว นอกจากนี้ยังเป็นไปได้ว่าคอมไพเลอร์ปรับแต่ง jmps ให้สมบูรณ์ด้วยการเลื่อนบิตอย่างสมบูรณ์


8
การกระโดดนั้นมาจากความจริงที่ว่าเงื่อนไขที่สองนั้นถูกประเมินถ้าหากเป็นอย่างแรกเท่านั้น รหัสจะต้องไม่ประเมินเป็นอย่างอื่นดังนั้นคอมไพเลอร์ไม่สามารถปรับให้เหมาะสมได้ดีกว่านี้และยังคงถูกต้อง (เว้นแต่ว่ามันจะสามารถอนุมานได้ว่าคำสั่งแรกจะเป็นจริงเสมอ)
rubenvb
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.