Retty Tech Blog

実名口コミグルメサービスRettyのエンジニアによるTech Blogです。プロダクト開発にまつわるナレッジをアウトプットして、世の中がHappyになっていくようなコンテンツを発信します。

リソース効率向上のためのGoのuniqueパッケージ

ソフトウェアエンジニアの福井です。
Go 1.23で新しくuniqueパッケージが追加されました。このuniqueパッケージはinterningを提供します。

interning

interningとはGoに限らず、新しいオブジェクトを作成するときにすでに同じオブジェクトのメモリ割り当てがあればそのメモリを再利用し、メモリスペース効率を向上させる方法です。 interningを活用した具体的なユースケースとしては以下があると思います。

  • 株価データの集計バッチで、同じ値が頻出するティッカーと株価のオブジェクトのinterning
  • フリマサイト 評価データの集計バッチで、「とても良い取引ができました~」定型文を多く含む評価本文のinterning


以降この記事のコードでは1つ目のティッカーと株価のオブジェクトを例に使います。なおGoのバージョンは1.23.2です。

uniqueパッケージ

使い方

uniqueパッケージは以下のように使います。
interningしたい値をunique.Make関数に渡すと、ポインタが含まれるunique.Handleが返るのでそれを使います。

   stocks := make([]unique.Handle[stock], 3)
    stocks[0] = unique.Make(stock{ticker: "RETTY", price: 200})
    stocks[1] = unique.Make(stock{ticker: "RETTY", price: 200}) // ↑と同じポインタを再利用し返す
    stocks[2] = unique.Make(stock{ticker: "RETTY", price: 100})

    fmt.Println("Handle address: ", &stocks[0])
    fmt.Println("Handle address: ", &stocks[1])
    fmt.Println("Handle address: ", &stocks[2])
    // Handle address:  &{0x1400000c060}
    // Handle address:  &{0x1400000c060} 同じポインタを指す
    // Handle address:  &{0x1400000c078}

    // unique.Handle.Value()で元の値を取得
    fmt.Printf("Handle Value: %#v\n", stocks[0].Value())
    fmt.Printf("Handle Value: %#v\n", stocks[1].Value())
    fmt.Printf("Handle Value: %#v\n", stocks[2].Value())
    // Handle Value: main.stock{ticker:"RETTY", price:200}
    // Handle Value: main.stock{ticker:"RETTY", price:200}
    // Handle Value: main.stock{ticker:"RETTY", price:100}

    // 同じ値の時は同じポインタを指すため比較できる
    fmt.Println(stocks[0] == stocks[1]) // true
    fmt.Println(stocks[1] == stocks[2]) // false
}

ベンチマーク

uniqueパッケージを活用するとメモリ使用量が減ることは、以下のmattnさんが公開されてるコードで確認できます。
https://github.com/mattn/go-unique-example

また比較速度についてはポインタを比較するため、値同士に比べて速いです。

func BenchmarkCompareNormal(b *testing.B) {
    stocks := make([]stock, b.N)
    for i := 0; i < b.N; i++ {
        stocks[i] = stock{ticker: "RETTY", price: 200}
    }
    b.ResetTimer()
    for i := 1; i < b.N; i++ {
        _ = stocks[i-1] == stocks[i]
    }
}

func BenchmarkCompareHandle(b *testing.B) {
    stocks := make([]unique.Handle[stock], b.N)
    for i := 0; i < b.N; i++ {
        stocks[i] = unique.Make(stock{ticker: "RETTY", price: 200})
    }
    b.ResetTimer()
    for i := 1; i < b.N; i++ {
        _ = stocks[i-1] == stocks[i]
    }
}
BenchmarkCompareNormal-8        1000000000               8.656 ns/op
BenchmarkCompareHandle-8        1000000000               0.4674 ns/op

ただunique.Handleの生成については、すでに値が割り当て済みか確認するなどの処理を含むため、通常の値生成に比べて遅いです。

func BenchmarkNormal(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = stock{ticker: "RETTY", price: 200}
    }
}

func BenchmarkMakeHandle(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = unique.Make(stock{ticker: "RETTY", price: 200})
    }
}
BenchmarkNormal-8               1000000000               0.3176 ns/op
BenchmarkMakeHandle-8           1000000000              30.32 ns/op

内部実装

値とポインタの情報はグローバル変数に持ちます。持たせる型は通常のMapではなく、独自のmap実装にkey: interningする型, value: (key: interningする値, value: 値のポインタ(unsafe.Pointer))の2層のmap形式で持ちます。
以下はunique.Makeのざっくりした処理フローです。

データ構造

独自のmapはトライ木で実装されています。uniqueパッケージのproposalによると、これを通常のMapで実装するとmap容量縮小が複雑になるなどの問題があったため、新しくトライ木を実装することになったようです。通常のMapは拡大した内部のバケット数を縮小できず、バケットが残り続けます。縮小のためにはMapの再作成が必要になります。
トライ木実装で2層目のkey(interningする値)は以下に持っています。 プリフィックスは64bitにハッシュした値の4bitの区切りです。
この実装により主に読み取りにおいてパフォーマンスが発揮できます。

弱参照

値のポインタ(unsafe.Pointer)は弱参照で保存されます。値の参照があってもGCで回収される可能性があるため、値を使うときは強参照(通常の参照)に戻しています。これにより効率的なメモリ管理ができます。

まとめ

Go 1.23で追加されたuniqueパッケージはメモリスペース効率を向上させるinterning機能を提供します。比較処理も速くでき、大規模データの処理、特に同じ値を繰り返し使用するアプリケーションにおいて有効です。一方、値の生成は通常の生成に比べて遅くなるというトレードオフもあります。用途に応じてuniqueパッケージを活用し、アプリケーション全体のリソース効率を向上させることが可能です。