ใน C ++ เพราะเหตุใดฟังก์ชั่นเสมือนจึงทำงานช้าลงได้อย่างไร


38

ทุกคนสามารถอธิบายในรายละเอียดได้อย่างไรว่าตารางเสมือนจริงทำงานอย่างไรและตัวชี้ใดที่เชื่อมโยงกันเมื่อเรียกใช้ฟังก์ชันเสมือน

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


5
การค้นหาเมธอดที่ถูกต้องจากvtableนั้นเห็นได้ชัดว่าต้องใช้เวลานานกว่าการเรียกเมธอดโดยตรงเนื่องจากมีสิ่งที่ต้องทำมากกว่า อีกนานเท่าไรหรือว่าเวลาเพิ่มเติมนั้นสำคัญในบริบทของโปรแกรมของคุณเองก็เป็นอีกคำถามหนึ่ง en.wikipedia.org/wiki/Virtual_method_table
Robert Harvey

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

7
บ่อยครั้งที่การโทรเสมือนนั้นไม่ช้า แต่คอมไพเลอร์ไม่มีความสามารถในการอินไลน์
Kevin Hsu

4
@ เควิน Hsu: ใช่นี่มันเป็นอย่างแน่นอน เกือบทุกครั้งที่มีคนบอกคุณว่าพวกเขามีความเร็วเพิ่มขึ้นจากการกำจัด "การเรียกใช้ฟังก์ชันเสมือน" บางส่วนถ้าคุณดูที่การเร่งความเร็วทั้งหมดมาจากความเป็นจริงจะมาจากการปรับให้เหมาะสมซึ่งตอนนี้เป็นไปได้ การโทรไม่แน่นอนก่อนหน้านี้
timday

7
แม้แต่คนที่สามารถอ่านรหัสแอสเซมบลีไม่สามารถทำนายค่าใช้จ่ายได้อย่างถูกต้องในการดำเนินการ CPU จริง ผู้ผลิตซีพียูที่ทำงานบนเดสก์ท็อปได้ลงทุนในการวิจัยมานานหลายทศวรรษไม่เพียง แต่การคาดการณ์ของสาขา แต่ยังให้ความสำคัญกับการทำนายและการดำเนินการเก็งกำไรด้วยเหตุผลหลักในการซ่อนเร้นของฟังก์ชันเสมือน ทำไม? เนื่องจากระบบปฏิบัติการและซอฟต์แวร์บนเดสก์ท็อปใช้พวกเขาอย่างมาก (ฉันจะไม่พูดเหมือนกันเกี่ยวกับซีพียูมือถือ)
rwong

คำตอบ:


55

เมธอดเสมือนถูกนำมาใช้โดยทั่วไปผ่านทางเมธอดเสมือนที่เรียกว่าตาราง (vtable สำหรับช่วงสั้น ๆ ) ซึ่งตัวชี้ฟังก์ชันจะถูกเก็บไว้ สิ่งนี้จะเพิ่มทางอ้อมให้กับการโทรจริง (ต้องดึงที่อยู่ของฟังก์ชั่นที่จะโทรจาก vtable จากนั้นเรียกมันว่า - ตรงข้ามกับการโทรไปข้างหน้าทันที) แน่นอนว่าต้องใช้เวลาพอสมควรและต้องใช้รหัสมากขึ้น

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

ใหญ่ แต่ : ความนิยมในการแสดงเหล่านี้มักน้อยเกินไปสำหรับเรื่อง สิ่งเหล่านี้ควรพิจารณาหากคุณต้องการสร้างรหัสประสิทธิภาพสูงและพิจารณาเพิ่มฟังก์ชั่นเสมือนที่จะถูกเรียกใช้ที่ความถี่ที่น่าตกใจ แต่ยังเก็บไว้ในใจว่าเปลี่ยนสายงานเสมือนกับวิธีการอื่น ๆ ของการแยก ( if .. else, switchตัวชี้ฟังก์ชั่นอื่น ๆ ) จะไม่แก้ปัญหาพื้นฐาน - มันเป็นอย่างดีอาจจะช้า ปัญหา (ถ้ามีอยู่เลย) ไม่ใช่ฟังก์ชั่นเสมือน แต่ใช้ทางอ้อม (ไม่จำเป็น)

แก้ไข: ความแตกต่างในคำแนะนำการโทรอธิบายไว้ในคำตอบอื่น ๆ โดยทั่วไปโค้ดสำหรับการโทรแบบสแตติก ("ปกติ") คือ:

  • คัดลอกรีจิสเตอร์บางตัวบนสแต็กเพื่ออนุญาตให้ฟังก์ชันที่เรียกใช้ใช้รีจิสเตอร์เหล่านั้น
  • คัดลอกอาร์กิวเมนต์ไปยังตำแหน่งที่กำหนดไว้ล่วงหน้าเพื่อให้ฟังก์ชันที่เรียกสามารถค้นหาได้โดยไม่คำนึงถึงตำแหน่งที่เรียกใช้
  • กดที่อยู่ผู้ส่ง
  • Branch / ข้ามไปยังโค้ดของฟังก์ชันซึ่งเป็นแอดเดรสคอมไพล์และด้วยเหตุนี้ฮาร์ดโค้ดในไบนารี่โดยคอมไพเลอร์ / ลิงเกอร์
  • รับค่าส่งคืนจากตำแหน่งที่กำหนดไว้ล่วงหน้าและคืนค่าการลงทะเบียนที่เราต้องการใช้

การเรียกเสมือนทำสิ่งเดียวกันทุกประการยกเว้นว่าไม่ทราบที่อยู่ฟังก์ชัน ณ เวลารวบรวม คำแนะนำสองสามข้อ ...

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

สำหรับสาขา: สาขาคือสิ่งที่ข้ามไปยังคำสั่งอื่นแทนที่จะปล่อยให้คำสั่งถัดไปดำเนินการ ซึ่งรวมถึงif,, switchชิ้นส่วนของลูปต่างๆ, การเรียกใช้ฟังก์ชั่น, ฯลฯ และบางครั้งคอมไพเลอร์ใช้สิ่งที่ดูเหมือนจะไม่แตกกิ่งก้านสาขาในแบบที่ต้องการสาขาภายใต้ประทุน ดูเหตุใดการประมวลผลอาร์เรย์ที่เรียงลำดับจึงเร็วกว่าอาร์เรย์ที่ไม่เรียงลำดับ เพราะเหตุใดสิ่งนี้จึงอาจช้าสิ่งที่ซีพียูทำเพื่อต่อต้านการชะลอตัวนี้และวิธีนี้ไม่ใช่วิธีแก้ทั้งหมด


6
@ JörgWMittagพวกมันเป็นล่ามทุกอย่างและพวกมันก็ยังช้ากว่ารหัสไบนารี่ที่สร้างโดยคอมไพเลอร์ C ++
Sam

13
@ JörgWMittagการเพิ่มประสิทธิภาพเหล่านี้มีอยู่เป็นหลักในการทำให้มีผลผูกพันทางอ้อม / ปลาย (เกือบ) ฟรีเมื่อไม่จำเป็นเพราะในภาษาเหล่านั้นการโทรทุกครั้งจะถูก จำกัด ทางเทคนิค หากคุณเรียกใช้วิธีเสมือนจริงที่แตกต่างกันจำนวนมากจากที่เดียวในช่วงเวลาสั้น ๆ การเพิ่มประสิทธิภาพเหล่านี้จะไม่ช่วยหรือทำร้ายอย่างแข็งขัน (สร้างรหัสจำนวนมากสำหรับสิ่งใด) พวก C ++ ไม่สนใจในการปรับแต่งเหล่านี้เพราะพวกเขาอยู่ในสถานการณ์ที่แตกต่างกันมาก ...

10
@ JörgWMittag ... พวก C ++ ไม่สนใจในการปรับแต่งเหล่านี้มากนักเพราะพวกมันอยู่ในสถานการณ์ที่แตกต่างกันมาก: วิธีการรวบรวมวีดิโอ AOT ที่รวบรวมไว้นั้นค่อนข้างเร็วอยู่แล้วการโทรน้อยมากนั้นเป็นเสมือนจริง ผูกพัน (ผ่านเทมเพลต) และสามารถแก้ไขได้เพื่อปรับให้เหมาะสม AOT ในที่สุดการทำการปรับให้เหมาะสมเหล่านี้แบบปรับเปลี่ยนได้ (แทนที่จะเป็นเพียงการเก็งกำไรในเวลาคอมไพล์) ต้องใช้การสร้างรหัสรันไทม์ซึ่งทำให้เกิดอาการปวดหัวเป็นตัน คอมไพเลอร์ของ JIT ได้แก้ไขปัญหาเหล่านั้นแล้วด้วยเหตุผลอื่น ๆ ดังนั้นพวกเขาจึงไม่รังเกียจ แต่คอมไพเลอร์ AOT ต้องการหลีกเลี่ยง

3
คำตอบที่ดี +1 สิ่งหนึ่งที่ควรทราบคือบางครั้งผลลัพธ์ของการแบรนช์เป็นที่รู้จักในเวลาคอมไพล์เช่นเมื่อคุณเขียนคลาสเฟรมเวิร์กที่ต้องการสนับสนุนการใช้งานที่แตกต่างกัน แต่เมื่อโค้ดแอปพลิเคชันโต้ตอบกับคลาสเหล่านั้น ในกรณีนี้ทางเลือกของฟังก์ชั่นเสมือนอาจเป็นเทมเพลต C ++ ตัวอย่างที่ดีคือ CRTP ซึ่งเลียนแบบพฤติกรรมของฟังก์ชันเสมือนโดยไม่มี vtables: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM

3
@ James คุณมีประเด็น สิ่งที่ผมพยายามจะบอกก็คือ: ร้ายใด ๆ virtualที่มีปัญหาเดียวกันก็ไม่มีอะไรที่เฉพาะเจาะจงเพื่อ

23

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

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

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

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

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


1
สิ่งที่ต้องทำความเข้าใจคือการค้นหา vtable และการโทรทางอ้อมในเกือบทุกกรณีมีผลกระทบเล็กน้อยต่อเวลาการทำงานทั้งหมดของวิธีการที่เรียก
John R. Strohm

11
@ JohnR.Strohm เล็กน้อยของชายคนหนึ่งเป็นคอขวดของคนอื่น
James

1
-0x8(%rbp). โอ้ ... ไวยากรณ์ของ AT&T
Abyx

" สามคำแนะนำเพิ่มเติมว่า" ไม่เพียงสอง: โหลด vptr และโหลดตัวชี้ฟังก์ชัน
curiousguy

@currigy ในความเป็นจริงมันเป็นสามคำแนะนำเพิ่มเติม คุณลืมว่าวิธีเสมือนถูกเรียกบนตัวชี้เสมอดังนั้นคุณต้องโหลดตัวชี้ไปยังการลงทะเบียนก่อน ในการสรุปขั้นตอนแรกคือการโหลดที่อยู่ที่ตัวแปรตัวชี้ถือไว้ใน register% rax จากนั้นตามที่อยู่ในการลงทะเบียนโหลด vtpr บนที่อยู่นี้เพื่อลงทะเบียน% rax จากนั้นตามที่อยู่นี้ใน ลงทะเบียนโหลดที่อยู่ของวิธีการที่จะเรียกเข้าไปใน% rax จากนั้นเรียก callq *% rax!
Gab 是好人

18

ช้ากว่าสิ่งที่ ?

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

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

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

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

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


4
"ช้ากว่าอะไร" - หากคุณสร้างวิธีการเสมือนจริงที่ไม่จำเป็นต้องใช้คุณมีสื่อการเปรียบเทียบที่ดี
tdammers

2
ขอบคุณที่ชี้ให้เห็นว่าการโทรไปยังฟังก์ชั่นเสมือนไม่ได้เป็นแบบไดนามิกเสมอไป การตอบสนองอื่น ๆ ที่นี่ทำให้ดูเหมือนว่าการประกาศฟังก์ชั่นเสมือนหมายถึงการเข้าชมโดยอัตโนมัติโดยไม่คำนึงถึงสถานการณ์
Syndog

12

เพราะการโทรเสมือนนั้นเทียบเท่า

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

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

นอกจากนี้ยังช่วยให้อินไลน์ฟังก์ชั่น (ด้วยผลการเพิ่มประสิทธิภาพทั้งหมดเนื่องจาก)

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