Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 19569] ファイルの概要

このコミットは、Go言語の標準ライブラリであるencoding/base32およびencoding/base64パッケージにおけるエンコード処理のパフォーマンス改善を目的としています。具体的には、エンコード中に一時的な値を格納する方法を変更することで、処理速度を向上させています。

コミット

commit 2fbfe55e6374d212e49cff4c6723936af8e4ce89
Author: Rui Ueyama <ruiu@google.com>
Date:   Wed Jun 18 12:05:46 2014 -0700

    encoding/base64, encoding/base32: make Encode faster
    
    Storing temporary values to a slice is slower than storing
    them to local variables of type byte.
    
    benchmark                         old MB/s     new MB/s  speedup
    BenchmarkEncodeToStringBase32       102.21       156.66    1.53x
    BenchmarkEncodeToStringBase64       124.25       177.91    1.43x
    
    LGTM=crawshaw
    R=golang-codereviews, crawshaw, bradfitz, dave
    CC=golang-codereviews
    https://golang.org/cl/109820045

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/2fbfe55e6374d212e49cff4c6723936af8e4ce89

元コミット内容

encoding/base64, encoding/base32: make Encode faster

Storing temporary values to a slice is slower than storing
them to local variables of type byte.

benchmark                         old MB/s     new MB/s  speedup
BenchmarkEncodeToStringBase32       102.21       156.66    1.53x
BenchmarkEncodeToStringBase64       124.25       177.91    1.43x

LGTM=crawshaw
R=golang-codereviews, crawshaw, bradfitz, dave
CC=golang-codereviews
https://golang.org/cl/109820045

変更の背景

このコミットの背景には、Go言語のencoding/base32およびencoding/base64パッケージにおけるエンコード処理のパフォーマンス最適化の必要性がありました。Base32およびBase64エンコーディングは、バイナリデータをASCII文字列に変換するための一般的な手法であり、ネットワーク通信やデータ保存など、様々な場面で利用されます。これらの処理は頻繁に実行されるため、わずかなパフォーマンスの改善でも全体的なシステムのスループットに大きな影響を与える可能性があります。

コミットメッセージに明記されているように、以前の実装ではエンコード処理中に一時的なバイト値を格納するためにスライス(dstスライスの一部を一時的に利用)を使用していました。しかし、この方法がローカルのbyte型変数に直接格納するよりも遅いことが判明しました。これは、スライスへのアクセスが、ポインタのデリファレンスや境界チェックなどのオーバーヘッドを伴う可能性があるためです。一方、少数のローカル変数は通常、CPUレジスタやスタックに割り当てられ、非常に高速にアクセスできます。

この知見に基づき、エンコード処理のホットパス(頻繁に実行されるコード部分)において、一時的なバイト値をスライスではなく個別のローカル変数に格納するように変更することで、パフォーマンスのボトルネックを解消し、エンコード速度を向上させることを目指しました。ベンチマーク結果が示すように、この変更はBase32で1.53倍、Base64で1.43倍という顕著な速度向上をもたらしました。

前提知識の解説

このコミットを理解するためには、以下のGo言語の基本的な概念とエンコーディングの知識が必要です。

  1. Base32/Base64エンコーディング:

    • Base64: バイナリデータをASCII文字列に変換するエンコーディング方式の一つです。3バイトのバイナリデータを4文字のBase64文字に変換します。主に、メールの添付ファイルやHTTPのBasic認証など、テキストベースのプロトコルでバイナリデータを扱う際に使用されます。
    • Base32: Base64と同様にバイナリデータをASCII文字列に変換しますが、5ビット単位で処理し、32種類の文字(A-Zと2-7)を使用します。Base64よりも文字セットが小さいため、人間が読みやすく、URLセーフな形式として利用されることがあります。
  2. Go言語のスライスと配列:

    • 配列 (Array): Go言語の配列は、同じ型の要素を固定長で連続して格納するデータ構造です。配列のサイズはコンパイル時に決定され、変更できません。通常、スタックに割り当てられます(ただし、エスケープ解析の結果によってはヒープに割り当てられることもあります)。
    • スライス (Slice): スライスはGo言語で最も一般的に使用される可変長シーケンス型です。内部的には、基盤となる配列へのポインタ、長さ、容量の3つの要素で構成されます。スライスは配列の一部を参照する「ビュー」のようなものであり、動的にサイズを変更できます(ただし、容量を超えると新しい基盤配列がヒープに割り当てられ、データがコピーされます)。スライスへのアクセスは、ポインタのデリファレンスと境界チェックを伴うため、直接的な配列やローカル変数へのアクセスよりもわずかにオーバーヘッドが発生する可能性があります。
  3. Go言語のメモリ管理 (スタックとヒープ):

    • スタック (Stack): 関数呼び出しやローカル変数が格納されるメモリ領域です。LIFO (Last-In, First-Out) 形式で管理され、高速な割り当てと解放が可能です。コンパイル時にサイズが決定できるような変数はスタックに割り当てられる傾向があります。
    • ヒープ (Heap): プログラム実行中に動的にメモリを割り当てる領域です。Goのガベージコレクタによって管理されます。ヒープへの割り当てはスタックよりも遅く、ガベージコレクションのオーバーヘッドも発生します。スライスやマップ、チャネルなどの参照型は、通常ヒープに割り当てられます。
    • エスケープ解析 (Escape Analysis): Goコンパイラは、変数がスタックに割り当てられるべきか、ヒープに割り当てられるべきかを決定するために「エスケープ解析」を行います。たとえ関数内で宣言されたローカル変数であっても、その変数が関数のスコープ外から参照される可能性がある場合(例: ポインタが返される、グローバル変数に代入されるなど)、コンパイラはその変数をヒープに「エスケープ」させます。
  4. マイクロベンチマーク:

    • Go言語には、testingパッケージに組み込みのベンチマーク機能があります。これにより、特定のコードブロックの実行時間を測定し、パフォーマンスのボトルネックを特定できます。コミットメッセージに記載されているBenchmarkEncodeToStringBase32BenchmarkEncodeToStringBase64は、この機能を使って測定されたものです。

技術的詳細

このコミットの技術的な核心は、Base32およびBase64エンコード処理における一時的なバイト値の取り扱い方法の変更です。

元の実装では、エンコードの各「量子」(Base32では5バイトの入力から8バイトの出力、Base64では3バイトの入力から4バイトの出力)を処理する際に、中間結果を格納するためにdstスライス(出力バッファ)の特定のインデックスを直接使用していました。例えば、Base32のEncode関数では、dst[0] = 0, dst[1] = 0, ... dst[7] = 0のように、dstスライスの要素を一時的な作業領域として初期化し、そこにビットシフトやマスク操作の結果を書き込んでいました。その後、これらのdstスライスの要素をエンコーディングテーブル(enc.encode)でルックアップして最終的なエンコード済みバイトに変換し、再度dstスライスに書き戻していました。

このアプローチの問題点は、dstスライスへの書き込みと読み込みが頻繁に発生することです。Goコンパイラは、スライス要素へのアクセスを最適化しようとしますが、スライスは基盤となる配列へのポインタを介してアクセスされるため、個別のローカル変数へのアクセスよりもわずかに間接的になります。また、ループ内でスライス要素に繰り返しアクセスする場合、コンパイラがレジスタ割り当てを最適化しにくい場合があります。

新しい実装では、この中間的な計算のために、var b0, b1, b2, b3, ... byteのように、個別のローカルbyte型変数を宣言しています。これらのローカル変数は、通常、スタックに割り当てられるか、コンパイラによってCPUレジスタに直接割り当てられます。これにより、アクセスが非常に高速になり、ポインタのデリファレンスや境界チェックのオーバーヘッドがなくなります。中間計算はこれらのローカル変数で行われ、最終的なエンコード済みバイトが計算された後、一度だけdstスライスに書き込まれます。

この変更は、特にエンコード処理の内部ループ(for len(src) > 0)内で、各量子を処理する部分に適用されています。このループは大量のデータがエンコードされる際に何度も実行されるため、この部分のわずかな最適化が全体的なパフォーマンスに大きな影響を与えます。

コアとなるコードの変更箇所

このコミットによるコアとなるコードの変更箇所は、以下の2つのファイルに集中しています。

  1. src/pkg/encoding/base32/base32.go
  2. src/pkg/encoding/base64/base64.go

それぞれのファイルのEncodeメソッド内で、一時的なバイト値を格納する方法が変更されています。

src/pkg/encoding/base32/base32.go の変更点:

  • func (enc *Encoding) Encode(dst, src []byte) メソッド内。
  • 変更前:
    for len(src) > 0 {
        dst[0] = 0
        dst[1] = 0
        dst[2] = 0
        dst[3] = 0
        dst[4] = 0
        dst[5] = 0
        dst[6] = 0
        dst[7] = 0
        // ... 中間計算で dst[j] を直接使用 ...
        for j := 0; j < 8; j++ {
            dst[j] = enc.encode[dst[j]]
        }
    }
    
  • 変更後:
    for len(src) > 0 {
        var b0, b1, b2, b3, b4, b5, b6, b7 byte // 新しくローカル変数を宣言
        // ... 中間計算で b0, b1, ... b7 を使用 ...
        dst[0] = enc.encode[b0] // 最終結果を一度だけ dst に書き込む
        dst[1] = enc.encode[b1]
        dst[2] = enc.encode[b2]
        dst[3] = enc.encode[b3]
        dst[4] = enc.encode[b4]
        dst[5] = enc.encode[b5]
        dst[6] = enc.encode[b6]
        dst[7] = enc.encode[b7]
    }
    

src/pkg/encoding/base64/base64.go の変更点:

  • func (enc *Encoding) Encode(dst, src []byte) メソッド内。
  • 変更前:
    for len(src) > 0 {
        dst[0] = 0
        dst[1] = 0
        dst[2] = 0
        dst[3] = 0
        // ... 中間計算で dst[j] を直接使用 ...
        for j := 0; j < 4; j++ {
            dst[j] = enc.encode[dst[j]]
        }
    }
    
  • 変更後:
    for len(src) > 0 {
        var b0, b1, b2, b3 byte // 新しくローカル変数を宣言
        // ... 中間計算で b0, b1, b2, b3 を使用 ...
        dst[0] = enc.encode[b0] // 最終結果を一度だけ dst に書き込む
        dst[1] = enc.encode[b1]
        dst[2] = enc.encode[b2]
        dst[3] = enc.encode[b3]
    }
    

また、両パッケージのテストファイル(base32_test.gobase64_test.go)には、BenchmarkEncodeToStringBenchmarkDecodeStringという新しいベンチマーク関数が追加されています。これにより、エンコード/デコード処理のパフォーマンスを継続的に監視し、将来の変更による影響を評価できるようになります。

コアとなるコードの解説

このコミットのコアとなるコードの変更は、エンコード処理の効率を最大化するためのマイクロ最適化です。

変更前は、エンコードの各ステップで生成される中間的な5ビット(Base32)または6ビット(Base64)のブロックを、出力先のdstスライスに直接書き込んでいました。例えば、Base32のエンコードでは、入力の5バイトから8つの5ビットブロックを抽出し、これらをBase32文字に変換します。この8つの5ビットブロックを一時的に保持するために、dst[0]からdst[7]までの要素を「スクラッチパッド」のように使用していました。

この方法では、dstスライスへの書き込みと読み込みが頻繁に発生します。Goのスライスは、その性質上、基盤となる配列へのポインタを介してアクセスされ、各アクセスにはポインタのデリファレンスと、場合によっては境界チェックのオーバーヘッドが伴います。これは、特にホットパスで繰り返し実行される場合、パフォーマンスに影響を与える可能性があります。

変更後のコードでは、この「スクラッチパッド」の役割を、var b0, b1, ..., b7 byte(Base32の場合)やvar b0, b1, b2, b3 byte(Base64の場合)といった個別のローカル変数に置き換えています。これらのローカル変数は、Goコンパイラによって最適化されやすく、多くの場合、CPUのレジスタに直接割り当てられます。レジスタへのアクセスは、メモリへのアクセスよりもはるかに高速です。

中間的なビット操作(srcからのビット抽出とシフト、マスク)はすべてこれらのローカル変数に対して行われます。例えば、b7 |= src[4] & 0x1Fのように、srcから読み取ったバイトをローカル変数b7に結合します。すべての計算がローカル変数で完了した後、最終的にエンコードテーブル(enc.encode)を使って文字に変換された結果が、一度だけdstスライスに書き込まれます。

この変更により、dstスライスへのアクセス回数が大幅に削減され、代わりに高速なローカル変数へのアクセスが増加します。これにより、CPUのキャッシュ効率が向上し、命令の実行パイプラインがよりスムーズになり、結果としてエンコード処理全体の速度が向上します。ベンチマーク結果がこの最適化の有効性を明確に示しています。

関連リンク

参考にした情報源リンク