ขนาด int_fast8_t เทียบกับขนาด int_fast16_t บนแพลตฟอร์ม x86-64

Oct 01 2023

ฉันได้เรียนรู้แล้วว่าบนแพลตฟอร์ม x86-64 ที่ใช้รีจิสเตอร์ 64 บิตใดๆ จำเป็นต้องมีREXคำนำหน้า และที่อยู่ใดๆ ที่น้อยกว่า 64 บิตจะต้องใช้คำนำหน้าขนาดที่อยู่

บน x86-64 บิต:

E3rel8 คือjrcxz

67 E3rel8 คือjecxz

67คือโอปโค้ดสำหรับคำนำหน้าการแทนที่ขนาดที่อยู่

sizeof(int_fast8_t)เป็น 8 บิต ในขณะที่รุ่นอื่นๆsizeof(int_fast16_t)( sizeof(int_fast32_t)เฉพาะบน Linux) เป็น 64 บิต

เหตุใดจึงเป็นเพียงint_fast8_t8 บิต ในขณะที่ typdef ที่เร็วอื่น ๆ เป็น 64 บิต

มันเกี่ยวข้องกับการจัดตำแหน่งหรือเปล่า?

คำตอบ

13 PeterCordes Oct 01 2023 at 21:21

เหตุใด int_fast8_t ถึงมีเฉพาะ 8 บิต ในขณะที่ typdef ที่เร็วอื่นๆ เป็น 64 บิต

เนื่องจาก glibc ตัดสินใจเลือกแบบเรียบง่ายและอาจไม่ดีเมื่อ x86-64 เป็นสิ่งใหม่ในขณะที่ประเภท C99 เหล่านี้เป็นสิ่งใหม่ และตัดสินใจผิดพลาดที่จะไม่ทำให้มันเฉพาะเจาะจงสำหรับ x86-64

ทั้งหมดนี้int_fast16/32/64_tถูกกำหนดให้longครอบคลุมทุกแพลตฟอร์ม ซึ่งดำเนินการในเดือนพฤษภาคม 2542ก่อนที่ AMD64 จะได้รับการประกาศด้วยข้อมูลจำเพาะบนกระดาษ (ตุลาคม 2542) ซึ่งนักพัฒนาอาจใช้เวลาพอสมควรในการทำความเข้าใจ (ขอขอบคุณ @Homer512 สำหรับการค้นหาคอมมิทและประวัติ)

longคือความกว้างของรีจิสเตอร์แบบเต็ม (จำนวนเต็ม) ในระบบ GNU แบบ 32 และ 64 บิตซึ่งก็คือความกว้างของตัวชี้ด้วยเช่นกัน

สำหรับ RISC 64 บิตส่วนใหญ่ ความกว้างเต็มถือเป็นเรื่องธรรมดา แม้ว่าฉันจะไม่รู้เกี่ยวกับความเร็วในการคูณและหารก็ตาม ถือเป็นเรื่องแย่มากสำหรับ x86-64 ที่ขนาดตัวดำเนินการ 64 บิตต้องใช้ขนาดโค้ดเพิ่มเติม แต่ MIPS dadduและadduตัวอย่างเช่น มีขนาดโค้ดเท่ากันและมีประสิทธิภาพที่สันนิษฐานว่าเทียบเท่ากัน (ก่อน x86-64 เป็นเรื่องปกติที่ RISC ABI จะรักษาเครื่องหมาย ประเภทแคบ ที่ขยายเป็น 64 บิตตลอดเวลา เนื่องจากอย่างน้อย MIPS ก็ต้องการสิ่งนั้นสำหรับคำสั่งที่ไม่ใช่การเลื่อน ดูMOVZX ที่ขาดรีจิสเตอร์ 32 บิตไปยังรีจิสเตอร์ 64 บิตสำหรับประวัติเพิ่มเติม)

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

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


ฉันจำได้ว่า MUSL มีตัวเลือกที่ดีกว่าใน x86-64 เช่น ขนาดทุกfastขนาดอาจเป็นขนาดขั้นต่ำ ยกเว้นอาจfast16เป็น 32 บิต หลีกเลี่ยงคำนำหน้าขนาดตัวดำเนินการและเรื่องของรีจิสเตอร์บางส่วน

fastทำให้เกิดคำถามว่า "เร็วสำหรับอะไร" และคำตอบก็ไม่ได้มีขนาดเท่ากันสำหรับทุกกรณีการใช้งาน ตัวอย่างเช่น ในสิ่งที่สามารถแปลงเป็นเวกเตอร์โดยอัตโนมัติด้วย SIMD จำนวนเต็มที่แคบที่สุดเท่าที่จะเป็นไปได้มักจะเป็นจำนวนที่ดีที่สุด เพื่อให้ทำงานได้มากขึ้นสองเท่าต่อคำสั่งเวกเตอร์ 16 ไบต์ ในกรณีนั้น จำนวนเต็มที่มีขนาด 16 บิตก็เพียงพอ หรือเพียงแค่สำหรับพื้นที่แคชในอาร์เรย์ แต่อย่าคาดหวังว่าfastxx_tประเภทจะพิจารณาการแลกเปลี่ยนระหว่าง "ไม่ช้าลงมากเกินไป" เมื่อเทียบกับการประหยัดขนาดในอาร์เรย์

โดยทั่วไป คำสั่งโหลด/จัดเก็บแบบแคบมักจะใช้ได้ใน ISA ส่วนใหญ่ ดังนั้น คุณควรมีintองค์ประกอบint_fastxx_tภายในและอาร์เรย์แบบแคบหากพื้นที่แคชมีความสำคัญ แต่ตัวเลือกของ glibc มักจะไม่ดีแม้แต่สำหรับตัวแปรภายใน


บางทีคนที่ใช้ glibc อาจจะนับเฉพาะคำสั่งเท่านั้น ไม่ได้นับขนาดโค้ด (คำนำหน้า REX) หรือต้นทุนของการคูณและหาร (ซึ่งแน่นอนว่าช้ากว่าสำหรับ 64 บิต เมื่อเทียบกับ 32 บิตหรือแคบกว่า โดยเฉพาะใน CPU AMD64 รุ่นแรกๆ การหารจำนวนเต็มยังคงช้ากว่ามากสำหรับ 64 บิตบน Intel จนกระทั่งถึง Ice Lakehttps://uops.info/และhttps://agner.org/optimize/-

และไม่ได้ดูที่ผลกระทบต่อขนาดของโครงสร้างทั้งทางตรงและเนื่องจากalignof(T) == 8(แม้ว่าขนาดของfastประเภทจะไม่ได้ถูกตั้งค่าใน x86-64 System V ABI ดังนั้นจึงอาจจะดีกว่าที่จะไม่ใช้ประเภทเหล่านี้ที่ขอบเขตของ ABI เช่น โครงสร้างที่เกี่ยวข้องกับ API ของไลบรารี)

ฉันไม่รู้จริงๆ ว่าทำไมพวกเขาถึงทำผิดพลาดร้ายแรงขนาดนั้น แต่การกระทำดังกล่าวทำให้int_fastxx_tประเภทต่างๆ ไร้ประโยชน์สำหรับทุกสิ่งยกเว้นตัวแปรในพื้นที่ (ไม่ใช่โครงสร้างหรืออาร์เรย์ส่วนใหญ่) เพราะว่า x86-64 GNU/Linux เป็นแพลตฟอร์มที่สำคัญสำหรับโค้ดพกพาส่วนใหญ่ และคุณคงไม่อยากให้โค้ดของคุณห่วยตรงนั้น

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


ข้อดีประการหนึ่งของการใช้จำนวนเต็ม 64 บิตคืออาจหลีกเลี่ยงการจัดการกับขยะในส่วนสูงของ reg ที่ขอบเขต ABI (อาร์กิวเมนต์ฟังก์ชันและค่าส่งคืน) แต่โดยปกติแล้วจะไม่มีความสำคัญ เว้นแต่คุณจะต้องขยายให้ถึงความกว้างของตัวชี้เป็นส่วนหนึ่งของโหมดการกำหนดที่อยู่ (ใน x86-64 รีจิสเตอร์ทั้งหมดในโหมดการกำหนดที่อยู่จะต้องมีความกว้างเท่ากัน เช่น[rdi + rdx*4]AArch64 มีโหมดที่[x0, w1 sxt]ขยายเครื่องหมายรีจิสเตอร์ 32 บิตเป็นดัชนีสำหรับรีจิสเตอร์ 64 บิต แต่รูปแบบรหัสเครื่องของ AArch64 ได้รับการออกแบบตั้งแต่ต้น และมาในภายหลังพร้อมกับการมองย้อนกลับไปเมื่อเห็น ISA 64 บิตอื่นๆ ใช้งานจริง)

เช่นarr[ foo(i) ]สามารถหลีกเลี่ยงคำสั่งให้ค่าส่งคืนขยายเป็นศูนย์ได้หากประเภทส่งคืนเติมลงในรีจิสเตอร์ มิฉะนั้น จะต้องมีเครื่องหมายหรือขยายเป็นศูนย์ตามความกว้างของตัวชี้ก่อนจึงจะใช้ในโหมดการกำหนดที่อยู่ได้ด้วยmovหรือmovsxd(32 ถึง 64 บิต) หรือmovzxหรือmovsx(8 หรือ 16 บิตถึง 64 บิต)

หรือด้วยวิธีการที่ x86-64 System V ส่งผ่านและส่งคืนโครงสร้างตามค่าในรีจิสเตอร์สูงสุด 2 ตัว จำนวนเต็มขนาด 64 บิตไม่จำเป็นต้องแกะออก เนื่องจากอยู่ในรีจิสเตอร์โดยตัวมันเองอยู่แล้ว เช่นstruct ( int32_t a,b; }มีทั้งints ที่แกะออกใน RAX ในค่าที่ส่งกลับ ซึ่งต้องทำงานในตัวเรียกเพื่อแกะออกและตัวเรียกเพื่อแกะออกหากใช้ผลลัพธ์จริง ไม่ใช่แค่จัดเก็บการแสดงวัตถุลงในโครงสร้างในหน่วยความจำเท่านั้น (เช่นmov ecx, eaxขยายส่วนต่ำ / เป็นศูนย์ shr rax, 32หรือเพียงแค่add ebx, eaxใช้ส่วนต่ำแล้วทิ้งด้วยการเลื่อน คุณไม่จำเป็นต้องขยายส่วนต่ำเป็นศูนย์เป็น 64 บิตเพื่อใช้เป็นจำนวนเต็มขนาด 32 บิตเพียงอย่างเดียว)

ภายในฟังก์ชัน คอมไพเลอร์จะทราบว่าค่าถูกขยายเป็นศูนย์แล้วเป็น 64 บิต หลังจากเขียนรีจิสเตอร์ 32 บิต และการโหลดจากหน่วยความจำ แม้แต่การขยายเครื่องหมายเป็น 64 บิตก็ยังเป็นอิสระ ( movsxd rax, [rdi]แทนที่จะเป็นmov eax, [rdi]) (หรือเกือบจะเป็นอิสระในซีพียูรุ่นเก่าที่การขยายเครื่องหมายแหล่งหน่วยความจำยังคงต้องใช้ ALU uop ซึ่งไม่ได้ทำเป็นส่วนหนึ่งของการโหลด uop)

เนื่องจากการโอเวอร์โฟลว์ของจำนวนเต็มที่มีเครื่องหมายคือ UB คอมไพเลอร์จึงสามารถขยายint( int32_t) เป็น 64 บิตในลูปเช่นfor (int i = 0 ; i < n ; i++ ) arr[i] += 1;หรือแปลงเป็นการเพิ่มตัวชี้แบบ 64 บิตได้ (ฉันสงสัยว่า GCC อาจจะทำสิ่งนี้ไม่ได้ในช่วงต้นทศวรรษปี 2000 เมื่อมีการตัดสินใจออกแบบซอฟต์แวร์เหล่านี้ ในกรณีนั้น ใช่movsxdคำสั่งที่เสียเปล่าเพื่อขยายตัวนับลูปเป็น 64 บิตต่อไปนั้นถือเป็นการพิจารณาที่น่าสนใจ)

แต่เพื่อให้ยุติธรรม คุณยังคงได้รับคำสั่งส่วนขยายเครื่องหมายจากการใช้ประเภทจำนวนเต็มที่มีเครื่องหมาย 32 บิตในการคำนวณ ซึ่งอาจสร้างผลลัพธ์เชิงลบได้หากคุณใช้คำสั่งเหล่านั้นเพื่อสร้างดัชนีอาร์เรย์ ดังนั้น 64 บิตจึงint_fast32_tหลีกเลี่ยงmovsxdคำสั่งเหล่านี้ โดยแลกกับการที่แย่ลงในกรณีอื่นๆ บางทีฉันอาจลดความสำคัญของสิ่งนี้ลงเพราะฉันรู้ดีว่าต้องหลีกเลี่ยงมัน เช่น ใช้unsignedเมื่อเหมาะสมเพราะฉันรู้ว่าการขยายเป็นศูนย์ฟรีบน x86-64 และ AArch64


สำหรับการคำนวณจริง ขนาดตัวดำเนินการ 32 บิตโดยทั่วไปจะเร็วอย่างน้อยเท่ากับสิ่งอื่นๆ รวมถึง imul/div และ popcntและหลีกเลี่ยงค่าปรับการลงทะเบียนบางส่วนหรือmovzxคำสั่งพิเศษที่คุณได้รับด้วย 8 บิตหรือ 16 บิต

  • ข้อดีของการใช้รีจิสเตอร์/คำสั่ง 32 บิตใน x86-64
  • เหตุใดขนาดตัวดำเนินการเริ่มต้นจึงเป็น 32 บิตในโหมด 64 - 32 บิตไม่จำเป็นต้องใช้ REX หรือคำนำหน้าขนาดตัวดำเนินการ

แต่ 8 บิตก็ไม่เลว และถ้าตัวเลขของคุณเล็กขนาดนั้น การเพิ่มเป็น 32 หรือ 64 บิตก็ยิ่งแย่เข้าไปอีก โปรแกรมเมอร์น่าจะคาดหวังให้ตัวเลขint_fast8_tเล็กกว่านี้ เว้นแต่ว่าจะ ทำให้ตัวเลขใหญ่ขึ้น มากตัวเลขนี้ไม่ได้อยู่ใน x86-64 มีซีพียูสมัยใหม่ตัวใดที่การจัดเก็บไบต์แบบแคชนั้นช้ากว่าการจัดเก็บคำหรือไม่ - ใช่ เห็นได้ชัดว่าไม่ใช่ x86 ส่วนใหญ่ แต่ x86 ทำให้ไบต์และคำขนาด 16 บิตเร็วขึ้นทั้งในการโหลด/จัดเก็บและการคำนวณ

การหลีกเลี่ยง 16 บิตนั้นอาจจะดี คุ้มกับต้นทุนของ 2 ไบต์เพิ่มเติมในบางกรณีadd ax, 12345(และคำสั่ง imm16 อื่นๆ) ทำให้การถอดรหัส LCP หยุดชะงักบน CPU ของ Intel นอกจากนี้ การลงทะเบียนบางส่วนยังทำให้เกิดการหยุดชะงักของการรวมข้อมูล (หรือบน CPU รุ่นเก่ากว่า)


jrcxzvs. jecxzเป็นตัวอย่างที่แปลกเพราะใช้ คำนำ 67h หน้าที่อยู่ -size แทน66hตัวดำเนินการ-size และเพราะคอมไพเลอร์ไม่เคยใช้เลย (?) มันไม่ช้าเท่าloopคำสั่งแต่ที่น่าแปลกใจคือมันไม่ช้าเท่ากับ uop เดียว แม้แต่บน CPU ของ Intel ที่สามารถรวม a test/jzเป็น uop เดียวได้