C++性能榨汁機之虛函數的開銷
來源 http://irootlee.com/juicer_vtable/
虛函數的實現
雖然C++標准並沒有規定編譯器實現虛函數的方式,但是大部分編譯器均是采用了虛函數表來實現虛函數,即對於每一個包含虛成員函數的類生成一個虛函數表,一個指向虛函數表的指針被放在對象的首地址(不考慮多繼承等復雜情況),虛函數表中存儲該類所有的虛函數地址。當使用引用或者指針調用虛函數時,首先通過虛函數表指針找到虛函數表,然后通過偏移量找到虛函數地址並調用。關於虛函數表的更多細節,建議閱讀《深度探索C++對象模型》這本書。
虛函數表面上的開銷
-
空間開銷
首先,由於需要為每一個包含虛函數的類生成一個虛函數表,所以程序的二進制文件大小會相應的增大;其次,對於包含虛函數的類的實例來說,每個實例都包含一個虛函數表指針用於指向對應的虛函數表,所以每個實例的空間占用都增加一個指針大小(32位系統4字節,64位系統8字節)。這些空間開銷可能會造成緩存的不友好,在一定程度上影響程序性能。
-
時間開銷
虛函數的時間開銷主要是增加了一次內存尋址,通過虛函數表指針找到虛函數表,雖對程序性能有一些影響,但是影響並不大。
虛函數隱藏在背后的開銷
上述虛函數表面上的開銷其實是微不足道的,真正影響虛函數性能的是隱藏在背后的,不被人輕易察覺的,只有對計算機體系結構有一定理解才能探尋出藏在背后的“性能殺手”。
首先我們先看調用虛函數時,在匯編層生成了什么代碼:
1 |
...
movq (%rax), %rax
movq (%rax), %rax
movq -24(%rbp), %rdx
movq %rdx, %rdi
call *%rax
... |
上述匯編代碼最重要的就是第6行,在AT&T格式匯編中,這是一個間接調用,意義是從%rax指明的地址處讀取跳轉的目標位置。這也是虛函數調用與普通成員函數的區別所在,普通函數調用是一個直接調用。直接調用與間接調用的區別就是跳轉地址是否確定,直接調用的跳轉地址是編譯器確定的,而間接調用是運行到該指令時從寄存器中取出地址然后跳轉。
有了上面的基本認識,我們就可以分析虛函數的性能開銷所在了,其實說到底,這個隱藏在背后的關鍵點就是分支預測器,如果看過我之前的博客,相信對分支預測器已經很熟悉了,如果感覺分支預測器還是很陌生,推薦閱讀我以前的分支預測器的四篇文章:
有了分支預測器和CPU指令流水線的基本知識,我們可以發現對於直接調用而言,是不存在分支跳轉的,因為跳轉地址是編譯器確定的,CPU直接去跳轉地址取后面的指令即可,不存在分支預測,這樣可以保證CPU流水線不被打斷。而對於間接尋址,由於跳轉地址不確定,所以此處會有多個分支可能,這個時候需要分支預測器進行預測,如果分支預測失敗,則會導致流水線沖刷,重新進行取指、譯碼等操作,對程序性能有很大的影響。
網上有部分文章中說對於虛函數這種間接跳轉會直接導致流水線沖刷,這種說法明顯是自相矛盾的,如果間接跳轉必定會導致流水線沖刷,那把這些指令放進流水線的意義何在呢?其實查閱資料就可以知道,Intel和AMD的CPU中存在兩級自適應預測器用於預測間接跳轉,此預測器可以預測多分支跳轉。
總結
本文探究出影響到虛函數調用性能的背后原因是流水線和分支預測,由於虛函數調用需要間接跳轉,所以會導致虛函數調用比普通函數調用多了分支預測的過程,產生性能差距的原因主要是分支預測失敗導致的流水線沖刷性能開銷。
本文的目的並不是為了說明虛函數調用有額外開銷而讓大家避免使用虛函數,使用不使用虛函數應該由自己程序的需要而定,如果程序邏輯需要使用動態綁定,如果不使用虛函數而是自己實現相應邏輯的話產生的性能損耗一般會比使用虛函數的性能損耗大得多。但對於一些性能敏感的程序,在虛函數可用可不用的時候,可以考慮不使用虛函數以提高性能。
================== End