還咋優化?我是說 Go 程序

Go 語言是一個極容易上手的語言,而且 Go 程序的優化套路基本上被大家莫得清清楚楚的,如果你有心,可以在互聯網上搜出很多 Go 程序優化的技巧,有些文章可能只介紹了幾個優化點,有些文章從 CPU 的架構到 Slice 預分配,再到通過 pprof 找性能的瓶頸點等等全面介紹 Go 程序的優化,所以說可見的手段基本上被大家摸得門清,最近老貘出了一道題,如下所示,可以看到大家對 Go 語言的優化已經摸的多深了。

const N = 1000
var a [N]int

//go:noinline
func g0(a *[N]int) {
 for i := range a {
  a[i] = i // line 12
 }
}

//go:noinline
func g1(a *[N]int) {
 _ = *a // line 18
 for i := range a {
  a[i] = i // line 20
 }
}

Go 官方也沒閒着。雖然 Go 語言創立之初也並沒有目標要和 C++ 語言打平性能,但是 Go 團隊對 Go 語言的編譯和運行時優化也一直在進行着。

最近,Go 語言也正在新加兩個性能優化的特性,一個是 cmd/compile: profile-guided optimization[1], 這個提案 [2] 已經被接受, 後續功能初步成型後我們再介紹。另外一個增加 memory arena[3]。

除了大家常見的通用語言的優化外,影響 Go 程序性能最大的問題之一就是垃圾回收,所以使用 C++、Rust 開發的程序員 diss Go 程序的原因之一。不過這也是垃圾回收編程語言無法繞開的特性,基本上無可避免的帶有 STW 的開銷,即使沒有 STW, 垃圾回收時也會耗資源進行對象的便利和檢查,所以理論上來說 Go 性能相比較 C+/Rust 語言性能總會差一些,除非你禁用垃圾回收、純粹做 CPU 計算。

Debian 的 benchmark's game 網站測試和公佈了好多語言的一些場景的性能比較,比如下面這個是 Rust 和 Go 的幾個實現版本的性能比較:

可以看到在這個二叉樹的場景下 Go 的性能比 Rust 的也差很多。不過性能最好的 Rust 實現使用arena的內存分配:

use bumpalo::Bump;
use rayon::prelude::*;

#[derive(Debug, PartialEq, Clone, Copy)]
struct Tree<'a> {
    left: Option<&'a Tree<'a>>,
    right: Option<&'a Tree<'a>>,
}

fn item_check(tree: &Tree) -> i32 {
    if let (Some(left), Some(right)) = (tree.left, tree.right) {
        1 + item_check(right) + item_check(left)
    } else {
        1
    }
}

fn bottom_up_tree<'r>(arena: &'r Bump, depth: i32) -> &'r Tree<'r> {
    let tree = arena.alloc(Tree { left: None, right: None });
    if depth > 0 {
        tree.right = Some(bottom_up_tree(arena, depth - 1));
        tree.left = Some(bottom_up_tree(arena, depth - 1));
    }
    tree
}
...

arena 是一個內存池的技術,一般來說 arena 會創建一個大的連續內存塊,該內存塊只需要預先分配一次,在這塊內存上的創建和釋放都是手工執行的。

Go 語言準備新加入 arena 的功能,並在標準庫提供一個新的包: arena。當前這個提案 [4] 還是 holding 的狀態,但是相關的代碼已經陸陸續續地提到 master 分支了,所以說配批准也基本跑不了了,應該在 Go 1.20,也就是明年春季的版本中嘗試使用了。(當然也有開發者對 Go 的這種做法不滿,因爲外部開發者提出這種想法基本上被駁回或者不被關注,而 Go 團隊的人有這想法就可以立馬實現,甚至提案還沒批准)。

arena當前提供了幾個方法:

當前還沒有實現MakeMapMakeChan這樣在 Arena 上創建 map 和 channel 的方法,後續可能會加上。

arena 的功能爲一組 Go 對象創建一塊內存,手工整體一次性的釋放,可以避免垃圾回收。畢竟,我們也提到了,垃圾回收是 Go 程序的最大的性能殺手之一。

官方建議在批量創建大量的 Go 對象的時候,每次能以 Mib 分配內存的場景下使用更有效,甚至他們找到了一個場景:protobuf 的反序列化。

因爲涉及到垃圾回收、內存分配的問題,所以這個功能實現起來也並不簡單,涉及到對運行時代碼的改造。不考慮垃圾回收對 arena 的處理, arena 主要的實現在在運行時的 arena.go[5] 中。因爲這個功能還在開發之中,或許這個文件還會有變更。

接下來,我們使用 debian benchmark's game 的二叉樹的例子,對使用 arena 和不使用 arena 的情況下做一個比較:

package main

import (
 "arena"
 "flag"
 "fmt"
 "strconv"
 "time"
)

// gotip run -tags "goexperiment.arenas" main.go -arena 21
// GOEXPERIMENT=arenas gotip run main.go -arena 21

var n = 0

type Node struct {
 left, right *Node
 value       []byte
}

func bottomUpTree(depth int) *Node {
 if depth <= 0 {
  return &Node{}
 }
 return &Node{bottomUpTree(depth - 1), bottomUpTree(depth - 1), make([]byte, 128, 128)}
}

func bottomUpTreeWithArena(depth int, a *arena.Arena) *Node {
 node := arena.New[Node]("Node")
 node.value = arena.MakeSlice[byte](a, 128, 128 "byte")

 if depth <= 0 {
  return node
 }

 node.left = bottomUpTreeWithArena(depth-1, a)
 node.right = bottomUpTreeWithArena(depth-1, a)

 return node
}

func (n *Node) itemCheck() int {
 if n.left == nil {
  return 1
 }
 return 1 + n.left.itemCheck() + n.right.itemCheck()
}

const minDepth = 4

var useArena = flag.Bool("arena", false, "use arena")

func main() {
 flag.Parse()
 if flag.NArg() > 0 {
  n, _ = strconv.Atoi(flag.Arg(0))
 }

 appStart := time.Now()
 defer func() {
  fmt.Printf("benchmark took: %v\n", time.Since(appStart))
 }()

 if *useArena {
  maxDepth := n
  if minDepth+2 > n {
   maxDepth = minDepth + 2
  }
  stretchDepth := maxDepth + 1

  a := arena.NewArena()
  start := time.Now()
  check := bottomUpTreeWithArena(stretchDepth, a).itemCheck()
  a.Free()
  fmt.Printf("stretch tree of depth %d\t check: %d, took: %v\n", stretchDepth, check, time.Since(start))

  a = arena.NewArena()
  longLiveStart := time.Now()
  longLivedTree := bottomUpTreeWithArena(maxDepth, a)
  defer a.Free()

  for depth := minDepth; depth <= maxDepth; depth += 2 {
   iterations := 1 << uint(maxDepth-depth+minDepth)
   check = 0

   start := time.Now()
   for i := 1; i <= iterations; i++ {
    a := arena.NewArena()
    check += bottomUpTreeWithArena(depth, a).itemCheck()
    a.Free()
   }
   fmt.Printf("%d\t trees of depth %d\t check: %d, took: %v\n", iterations, depth, check, time.Since(start))
  }
  fmt.Printf("long lived tree of depth %d\t check: %d, took: %v\n", maxDepth, longLivedTree.itemCheck(), time.Since(longLiveStart))
 } else {
  maxDepth := n
  if minDepth+2 > n {
   maxDepth = minDepth + 2
  }
  stretchDepth := maxDepth + 1

  start := time.Now()
  check := bottomUpTree(stretchDepth).itemCheck()
  fmt.Printf("stretch tree of depth %d\t check: %d, took: %v\n", stretchDepth, check, time.Since(start))

  longLiveStart := time.Now()
  longLivedTree := bottomUpTree(maxDepth)

  for depth := minDepth; depth <= maxDepth; depth += 2 {
   iterations := 1 << uint(maxDepth-depth+minDepth)
   check = 0

   start := time.Now()
   for i := 1; i <= iterations; i++ {
    check += bottomUpTree(depth).itemCheck()
   }
   fmt.Printf("%d\t trees of depth %d\t check: %d, took: %v\n", iterations, depth, check, time.Since(start))
  }
  fmt.Printf("long lived tree of depth %d\t check: %d, took: %v\n", maxDepth, longLivedTree.itemCheck(), time.Since(longLiveStart))
 }
}

這段程序中我們使用-arena參數控制要不要使用arena。首先你必須安裝或者更新gotip到最新版 (如果你已經安裝了 gotip, 執行gotip downloamd, 如果還未安裝,請先go install golang.org/dl/gotip@latest)。

不過這個特性還在開發之中,功能還不完善。

我在 MacOS 上測試,使用arena性能會有明顯的提升,而在 windows 下測試,性能反而下降了。

參考資料

[1]

cmd/compile: profile-guided optimization: https://github.com/golang/go/issues/55022#

[2]

提案: https://github.com/golang/proposal/blob/master/design/55022-pgo-implementation.md

[3]

memory arena: https://github.com/golang/go/issues/51317

[4]

提案: https://github.com/golang/go/issues/51317

[5]

arena.go: https://github.com/golang/go/blob/26b48442569102226baba1d9b4a83aaee3d06611/src/runtime/arena.go

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/VtDR2uJ8SQ2jiIS2o2uyfA