日本免费高清视频-国产福利视频导航-黄色在线播放国产-天天操天天操天天操天天操|www.shdianci.com

學無先后,達者為師

網站首頁 編程語言 正文

Go?內聯優化讓程序員愛不釋手_Golang

作者:??煎魚eddycjy???? ? 更新時間: 2022-08-12 編程語言

前言:

這是一篇介紹 Go 編譯器如何實現內聯的文章,以及這種優化將如何影響你的 Go 代碼。

什么是內聯?

內聯是將較小的函數合并到它們各自的調用者中的行為。其在不同的計算歷史時期的做法不一樣,如下:

  • 早期:這種優化通常是由手工完成的。
  • 現在:內聯是在編譯過程中自動進行的一類基本優化之一。

為什么內聯很重要?

內聯是很重要的,每一門語言都必然會有。

具體的原因如下:

  • 它消除了函數調用本身的開銷。
  • 它允許編譯器更有效地應用其他優化策略。

核心來講,就是性能更好了。

函數調用的開銷

基本知識

在任何語言中調用一個函數都是有代價的。將參數編入寄存器或堆棧(取決于ABI),并在返回時反轉這一過程,這些都是開銷。

調用一個函數需要將程序計數器從指令流中的一個點跳到另一個點,這可能會導致流水線停滯。一旦進入函數,通常需要一些前言來為函數的執行準備一個新的堆棧框架,在返回調用者之前,還需要一個類似的尾聲來退掉這個框架。

Go 中的開銷

在 Go 中,一個函數的調用需要額外的成本來支持動態堆棧的增長。在進入時,goroutine 可用的堆棧空間的數量與函數所需的數量進行比較。

如果可用的堆棧空間不足,序言就會跳轉到運行時邏輯,通過將堆棧復制到一個新的、更大的位置來增加堆棧。

一旦這樣做了,運行時就會跳回到原始函數的起點,再次進行堆棧檢查,現在通過了,然后繼續調用。通過這種方式,goroutines可以從一個小的堆棧分配開始,只有在需要時才會增加。

這種檢查很便宜,只需要幾條指令,而且由于goroutine的堆棧以幾何級數增長,檢查很少失敗。因此,現代處理器中的分支預測單元可以通過假設堆棧檢查總是成功來隱藏堆棧檢查的成本。在處理器錯誤預測堆棧檢查并不得不丟棄它在投機執行時所做的工作的情況下,與運行時增長goroutine堆棧所需的工作成本相比,管道停滯的成本相對較小。

Go 里的優化

雖然每個函數調用的通用組件和 Go 特定組件的開銷被使用投機執行技術的現代處理器很好地優化了,但這些開銷不能完全消除,因此每個函數調用都帶有性能成本,超過了執行有用工作的時間。由于函數調用的開銷是固定的,較小的函數相對于較大的函數要付出更大的代價,因為它們每次調用的有用工作往往較少。

因此,消除這些開銷的解決方案必須是消除函數調用本身,Go 編譯器在某些條件下通過用函數的內容替換對函數的調用來做到這一點。這被稱為內聯,因為它使函數的主體與它的調用者保持一致。

改善優化的機會

Cliff Click 博士將內聯描述為現代編譯器進行的優化,因為它是常量傳播和死代碼消除等優化的基礎。

實際上,內聯允許編譯器看得更遠,允許它在特定函數被調用的情況下,觀察到可以進一步簡化或完全消除的邏輯。

由于內聯可以遞歸應用,優化決策不僅可以在每個單獨的函數的上下文中做出,還可以應用于調用路徑中的函數鏈。

進行內聯優化

不允許內聯

內聯的效果可以通過這個小例子來證明:

package main
import "testing"
//go:noinline
func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}
var Result int
func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(-1, i)
    }
    Result = r
}

運行這個基準可以得到以下結果:

% go test -bench=.?
BenchmarkMax-4 ? 530687617 ? ? ? ? 2.24 ns/op

從執行結果來看,max(-1, i)的成本大約是 2.24ns,感覺性能不錯。

允許內聯

現在讓我們去掉?//go:noinline pragma?的語句,再看看不允許內聯的情況下,性能是否會改變。

如下結果:

% go test -bench=.?
BenchmarkMax-4 ? 1000000000 ? ? ? ? 0.514 ns/op

兩個結果對比一看,2.24ns 和 0.51ns。差距至少一倍以上,根據 benchstat 的建議,內聯情況下,性能提高了 78%。

如下結果:

% benchstat {old,new}.txt
name ? old time/op ?new time/op ?delta
Max-4 ?2.21ns ± 1% ?0.49ns ± 6% ?-77.96% ?(p=0.000 n=18+19)

這些改進從何而來?

首先,取消函數調用和相關的前導動作是主要的改進貢獻者。其將 max 函數的內容拉到它的調用者中,減少了處理器執行的指令數量,并消除了幾個分支。

現在 max 函數的內容對編譯器來說是可見的,當它優化 BenchmarkMax 時,它可以做一些額外的改進。

考慮到一旦 max 被內聯,BenchmarkMax 的主體對編譯器而言就會有所改變,與用戶端看到的并不一樣。

如下代碼:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if -1 > i {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

再次運行基準測試,我們看到我們手動內聯的版本與編譯器內聯的版本表現一樣好。

如下結果:

% benchstat {old,new}.txt
name ? old time/op ?new time/op ?delta
Max-4 ?2.21ns ± 1% ?0.48ns ± 3% ?-78.14% ?(p=0.000 n=18+18)

現在,編譯器可以獲得 max 內聯到 BenchmarkMax 的結果,它可以應用以前不可能的優化方法。

例如:編譯器注意到 i 被初始化為 0,并且只被遞增,所以任何與 i 的比較都可以假定 i 永遠不會是負數。因此,條件?-1 > i?將永遠不會為真。

在證明了?-1 > i?永遠不會為真之后,編譯器可以將代碼簡化為:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if false {  // 注意已為 false
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

并且由于該分支現在是一個常數,編譯器可以消除無法到達的路徑,只留下如下代碼:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = i
    }
    Result = r
}

通過內聯和它所釋放的優化,編譯器已經將表達式?r = max(-1, i)?簡化為?r = i

這個例子非常不錯,很好的體現了內聯的優化過程和性能提升的緣由。

內聯的限制

在這篇文章中,討論了所謂的葉子內聯:將調用棧底部的一個函數內聯到其直接調用者中的行為。

內聯是一個遞歸的過程,一旦一個函數被內聯到它的調用者中,編譯器就可能將產生的代碼內聯到它的調用者中,依此類推。

例如如下代碼:

func BenchmarkMaxMaxMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(max(-1, i), max(0, i))
    }
    Result = r
}

該運行速度將會和前面的例子一樣快,因為編譯器能夠反復應用上面的優化,將代碼減少到相同的?r = i?表達式。

總結

這篇文章針對內聯進行了基本的概念介紹和分析,并且通過 Go 的例子進行了一步步的剖析,讓大家對真實案例有了一個更貼切的理解。

Go 編譯器的優化總是無處不在的。

原文鏈接:https://juejin.cn/post/7111176199174357023

相關推薦

欄目分類
最近更新