C++尾調用優化
- 什么是尾調用?
- 描述
- 無返回值函數最后調用函數也可能做尾調用優化
- 例子
- 關鍵特征(寫法)
- 尾調用和尾遞歸的區別?
- 為什么尾調用優化可以提高效率?
- 通常的遞歸調用:
- 尾調用優化:
- 為什么棧幀復用就可以提高效率
- 函數調用和尾調用優化避免的開銷
- 棧空間分配
- 棧幀入棧
- 內存訪問與緩存
- 如何判斷編譯器是否做了尾調用優化?
- 代碼示例
尾調用優化(Tail Call Optimization, 簡稱 TCO)是現代編譯器中一項重要的優化技術,它能在某些條件下避免函數調用時的棧增長,從而減少運行時內存開銷,提高程序性能。
本文回答了以下幾個問題:
- 什么是尾調用?
- 尾調用和尾遞歸的區別?
- 為什么尾調用優化可以提高效率?
- 如何判斷編譯器是否做了尾調用優化?
【一句話】
函數調用有棧增長的開銷,尾調用優化省去了函數調用入棧的開銷。
什么是尾調用?
描述
尾調用是指:一個函數在“最后一步”調用另一個函數,并將其返回值直接返回。
【補充】
無返回值函數最后調用函數也可能做尾調用優化
如果函數A是無返回值,只要函數A在最后調用函數B,最后指的是調用函數B后沒有其他操作,那編譯器也是有可能會做尾調用優化的。
因為尾調用的關鍵在于函數A調用函數B后,還需不需要用到函數A中的信息,如果不需要再用了,那么也就沒有了將函數A相關信息入棧的必要,也就能直接復用當前的棧幀了。
例子
int foo(int x) {return bar(x); // ← 這是一個尾調用
}
void foo_(int x) {bar(x); // ← 這也是一個尾調用
}
關鍵特征(寫法)
調用另一個函數之后,不再進行其他操作,直接返回。
尾調用和尾遞歸的區別?
尾遞歸就是尾調用中最后一個函數是調用自己,形成遞歸。
尾遞歸優化,編譯器實際上可能把遞歸函數轉換為循環實現。
// 原始尾遞歸
int sum(int n, int acc = 0) {if (n == 0) return acc;return sum(n - 1, acc + n); // 尾遞歸調用
}// 優化后(編譯器可能轉為循環)
int sum(int n, int acc = 0) {while (n > 0) {acc += n;n--;}return acc;
}
為什么尾調用優化可以提高效率?
通常的遞歸調用:
每調用一次函數,就在棧上分配一個新棧幀來保存局部變量和返回地址。
尾調用優化:
編譯器可以直接復用當前棧幀來執行下一個函數調用,避免了棧幀的增長。
為什么棧幀復用就可以提高效率
首先我們需要明白函數調用時發生了什么,知道了棧幀生成的開銷,才能知道為什么棧幀復用可以提高效率。
函數調用和尾調用優化避免的開銷
棧空間分配
每次函數調用,系統都會為該調用分配一個新的棧幀(stack frame),用來保存局部變量、返回地址、參數、寄存器狀態等信息。函數返回時,這個棧幀會被銷毀。
【尾調用優化】
譯器做了尾調用優化,就可以復用當前函數的棧幀,直接跳轉到被調用函數,而不再分配新的棧幀。這樣就避免了頻繁分配和釋放棧幀的開銷。
棧幀入棧
棧空間分配后,需要把棧幀壓入棧中,而在遞歸調用時,很容易出現深度過大導致的棧溢出。
【尾調用優化】
尾調用優化通過復用棧幀,使得遞歸調用不再增加棧深度,相當于變成了循環,極大降低了棧空間需求。
內存訪問與緩存
棧幀的分配和釋放涉及內存操作,雖然CPU有多級緩存,但頻繁的內存訪問仍然影響性能。
【尾調用優化】
棧幀頻繁分配釋放會帶來內存操作,增加緩存失效風險,復用棧幀則降低了內存訪問壓力,有助于提升CPU緩存命中率,進一步提升性能。
如何判斷編譯器是否做了尾調用優化?
我們可以通過查看生成的匯編代碼來判斷是否進行了優化。
生成匯編的方法可以看看我的這篇博客C++中switch-case的性能優化策略詳解
代碼示例
int bar(int x) {return x * 2156 + 15484;
}int foo(int x) {x++;return bar(x * 5);
}
x86-64 gcc 編譯,不開啟優化
"_Z3bari":push rbpmov rbp, rspmov DWORD PTR [rbp-4], edimov eax, DWORD PTR [rbp-4]imul eax, eax, 2156add eax, 15484pop rbpret
"_Z3fooi":push rbpmov rbp, rspsub rsp, 8mov DWORD PTR [rbp-4], ediadd DWORD PTR [rbp-4], 1mov edx, DWORD PTR [rbp-4]mov eax, edxsal eax, 2add eax, edxmov edi, eaxcall "_Z3bari" ; 注意在沒有開啟優化的情況下,是直接通過call指令調用函數,而這就會涉及到上一節講的函數調用的開銷。leaveret
開啟-O2優化后
【注意】
這里有一點要提及,編譯器有可能會做內聯優化,這是另一種優化手段,但是本文想討論的是尾調用優化,在函數體過于簡單的情況下(例如本文提供的案例),編譯器更傾向于使用內聯優化,因此為了避免內聯優化,我們必須對函數做一點修改,變成以下的樣式,來明確規定不允許內聯。// 增加__attribute__((noinline)) 明確告知編譯器不用內聯【注意,這個標記是GCC和Clang支持的,MSVC或者其他編譯器可能有不一樣的標記】 __attribute__((noinline)) int bar(int x) {return x * 2156 + 15484; }int foo(int x) {x++;return bar(x * 5); // 這是一個“尾調用”! }
"_Z3bari":imul eax, edi, 2156add eax, 15484ret
"_Z3fooi":lea edi, [rdi+5+rdi*4]jmp "_Z3bari" ; 直接跳轉而非 call ? 沒有新棧幀產生
使用了jmp而不是call,說明這里棧幀復用,TCO 生效。