หากคุณคิดว่าคำสั่ง DIV แบบ 64 บิตเป็นวิธีที่ดีในการหารด้วยสองดังนั้นจึงไม่น่าแปลกใจที่ผลลัพธ์ asm ของคอมไพเลอร์จะเอาชนะรหัสที่เขียนด้วยมือของคุณแม้จะใช้-O0
(คอมไพล์อย่างรวดเร็วไม่มีการเพิ่มประสิทธิภาพพิเศษ หน้าคำสั่ง C ทุกคำสั่งเพื่อให้ดีบักเกอร์สามารถแก้ไขตัวแปร)
ดูคู่มือประกอบการเพิ่มประสิทธิภาพของ Agner Fogเพื่อเรียนรู้วิธีเขียน asm อย่างมีประสิทธิภาพ นอกจากนี้เขายังมีตารางคำแนะนำและคู่มือ microarch สำหรับรายละเอียดเฉพาะสำหรับ CPU ที่เฉพาะเจาะจง ดูยังx86 แท็ก wiki สำหรับลิงค์ perf เพิ่มเติม
ดูคำถามทั่วไปเกี่ยวกับการแปลคอมไพเลอร์ด้วย asm ที่เขียนด้วยมือ: ภาษาแอสเซมบลีแบบอินไลน์ช้ากว่าโค้ด C ++ ดั้งเดิมหรือไม่ . TL: DR: ใช่ถ้าคุณทำผิด (เช่นคำถามนี้)
โดยปกติแล้วคุณปรับกำลังปล่อยให้คอมไพเลอร์ทำสิ่งที่ตนโดยเฉพาะอย่างยิ่งถ้าคุณพยายามที่จะเขียน c ++ ที่สามารถรวบรวมได้อย่างมีประสิทธิภาพ ดูด้วยว่าการประกอบเร็วกว่าภาษาที่คอมไพล์หรือไม่ . หนึ่งในคำตอบเชื่อมโยงไปยังสไลด์ที่เรียบร้อยเหล่านี้แสดงให้เห็นว่าคอมไพเลอร์ C ต่าง ๆ ปรับแต่งฟังก์ชั่นที่เรียบง่ายด้วยเทคนิคสุดเจ๋ง CppCon2017 ของ Matt Godbolt พูดคุยว่า " เมื่อเร็ว ๆ นี้คอมไพเลอร์ของฉันทำอะไรให้ฉันบ้าง? การคลายสลักเกลียวของ Compiler ” อยู่ในหลอดเลือดดำที่คล้ายกัน
even:
mov rbx, 2
xor rdx, rdx
div rbx
บน Intel Haswell div r64
มี 36 uops โดยมีเวลาแฝงอยู่ที่ 32-96 รอบและปริมาณงานหนึ่งรอบต่อ 21-74 รอบ (บวก 2 uops เพื่อตั้งค่า RBX และศูนย์ RDX แต่การดำเนินการที่ไม่เป็นไปตามคำสั่งสามารถเรียกใช้งานก่อนหน้าได้) คำแนะนำแบบนับจำนวนสูงเช่น DIV นั้นเป็นไมโครโค้ดซึ่งอาจทำให้เกิดปัญหาคอขวดส่วนหน้า ในกรณีนี้เวลาในการตอบสนองเป็นปัจจัยที่มีความเกี่ยวข้องมากที่สุดเนื่องจากเป็นส่วนหนึ่งของห่วงโซ่การพึ่งพาแบบวนรอบ
shr rax, 1
ทำส่วนที่ไม่ได้ลงนามเดียวกัน: มันคือ 1 uop, กับ 1c latency , และสามารถรัน 2 ต่อรอบสัญญาณนาฬิกา
สำหรับการเปรียบเทียบการแบ่งแบบ 32 บิตนั้นเร็วกว่า แต่ก็ยังน่ากลัวอยู่ idiv r32
คือ 9 uops, 22-29c latency, และหนึ่งต่อ 8-11c throughput บน Haswell
อย่างที่คุณเห็นจากการดู-O0
เอาต์พุต asm ของ gcc ( Godbolt compiler explorer ) จะใช้คำสั่งกะเท่านั้น clang -O0
รวบรวมอย่างไร้เดียงสาอย่างที่คุณคิดแม้กระทั่งใช้ 64- บิต IDIV สองครั้ง (เมื่อทำการออปติไมซ์คอมไพเลอร์จะใช้ทั้งสองเอาต์พุตของ IDIV เมื่อแหล่งที่มาทำการหารและโมดูลัสด้วยตัวถูกดำเนินการเดียวกันถ้าพวกเขาใช้ IDIV เลย)
GCC ไม่มีโหมดที่ไร้เดียงสาทั้งหมด; มันจะแปลงผ่าน GIMPLE เสมอซึ่งหมายความว่า "การเพิ่มประสิทธิภาพ" บางอย่างไม่สามารถปิดใช้งานได้ ซึ่งรวมถึงการจดจำการหารโดยค่าคงที่และการใช้กะ (กำลัง 2) หรือการผกผันการคูณจุดคงที่ (ไม่ใช่กำลัง 2) เพื่อหลีกเลี่ยง IDIV (ดูdiv_by_13
ในลิงค์ godbolt ด้านบน)
gcc -Os
(การเพิ่มประสิทธิภาพขนาด) ไม่ใช้ IDIV สำหรับพลังงานที่ไม่ของ 2 ส่วนที่น่าเสียดายที่แม้ในกรณีที่รหัสผกผันเป็นเพียงขนาดใหญ่กว่าเล็กน้อย แต่ได้เร็วขึ้นมาก
ช่วยคอมไพเลอร์
(สรุปสำหรับกรณีนี้: ใช้uint64_t n
)
ก่อนอื่นมันเป็นเรื่องที่น่าสนใจเพียงอย่างเดียวที่จะมองหาคอมไพเลอร์เอาท์พุทที่ดีที่สุด ( -O3
) -O0
ความเร็วนั้นไม่มีความหมายโดยทั่วไป
ดูที่เอาต์พุต asm ของคุณ (บน Godbolt หรือดูวิธีการ "ขจัดเสียงรบกวน" ออกจากเอาต์พุตชุดประกอบ GCC / เสียงดังกราวด์? ) เมื่อคอมไพเลอร์ไม่ได้ทำให้รหัสที่เหมาะสมในสถานที่แรก: การเขียนของคุณ C / C ++ แหล่งที่มาในทางที่คู่มือการคอมไพเลอร์ในการทำรหัสดีกว่ามักจะเป็นวิธีที่ดีที่สุด คุณต้องรู้จัก asm และรู้ว่าอะไรมีประสิทธิภาพ แต่คุณใช้ความรู้นี้ทางอ้อม คอมไพเลอร์ยังเป็นแหล่งความคิดที่ดี: บางครั้งเสียงดังกราวจะทำอะไรที่เจ๋ง ๆ และคุณสามารถถือ gcc ทำสิ่งเดียวกันได้: ดูคำตอบนี้และสิ่งที่ฉันทำกับลูปที่ไม่ได้ควบคุมในโค้ดของ @ Veedrac ด้านล่าง)
วิธีการนี้เป็นแบบพกพาและในอีก 20 ปีผู้รวบรวมในอนาคตสามารถรวบรวมสิ่งที่มีประสิทธิภาพในฮาร์ดแวร์ในอนาคต (x86 หรือไม่) อาจใช้ส่วนขยาย ISA ใหม่หรือปรับเวกเตอร์อัตโนมัติ x86-64 asm ที่เขียนด้วยมือเมื่อ 15 ปีที่แล้วมักจะไม่ได้รับการปรับให้เหมาะสมสำหรับ Skylake เช่นการเปรียบเทียบและการรวมตัวของแมโครฟิวชั่นสาขาไม่มีอยู่ในตอนนั้น สิ่งที่ดีที่สุดในตอนนี้สำหรับ asm ที่สร้างขึ้นด้วยมือสำหรับ microarchitecture หนึ่งอาจไม่เหมาะสำหรับ CPU ปัจจุบันและอนาคตอื่น ๆ ความเห็นเกี่ยวกับคำตอบของ @ johnfoundกล่าวถึงความแตกต่างที่สำคัญระหว่าง AMD Bulldozer และ Intel Haswell ซึ่งมีผลอย่างมากต่อรหัสนี้ แต่ในทางทฤษฎีg++ -O3 -march=bdver3
และg++ -O3 -march=skylake
จะทำสิ่งที่ถูกต้อง (หรือ-march=native
.) หรือ-mtune=...
เพียงแค่ปรับแต่งโดยไม่ต้องใช้คำสั่งที่ CPU อื่นอาจไม่รองรับ
ความรู้สึกของฉันคือการแนะนำคอมไพเลอร์เป็น asm ซึ่งดีสำหรับซีพียูปัจจุบันที่คุณสนใจไม่ควรเป็นปัญหาสำหรับคอมไพเลอร์ในอนาคต พวกเขาหวังว่าจะดีกว่าคอมไพเลอร์ปัจจุบันในการหาวิธีในการแปลงรหัสและสามารถหาวิธีที่เหมาะกับซีพียูในอนาคต ไม่ว่าในอนาคต x86 อาจจะไม่น่ากลัวในสิ่งที่ดีกับ x86 ปัจจุบันและคอมไพเลอร์ในอนาคตจะหลีกเลี่ยงข้อผิดพลาดเฉพาะ asm ในขณะที่ดำเนินการบางอย่างเช่นการเคลื่อนย้ายข้อมูลจากแหล่ง C ของคุณหากไม่เห็นอะไรดีขึ้น
asm ที่เขียนด้วยมือเป็นกล่องดำสำหรับเครื่องมือเพิ่มประสิทธิภาพดังนั้นการแพร่กระจายคงที่ไม่ทำงานเมื่ออินไลน์ทำให้อินพุทเป็นค่าคงที่เวลาคอมไพล์ การเพิ่มประสิทธิภาพอื่น ๆ ยังได้รับผลกระทบ อ่านhttps://gcc.gnu.org/wiki/DontUseInlineAsmก่อนใช้ asm (และหลีกเลี่ยง MSM แบบอินไลน์ asm: อินพุต / เอาท์พุตจะต้องผ่านหน่วยความจำที่เพิ่มค่าใช้จ่าย )
ในกรณีนี้ : คุณn
มีประเภทที่เซ็นชื่อแล้วและ gcc ใช้ลำดับ SAR / SHR / ADD ที่ให้การปัดเศษที่ถูกต้อง (IDIV และ arithmetic-shift "round" แตกต่างกันสำหรับอินพุตเชิงลบโปรดดูรายการป้อนด้วยตนเองของSAR insn set ) (IDK ถ้า gcc พยายามและล้มเหลวในการพิสูจน์ว่าn
ไม่สามารถลบได้หรืออะไรการลงชื่อล้นเกินนั้นเป็นพฤติกรรมที่ไม่ได้กำหนดดังนั้นจึงควรจะสามารถทำได้)
คุณควรจะใช้uint64_t n
มันก็แค่ SHR ดังนั้นจึงพกพาไปยังระบบที่long
มีเพียง 32 บิต (เช่น x86-64 Windows)
BTW, เอาต์พุต asm ที่ปรับปรุงแล้วของ gcc ดูค่อนข้างดี (โดยใช้)unsigned long n
: การวนรอบภายในที่อินไลน์เข้าmain()
ทำเช่นนี้:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
วงในเป็นแบบไร้สาขาและเส้นทางวิกฤตของห่วงโซ่การพึ่งพาแบบวนรอบที่ดำเนินการคือ:
- LEA 3 ส่วนประกอบ (3 รอบ)
- cmov (2 รอบใน Haswell, 1c บน Broadwell หรือใหม่กว่า)
ทั้งหมด: 5 รอบต่อย้ำแฝงคอขวด การดำเนินการที่ไม่เป็นไปตามสั่งจะดูแลทุกอย่างอื่นควบคู่ไปกับสิ่งนี้ (ในทางทฤษฎี: ฉันไม่ได้ทดสอบกับเคาน์เตอร์ที่สมบูรณ์แบบเพื่อดูว่ามันทำงานที่ 5c / iter จริงๆ
อินพุต FLAGS ของcmov
(สร้างโดย TEST) นั้นเร็วกว่าการสร้างมากกว่าอินพุต RAX (จาก LEA-> MOV) ดังนั้นจึงไม่ได้อยู่ในเส้นทางวิกฤติ
ในทำนองเดียวกัน MOV-> SHR ที่สร้างอินพุต RDI ของ CMOV อยู่นอกเส้นทางที่สำคัญเพราะมันเร็วกว่า LEA เช่นกัน MOV บน IvyBridge และใหม่กว่ามีเวลาแฝงเป็นศูนย์ (จัดการเมื่อลงทะเบียนเปลี่ยนชื่อ) (มันยังคงใช้ uop และสล็อตในไปป์ไลน์ดังนั้นจึงไม่ฟรีเพียงไม่มีเวลาในการตอบสนอง) MOV พิเศษในเครือข่าย LEA dep เป็นส่วนหนึ่งของคอขวดในซีพียูอื่น ๆ
cmp / jne ยังไม่ได้เป็นส่วนหนึ่งของเส้นทางวิกฤติ: มันไม่ได้เป็นแบบวนรอบเนื่องจากการพึ่งพาการควบคุมได้รับการจัดการด้วยการคาดคะเนสาขา + การดำเนินการเก็งกำไรซึ่งแตกต่างจากการอ้างอิงข้อมูลบนเส้นทางวิกฤติ
ตีคอมไพเลอร์
GCC ทำได้ค่อนข้างดีที่นี่ มันสามารถบันทึกหนึ่งไบต์รหัสโดยใช้inc edx
แทนadd edx, 1
เนื่องจากไม่มีใครสนใจ P4 และการอ้างอิงเท็จสำหรับคำแนะนำการปรับเปลี่ยนบางส่วนการตั้งค่าสถานะ
มันยังสามารถบันทึกทุกคำแนะนำ MOV และการทดสอบ: SHR ชุด CF = บิตขยับตัวออกมาเพื่อให้เราสามารถใช้cmovc
แทน/test
cmovz
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
ดูคำตอบของ @ johnfound สำหรับเคล็ดลับอันชาญฉลาดอื่น: ลบ CMP โดยการแยกผลลัพธ์ธงของ SHR และใช้กับ CMOV: ศูนย์เฉพาะถ้า n เป็น 1 (หรือ 0) เพื่อเริ่มต้นด้วย (ความจริงแล้วสนุก: SHR พร้อม count! = 1 ใน Nehalem หรือก่อนหน้านี้ทำให้แผงลอยถ้าคุณอ่านผลลัพธ์ธงนั่นเป็นวิธีที่พวกเขาทำให้มันเป็นแบบ single-uop การเข้ารหัสพิเศษแบบ shift-by-1 ทำได้ดี)
การหลีกเลี่ยง MOV ไม่ได้ช่วยในเรื่องเวลาแฝงเลยใน Haswell ( MOV ของ x86 สามารถ "ฟรี" ได้หรือไม่ทำไมฉันถึงทำซ้ำไม่ได้เลย? ) มันช่วยอย่างมีนัยสำคัญเกี่ยวกับซีพียูเช่น Intel pre-IvB และ AMD Bulldozer-family ซึ่ง MOV ไม่ได้มีความหน่วงแฝง คำสั่ง MOV ที่เสียไปของคอมไพเลอร์จะส่งผลกระทบต่อเส้นทางที่สำคัญ คอมเพล็กซ์ของ LEA และ CMOV ของ BD มีทั้งเวลาแฝงที่ต่ำกว่า (2c และ 1c ตามลำดับ) ดังนั้นจึงเป็นความล่าช้าที่ใหญ่กว่า นอกจากนี้ปัญหาคอขวดสำหรับปริมาณงานกลายเป็นปัญหาเนื่องจากมีเพียง ALU จำนวนเต็มสองตัว ดูคำตอบของ @ johnfoundซึ่งเขามีผลการจับเวลาจากซีพียู AMD
แม้แต่ใน Haswell เวอร์ชันนี้อาจช่วยได้เล็กน้อยโดยหลีกเลี่ยงความล่าช้าบางครั้งซึ่ง uop ที่ไม่สำคัญขโมยพอร์ตการเรียกใช้จากพอร์ตที่สำคัญบนเส้นทางที่สำคัญ (สิ่งนี้เรียกว่าข้อขัดแย้งของทรัพยากร) นอกจากนี้ยังบันทึกการลงทะเบียนซึ่งอาจช่วยเมื่อทำn
ค่าหลายค่าในแบบคู่ขนานในการวนซ้ำ interleaved (ดูด้านล่าง)
เวลาแฝงของ LEA ขึ้นอยู่กับโหมดการกำหนดแอดเดรสบน Intel SnB ตระกูลซีพียู 3c สำหรับ 3 องค์ประกอบ ( [base+idx+const]
ซึ่งใช้เวลาสองแยกเพิ่ม) แต่เพียง 1c กับ 2 หรือน้อยกว่าส่วนประกอบ (หนึ่งเพิ่ม) CPU บางตัว (เช่น Core2) ทำแม้แต่ 3 องค์ประกอบ LEA ในรอบเดียว แต่ SnB ตระกูลไม่ได้ ที่เลวร้ายยิ่งกว่าตระกูล Intel SnB- มาตรฐานเวลาแฝงดังนั้นจึงไม่มี 2c uopsมิฉะนั้น LEA 3 องค์ประกอบจะเป็นเพียง 2c เช่น Bulldozer (3 องค์ประกอบของ LEA นั้นช้ากว่าของ AMD เช่นกันไม่มากเท่า)
ดังนั้นlea rcx, [rax + rax*2]
/ inc rcx
เป็นเพียง 2c latency เร็วกว่าlea rcx, [rax + rax*2 + 1]
บน Intel SnB ตระกูล CPU เช่น Haswell Break-even บน BD และแย่กว่าใน Core2 มันเสียค่าใช้จ่ายเพิ่ม uop ซึ่งโดยปกติแล้วจะไม่คุ้มค่าที่จะประหยัด 1c latency แต่ latency เป็นคอขวดที่สำคัญที่นี่และ Haswell มีท่อที่กว้างพอที่จะรองรับปริมาณงานที่เพิ่มขึ้นของ uop
ทั้ง gcc, icc และ clang (บน godbolt) ไม่ใช้เอาต์พุต CF ของ SHR โดยใช้ AND หรือ TESTเสมอ คอมไพเลอร์โง่ ๆ : P พวกมันเป็นชิ้นส่วนที่ซับซ้อนของเครื่องจักร แต่มนุษย์ที่ฉลาดสามารถเอาชนะพวกมันได้ในปัญหาเล็ก ๆ (ให้เวลานานกว่าพันถึงล้านเท่าในการคิดเกี่ยวกับมันคอมไพเลอร์ไม่ได้ใช้อัลกอริธึมที่ละเอียดถี่ถ้วนเพื่อค้นหาทุกวิธีที่เป็นไปได้ในการทำสิ่งต่าง ๆ เพราะมันจะใช้เวลานานเกินไปเมื่อปรับโค้ดอินไลน์จำนวนมาก พวกเขาทำได้ดีที่สุดพวกเขายังไม่จำลองแบบไปป์ไลน์ใน microar Architecture เป้าหมายอย่างน้อยก็ไม่ได้อยู่ในรายละเอียดเดียวกับIACAหรือเครื่องมือวิเคราะห์แบบคงที่อื่น ๆ พวกเขาใช้ฮิวริสติกบางอย่าง)
การวนรอบอย่างง่ายจะไม่ช่วยอะไร คอขวดห่วงนี้ในเวลาแฝงของห่วงโซ่พึ่งพาห่วงดำเนินการไม่ได้อยู่ในห่วงค่าใช้จ่าย / throughput ซึ่งหมายความว่ามันจะทำงานได้ดีกับการทำไฮเปอร์เธรด (หรือ SMT ชนิดอื่น) เนื่องจากซีพียูมีเวลามากมายในการแทรกคำแนะนำจากสองเธรด นี่จะหมายถึงการวนลูปแบบขนานmain
แต่ก็ดีเพราะแต่ละเธรดสามารถตรวจสอบช่วงของn
ค่าและสร้างผลลัพธ์เป็นคู่ได้
การสอดแทรกด้วยมือภายในเธรดเดียวอาจทำงานได้เช่นกัน บางทีคำนวณลำดับสำหรับคู่ของตัวเลขในแบบคู่ขนานเพราะแต่ละคนจะใช้เวลาเพียงสองสามลงทะเบียนและพวกเขาทั้งหมดสามารถอัปเดตเดียว/max
maxi
นี้จะสร้างเพิ่มเติมขนานการเรียนการสอนระดับ
เคล็ดลับคือการตัดสินใจว่าจะรอจนกว่าn
ค่าทั้งหมดจะถึง1
ก่อนที่จะรับn
ค่าเริ่มต้นอีกคู่หรือว่าจะแยกออกและได้รับจุดเริ่มต้นใหม่สำหรับเพียงหนึ่งที่มาถึงสภาพสิ้นสุดโดยไม่ต้องลงทะเบียนสำหรับลำดับอื่น ๆ อาจเป็นการดีที่สุดที่จะให้แต่ละลูกโซ่ทำงานกับข้อมูลที่มีประโยชน์ไม่เช่นนั้นคุณจะต้องเพิ่มตัวนับตามเงื่อนไข
คุณสามารถแม้กระทั่งทำเช่นนี้กับ SSE บรรจุ-เปรียบเทียบสิ่งที่จะเพิ่มเงื่อนไขเคาน์เตอร์สำหรับองค์ประกอบเวกเตอร์ที่n
ได้ไม่ถึง1
เลย แล้วเพื่อซ่อนเวลาแฝงที่นานขึ้นของการใช้งานแบบเพิ่มเงื่อนไข SIMD คุณจะต้องเก็บn
ค่าเวกเตอร์ไว้ในอากาศมากขึ้น อาจคุ้มค่ากับ 256b เวกเตอร์ (4x uint64_t
)
ฉันคิดว่ากลยุทธ์ที่ดีที่สุดในการตรวจจับ1
"เหนียว" คือการปกปิดเวกเตอร์ของทุกคนที่คุณเพิ่มเพื่อเพิ่มเคาน์เตอร์ ดังนั้นหลังจากที่คุณเห็น a 1
ในองค์ประกอบ, increment-vector จะมีศูนย์และ + = 0 คือ no-op
แนวคิดที่ไม่ได้ทดสอบสำหรับการทำให้เป็นเวกเตอร์ด้วยตนเอง
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
คุณสามารถและควรใช้สิ่งนี้กับอินทิลิตี้แทนที่จะเป็น asm ที่เขียนด้วยมือ
การปรับปรุงอัลกอริทึม / การใช้งาน:
นอกเหนือจากการใช้ตรรกะเดียวกันกับ asm มีประสิทธิภาพมากขึ้นมองหาวิธีที่จะลดความซับซ้อนของตรรกะหรือหลีกเลี่ยงการทำงานซ้ำซ้อน เช่นบันทึกช่วยจำในการตรวจจับตอนจบทั่วไปของลำดับ หรือดียิ่งขึ้นดูที่ 8 บิตต่อท้ายในครั้งเดียว (คำตอบของ gnasher)
@EOF ชี้ให้เห็นว่าtzcnt
(หรือbsf
) สามารถใช้ในการทำn/=2
ซ้ำหลายรายการในขั้นตอนเดียว นั่นอาจจะดีกว่า SIMD เวกเตอร์ คำสั่ง SSE หรือ AVX ไม่สามารถทำได้ มันยังคงเข้ากันได้กับการทำหลายสเกลาร์n
แบบขนานในการลงทะเบียนจำนวนเต็มที่แตกต่างกัน
ดังนั้นลูปอาจมีลักษณะเช่นนี้:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
สิ่งนี้อาจทำซ้ำได้น้อยลงอย่างมาก แต่การกะนับจำนวนตัวแปรช้าสำหรับซีพียู Intel SnB ตระกูลที่ไม่มี BMI2 3 uops, 2c latency (พวกเขามีการพึ่งพาการป้อนข้อมูลใน FLAGS เพราะ count = 0 หมายถึงธงที่ไม่ได้แก้ไขพวกเขาจัดการสิ่งนี้เป็นการพึ่งพาข้อมูลและใช้หลาย uops เพราะ uop สามารถมี 2 อินพุตเท่านั้น (pre-HSW / BDW ต่อไป)) นี่เป็นประเภทที่ผู้คนร้องเรียนเกี่ยวกับการออกแบบบ้า -CISC ของ x86 ที่อ้างถึง มันทำให้ซีพียู x86 ช้ากว่าที่ควรจะเป็นหาก ISA ได้รับการออกแบบตั้งแต่เริ่มต้นจนถึงทุกวันนี้แม้จะคล้ายกันมาก (กล่าวคือนี่เป็นส่วนหนึ่งของ "ภาษี x86" ที่มีค่าความเร็ว / พลังงาน) SHRX / SHLX / SARX (BMI2) ถือเป็นชัยชนะครั้งใหญ่ (1 uop / 1c latency)
นอกจากนี้ยังวาง tzcnt (3c บน Haswell และใหม่กว่า) บนเส้นทางคริติคอลดังนั้นจึงมีความหมายรวมถึงความหน่วงแฝงทั้งหมดของห่วงโซ่การพึ่งพาแบบวนรอบ มันไม่จำเป็นต้องลบสำหรับ CMOV หรือสำหรับการเตรียมการลงทะเบียนถือครองn>>1
ใด ๆ @ คำตอบของ Veedrac จะเอาชนะทุกสิ่งนี้โดยการเลื่อน tzcnt / shift สำหรับการวนซ้ำหลายครั้งซึ่งมีประสิทธิภาพสูง (ดูด้านล่าง)
เราสามารถใช้BSFหรือTZCNTอย่างปลอดภัยแทนกันได้เนื่องจากn
ไม่สามารถเป็นศูนย์ ณ จุดนั้น รหัสเครื่องของ TZCNT ถอดรหัสเป็น BSF บน CPU ที่ไม่รองรับ BMI1 (ส่วนนำหน้าที่ไม่มีความหมายจะถูกข้ามดังนั้น REP BSF จึงทำงานเหมือน BSF)
TZCNT ทำงานได้ดีกว่า BSF บน CPU AMD ที่รองรับดังนั้นจึงเป็นความคิดที่ดีที่จะใช้REP BSF
แม้ว่าคุณจะไม่สนใจการตั้งค่า ZF หากอินพุตเป็นศูนย์แทนที่จะเป็นเอาต์พุต คอมไพเลอร์บางคนทำเช่นนี้เมื่อคุณใช้แม้จะมี __builtin_ctzll
-mno-bmi
พวกมันทำงานบน CPU ของ Intel เหมือนกันดังนั้นเพียงบันทึกไบต์ถ้านั่นคือสิ่งที่สำคัญ TZCNT บน Intel (pre-Skylake) ยังคงมีการพึ่งพาเท็จในตัวถูกดำเนินการเอาต์พุตแบบเขียนอย่างเดียวที่ควรจะเป็นเช่นเดียวกับ BSF เพื่อสนับสนุนพฤติกรรมที่ไม่มีเอกสารที่ BSF ที่มีอินพุต = 0 ทำให้ปลายทางไม่ถูกแก้ไข ดังนั้นคุณต้องหลีกเลี่ยงสิ่งนั้นเว้นแต่ว่าจะปรับให้เหมาะสมสำหรับ Skylake เท่านั้นดังนั้นจึงไม่มีอะไรที่จะได้รับจาก REP พิเศษ (Intel มักจะไปข้างต้นและนอกเหนือสิ่งที่คู่มือ x86 ISA ต้องเพื่อหลีกเลี่ยงการทำลายรหัสใช้กันอย่างแพร่หลายที่ขึ้นอยู่กับบางสิ่งบางอย่างมันไม่ควรหรือที่จะไม่อนุญาตให้มีผลย้อนหลัง. เช่นWindows 9x ของถือว่าไม่มีการโหลดล่วงหน้าเก็งกำไรของรายการ TLBซึ่งเป็นที่ปลอดภัย เมื่อรหัสถูกเขียนขึ้นก่อนที่ Intel จะอัพเดทกฎการจัดการ TLB )
อย่างไรก็ตาม LZCNT / TZCNT บน Haswell มี dep ที่ผิดพลาดเหมือนกับ POPCNT: ดูคำถาม & คำตอบนี้ นี่คือสาเหตุที่เอาต์พุต gsm ของ ascc สำหรับโค้ดของ @ Veedrac คุณเห็นว่าการทำลายโซ่ dep ด้วย xor-zeroingบน register มันกำลังจะใช้เป็นปลายทางของ TZCNT เมื่อไม่ใช้ dst = src เนื่องจาก TZCNT / LZCNT / POPCNT ไม่เคยออกจากปลายทางที่ไม่ได้กำหนดหรือไม่ได้แก้ไขดังนั้นการพึ่งพาเท็จในผลลัพธ์บน CPU ของ Intel จึงเป็นข้อบกพร่อง / ข้อ จำกัด ด้านประสิทธิภาพ สันนิษฐานว่ามันคุ้มค่ากับทรานซิสเตอร์ / พลังงานที่ให้พวกมันทำงานเหมือน uops อื่น ๆ ที่ไปยังหน่วยปฏิบัติการเดียวกัน ข้อเสียที่สมบูรณ์แบบเพียงอย่างเดียวคือการโต้ตอบกับข้อ จำกัด uarch อื่น: พวกเขาสามารถ micro-ฟิวส์ตัวถูกดำเนินการหน่วยความจำด้วยโหมดที่อยู่ดัชนี บน Haswell แต่บน Skylake ที่ Intel ลบ dep ที่ผิดสำหรับ LZCNT / TZCNT พวกเขาจะ "ยกเลิกการลามิเนต" โหมดการทำดัชนีที่อยู่ในขณะที่ POPCNT ยังคงสามารถไมโครโหมด addr ใด ๆ
การปรับปรุงแนวคิด / รหัสจากคำตอบอื่น ๆ :
คำตอบของ @ hidefromkgbมีข้อสังเกตที่ดีว่าคุณรับประกันได้ว่าจะสามารถทำการเปลี่ยนแปลงได้อย่างถูกต้องหลังจาก 3n + 1 คุณสามารถคำนวณได้อย่างมีประสิทธิภาพมากขึ้นกว่าเพียงแค่ออกจากการตรวจสอบระหว่างขั้นตอน การใช้ asm ในคำตอบนั้นแตก แต่ (ขึ้นอยู่กับของซึ่งไม่ได้กำหนดหลังจาก SHRD ด้วยจำนวน> 1) และช้า: ROR rdi,2
เร็วกว่าSHRD rdi,rdi,2
และใช้คำสั่ง CMOV สองคำสั่งบนเส้นทางวิกฤตินั้นช้ากว่าการทดสอบพิเศษ ที่สามารถทำงานแบบขนาน
ฉันใส่ tidied / C ที่ดีขึ้น (ซึ่งแนะนำคอมไพเลอร์ในการผลิต asm ดีกว่า) และการทดสอบการทำงาน + asm เร็วขึ้น (ในความคิดเห็นด้านล่างซี) ขึ้นบน Godbolt: เห็นการเชื่อมโยงในคำตอบ @ hidefromkgb ของ (คำตอบนี้ใช้ขีด จำกัด ถ่าน 30k จาก Godbolt URL ที่มีขนาดใหญ่ แต่Shortlink สามารถเน่าและยาวเกินไปสำหรับ goo.gl ต่อไป)
ปรับปรุงการพิมพ์เอาต์พุตเพื่อแปลงเป็นสตริงและสร้างwrite()
แทนการเขียนทีละตัวอักษร สิ่งนี้จะลดผลกระทบต่อการกำหนดเวลาโปรแกรมทั้งหมดด้วยperf stat ./collatz
(เพื่อบันทึกเคาน์เตอร์วัดประสิทธิภาพ) และฉันไม่สับสนกับ asm ที่ไม่สำคัญ
@ รหัสของ Veedrac
ฉันได้รับความเร็วเล็กน้อยจากการเลื่อนขวาเท่าที่เรารู้ว่าต้องทำและการตรวจสอบเพื่อวนรอบต่อไป จาก 7.5 วินาทีสำหรับ limit = 1e8 ลงไปที่ 7.275s บน Core2Duo (Merom) โดยมีปัจจัยที่ไม่สามารถเลื่อนได้ที่ 16
รหัสเมือง + ความคิดเห็นเกี่ยวกับ Godbolt อย่าใช้เวอร์ชั่นนี้กับเสียงดังกราว มันทำอะไรโง่ ๆ กับ defer-loop การใช้ตัวนับ tmp k
แล้วเพิ่มเข้าไปในcount
ภายหลังจะเปลี่ยนสิ่งที่เสียงดังกราวทำ แต่สิ่งนี้ทำให้เจ็บเล็กน้อย
ดูการสนทนาในความคิดเห็น: รหัสของ Veedrac นั้นยอดเยี่ยมสำหรับซีพียูที่มี BMI1 (เช่นไม่ใช่ Celeron / Pentium)