ตรรกะและตัวดำเนินการ ( &&
) ใช้การประเมินการลัดวงจรซึ่งหมายความว่าการทดสอบครั้งที่สองจะกระทำก็ต่อเมื่อการเปรียบเทียบครั้งแรกประเมินเป็นจริง นี่เป็นความหมายที่คุณต้องการ ตัวอย่างเช่นพิจารณารหัสต่อไปนี้:
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
setbe
XOR เป็นเพียงกลลวงมาตรฐานสำหรับการล้างทะเบียน setbe
เป็นคำสั่ง x86 ที่กำหนดเล็กน้อยขึ้นอยู่กับมูลค่าของธงและมักจะถูกนำมาใช้ในการดำเนินการรหัสสาขา นี่setbe
เป็นสิ่งที่ตรงกันข้ามของja
เป็นสิ่งที่ตรงกันข้ามของมันตั้งค่าการลงทะเบียนปลายทางเป็น 1 หากการเปรียบเทียบต่ำกว่าหรือเท่ากับ (เนื่องจากการลงทะเบียนเป็นศูนย์ล่วงหน้ามันจะเป็น 0 อย่างอื่น) ในขณะที่ja
แยกหากการเปรียบเทียบข้างต้น เมื่อได้รับค่าทั้งสองนี้ในr15b
และr14b
imul
ลงทะเบียนที่พวกเขาจะถูกคูณด้วยกันโดยใช้ การคูณนั้นเป็นการดำเนินการที่ค่อนข้างช้า แต่มันรวดเร็วในโปรเซสเซอร์ที่ทันสมัยและจะเร็วเป็นพิเศษเพราะมันเป็นการคูณค่าสองไบต์เท่านั้น
คุณสามารถแทนที่การคูณได้อย่างง่ายดายด้วยตัวดำเนินการ 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 แม้แต่คอมไพเลอร์ที่ฉลาดและฉลาดที่สุดก็ยังมีปัญหาในการตัดสินใจเลือก
และสำหรับคำถามของคุณว่านี่เป็นสิ่งที่โปรแกรมเมอร์ต้องระวังหรือไม่คำตอบคือแทบจะไม่ยกเว้นในลูปร้อนที่คุณพยายามเพิ่มความเร็วด้วยการปรับให้เหมาะสมแบบไมโคร จากนั้นคุณนั่งลงด้วยการถอดแยกชิ้นส่วนและหาวิธีในการปรับแต่ง และอย่างที่ฉันพูดไว้ก่อนหน้านี้ให้เตรียมพร้อมที่จะทบทวนการตัดสินใจเหล่านั้นอีกครั้งเมื่อคุณอัปเดตคอมไพเลอร์เวอร์ชั่นใหม่กว่าเพราะอาจทำอะไรที่โง่ ๆ กับรหัสที่ยุ่งยากของคุณหรืออาจเปลี่ยนวิธีการเพิ่มประสิทธิภาพ เพื่อใช้รหัสเดิมของคุณ แสดงความคิดเห็นอย่างละเอียด!