Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

Goコンパイラのお勉強(3)~配列の効率的な操作に関する最適化~

はじめに

SRE の izzii (𝕏: @ahneahneahne) です。今回をもって「Go コンパイラのお勉強」と題した連載ブログが完結です!仕事の話とは直接関係がないネタだったので、書く内容に悩むということはなく気持ちよくかけました。さて、今回は「配列の効率的な利用」と題して

golang/go CompilerOptimizations で扱われている残り物の紹介をしていきます笑 残り物とは言っても知ると知らないとでは Go コードの読み方が変わるかと思いますので、ぜひ!

string と byte のキャスト最適化

byte から string 、string から byte へのキャストは、コンパイラの最適化によってメモリアロケーションが発生しない場合があります。逆を言うと基本的に string と byte へのキャストの際にはメモリアロケーションが走るということです。百聞は一見に如かずということでまずはコードを見てみましょう。

// go1.21.1 darwin/arm64
...
func printTotalAlloc() {
  ... // メモリアロケーションの合計を出力
}

func main() {
    m := make(map[string]struct{}, 1) // 入れ物を作る

    for i := 0; i < 10; i++ {
        b := make([]byte, 1024 * 1024) // 1MiB のアロケーション
        rand.Read(b) // 簡単のためエラーハンドリングしません

        if _, ok := m[string(b)]; ok { // []byte -> string のキャスト
            fmt.Println("never called")
        }
    }

    printTotalAlloc() // TotalAlloc = 10 MiB
}

キャストstring(b) を別出ししてみます。

// go1.20.5 darwin/arm64
...
        // if _, ok := m[string(b)]; ok { // []byte -> string のキャスト
        s := string(b) // コピーで 1MiB のアロケーション
        if _, ok := m[s]; ok {
            fmt.Println("never called")
        }
    }
    printTotalAlloc() // TotalAlloc = 20 MiB
}

byte から string へのキャストを別行に外出ししただけで、メモリアロケーションのサイズが 2 倍に変わりました!正直自分も今までこれを知らずに勿体無いコードを書いていたかもしれません。

たとえば、HTTP リクエストやファイルからのデータ読み取りといった、I/O 処理の場面で活躍することが多いのではないでしょうか。string と byte のキャストでアロケーションが走らないのは以下の 3 つのパターンがあります。

1. map のキー指定のためのキャスト

先ほどの例です。

m := make(map[string]struct{})
var b []byte
...
...

// アロケートは発生しない
m[string(b)]

2. string を byte 毎に処理するためのキャスト

string を for 文で 1byte ずつ処理するコードを書こうとした場合もメモリアロケーションの回避ができます。もちろんキャストを外出ししちゃダメですよ!

s := "..."
for i, c := range []byte(s) { // アロケートは発生しない
    // ...
}

3. 比較のためのキャスト

もちろんキャストを外出ししちゃダメですよ!

var s string
var b []byte
var x = string(s) == string(b)
var y = string(s) < string(b)

memclr による配列ゼロクリア最適化

slices や array でゼロクリアする際にはその操作に最適化された memclr が使われます。 memclrとは Go の標準関数ではなく、ランタイムでのみ利用される関数です。早速実験してみましょう。

// go1.21.0 darwin/arm64 

const N = 1 << 20 // 1,048,576

func memclrRange(a []int) {
    for i := range a { // この for ループが最適化される
        a[i] = 0
    }
}

func memclrIndex(a []int) {
    for i := 0; i < len(a); i++ { // この for ループは最適化されない
        a[i] = 0
    }
}

func memset1(a []int) {
    for i := range a {
        a[i] = 1
    }
}

func memsetRand(a []int) {
    r := rand.New(rand.NewSource(time.Now().UnixNano()))
    for i := range a {
        a[i] = r.Int()
    }
}

func BenchmarkMemclrRange(b *testing.B) {
    b.Run(strconv.Itoa(N), func(b *testing.B) {
        var a = make([]int, N)
        memsetRand(a) // 一応値を埋めておく
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            memclrRange(a)
        }
    })
}

func BenchmarkMemclrIndex(b *testing.B) {
    b.Run(strconv.Itoa(N), func(b *testing.B) {
        var a = make([]int, N)
        memsetRand(a) // 一応値を埋めておく
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            memclrIndex(a)
        }
    })
}

func BenchmarkNoMemclr(b *testing.B) {
    b.Run(strconv.Itoa(N), func(b *testing.B) {
        var a = make([]int, N)
        memsetRand(a) // 一応値を埋めておく
        b.ResetTimer()
        for i := 0; i < b.N; i++ {
            memset1(a)
        }
    })
}
BenchmarkMemclrRange/1048576-8              5482            243813 ns/op
BenchmarkMemclrIndex/1048576-8              2767            497839 ns/op
BenchmarkNoMemclr/1048576-8                 2176            537496 ns/op

range によるスキャンでは約 2 倍の速さに最適化されています。一方、range でないスキャンでは最適化が効かないことには注意です!

memclr はなぜ早いのか?というと、実装が OS や CPU アーキテクチャに合わせて最適化されているからです。より一般的な memset によるゼロ値以外の値でも最適化も検討されたことがあるようですが、こちらは実装されていません。

github.com

明示的にデータをクリアして GC を効かせたい時、メモリアロケーションされた領域の使い回しの際に、ゼロクリアで初期化する場合などで活躍してくれるでしょう。

下記のような書き方で部分 slice のゼロクリアができることも確認しています。

// go1.21.0 darwin/arm64 

func memclr(a []int) {
    b := a[len(a)/3 : len(a)/2]
    for i := range b { // この for ループが最適化される
        b[i] = 0
    }
}

ガベージコレクションのマークスキャン回避

ガベージコレクション (GC) のコストは小さいに越したことはありません。Go はトレースガベージコレクションという仕組みを採用している関係で slices, channels, maps … といったコレクション内部で参照がないポインターをスキャニングします。もしもコレクションの要素にポインターが含まれていなければスキャンしなくなるというのがこの最適化です。たとえば以下のような型を要素に持つ配列が具体例です。

type Value struct {
    Name      [32]byte
    Balance   uint64
    Timestamp int64
}

// GC は m のポインタだけ管理する。中身を見る必要はない。
m := make([]Value, 1e8)

完全に固定長でヒープ上でも連続したところに収まってくれそうなデータですね。逆に Name を string とすると実体としてはポインターなのでスキャンの対象となってしまいます。もう少し Go のトレースガベージコレクションの仕組みが知りたいという方がいらっしゃれば roki さんの記事、A Guide to the Go Garbage Collectorを読んで GOGC と GOMEMLIMIT を理解する をぜひ読んでみてください!

本章でも実験を通して、スキャン実効の有無や実行速度の差を確認したかったのですが、難しかったです。今後方法がわかれば、ぜひ記載したいと思います。大きなデータをメモリ上にストアする必要がある場合に、この最適化が有効な場面があるでしょう。

おわりに

Go コンパイラのお勉強と題しまして 3 つ記事を書いてみました。公式が出している資料があっさりなので、行間を補いつつ、実験コードを交えつつ解説してみました。コンパイラによる最適化が効く条件というのはコード上に明示されないので、知ると知らないとで Go コードの読み方が変わると思っています。 自分も知らないことがあったので、非常に学びが多くて良い取り組みでした。また何か知識を整理するようなブログを書いて、勉強したいと思いました。

ぜひ連載のほか 2 つのブログもみていただけますと嬉しいです!

tech.techtouch.jp

tech.techtouch.jp

参考文献