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

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

このコミットは、Go言語の標準ライブラリにおけるcryptoパッケージ内の様々なハッシュ関数およびTLS (Transport Layer Security) 関連のコードにおいて、メモリ割り当てを削減し、パフォーマンスを向上させることを目的としています。具体的には、ハッシュ計算の結果を格納するための新しいスライス(動的配列)の生成を極力避け、既存のバッファを再利用する、あるいはスタック上に直接値をコピーすることで、ガベージコレクションの負荷を軽減しています。

コミット

commit 554ac03637bd855179c93d76d05b9c847571d0e2
Author: Adam Langley <agl@golang.org>
Date:   Tue Dec 6 18:25:14 2011 -0500

    crypto: allocate less.
    
    The code in hash functions themselves could write directly into the
    output buffer for a savings of about 50ns. But it's a little ugly so I
    wasted a copy.
    
    R=bradfitz
    CC=golang-dev
    https://golang.org/cl/5440111

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

https://github.com/golang/go/commit/554ac03637bd855179c93d76d05b9c847571d0e2

元コミット内容

このコミットの元のメッセージは以下の通りです。

crypto: allocate less.

The code in hash functions themselves could write directly into the output buffer for a savings of about 50ns. But it's a little ugly so I wasted a copy.

日本語訳:

crypto: 割り当てを減らす。

ハッシュ関数自体のコードは、出力バッファに直接書き込むことで約50ナノ秒の節約が可能だった。しかし、それは少し見苦しいので、私はコピーを無駄にした。

このメッセージは、メモリ割り当てを減らすという明確な意図を示しています。特に「コピーを無駄にした」という表現は、以前のコードが不必要にメモリをコピーしていたことを示唆しており、このコミットでその「無駄」を排除しようとしていることが読み取れます。

変更の背景

Go言語はガベージコレクション(GC)によってメモリ管理を行いますが、頻繁なメモリ割り当てはGCの頻度を増加させ、アプリケーションのパフォーマンスに影響を与える可能性があります。特に、暗号化処理のような計算負荷が高く、かつ頻繁に実行される可能性のある操作では、わずかなメモリ割り当ての削減でも全体的なスループットに大きな改善をもたらすことがあります。

このコミットは、cryptoパッケージ内のハッシュ関数(MD5, SHA1, SHA256, SHA512, RIPEMD160, HMAC)やTLSのMAC (Message Authentication Code) 計算において、ハッシュ結果を格納するためのスライス(Go言語の動的配列)の割り当てを最適化することを目的としています。以前の実装では、ハッシュ結果を返す際に常に新しいスライスを割り当てていたため、これがパフォーマンスのボトルネックとなる可能性がありました。

コミットメッセージにある「約50ナノ秒の節約」という具体的な数値は、この最適化がマイクロベンチマークレベルで測定可能な効果をもたらすことを示しており、特に高頻度で呼び出される暗号関数においては、累積的なパフォーマンス向上が期待されます。

前提知識の解説

Go言語のスライスとメモリ割り当て

Go言語のスライスは、配列への参照、長さ、容量を持つデータ構造です。append関数は、スライスの容量が足りない場合に新しい基底配列を割り当ててデータをコピーします。make([]byte, 0, capacity)のように容量を指定してスライスを作成すると、その容量内では再割り当てなしでappend操作が可能です。 new(T)は型Tのゼロ値を指すポインタを返しますが、T{}var t Tのように値型を直接宣言すると、スタック上に値が割り当てられることが多く、ヒープ割り当てを避けることができます。

hash.Hashインターフェース

Go言語のcryptoパッケージでは、様々なハッシュ関数がhash.Hashインターフェースを実装しています。このインターフェースには以下の主要なメソッドがあります。

  • Write(p []byte) (n int, err error): ハッシュ計算の対象となるデータを書き込みます。
  • Sum(b []byte) []byte: 現在のハッシュ値を計算し、それをbに追記した新しいスライスを返します。bnilの場合、新しいスライスが割り当てられます。bに既存のスライスを渡すと、そのスライスの容量が許す限り、新しい割り当てなしでハッシュ値が追記されます。
  • Reset(): ハッシュの状態を初期化します。
  • Size() int: ハッシュ値のバイト長を返します。
  • BlockSize() int: ハッシュ関数のブロックサイズを返します。

TLS (Transport Layer Security) のMAC (Message Authentication Code)

TLSプロトコルでは、通信の完全性と認証を保証するためにMACが使用されます。MACは、メッセージと秘密鍵から計算される短い固定長のデータであり、メッセージが改ざんされていないこと、および送信者が秘密鍵を知っていることを検証するために使用されます。MACの計算にはハッシュ関数が利用されます。

技術的詳細

このコミットの主要な最適化は、以下の3つのパターンに集約されます。

  1. new(digest)から値のコピーへの変更: ハッシュ関数のSumメソッド内で、digest構造体のコピーを作成する際に、以前はd := new(digest); *d = *d0という形式で新しいポインタとそれに対応するヒープメモリを割り当てていました。これをd := *d0という形式に変更することで、d0の値を直接dにコピーし、digest構造体自体のヒープ割り当てを回避しています。これにより、ガベージコレクションの対象となるオブジェクトが減少し、GCのオーバーヘッドが削減されます。

  2. Sum(nil)からSum(in)またはSum(digest[:0])への変更: hash.HashインターフェースのSumメソッドは、引数として[]byteスライスを受け取ります。このスライスにハッシュ結果を追記し、その結果を含む新しいスライスを返します。

    • 以前のSum(nil)の呼び出しは、常にハッシュ結果を格納するための新しいスライスを割り当てていました。
    • 変更後、Sum(in)のように既存の入力スライスを渡すことで、そのスライスの基底配列に十分な容量があれば、新しい割り当てなしでハッシュ結果が追記されます。
    • また、Sum(digest[:0])のように、固定サイズの配列(例: var digest [Size]byte)を基底配列とするゼロ長のスライスを渡すことで、その固定サイズの配列のメモリを再利用してハッシュ結果を格納できるようになります。これにより、ハッシュ結果を一時的に格納するための動的なスライス割り当てが不要になります。
  3. TLS MAC計算におけるバッファの再利用: crypto/tlsパッケージでは、MAC計算のためにmacFunctionインターフェースが定義されています。以前のMACメソッドは、MAC結果を返す際に新しいスライスを割り当てていました。 変更後、macFunctionインターフェースのMACメソッドにdigestBuf []byteという引数が追加されました。これにより、呼び出し元はMAC結果を格納するための既存のバッファを渡すことができ、MACメソッドはそのバッファを再利用して結果を書き込むことができます。具体的には、halfConn構造体にinDigestBufoutDigestBufというフィールドが追加され、これらがMAC計算の際に再利用されるバッファとして機能します。

これらの変更は、Go言語のメモリ管理モデルとスライスの特性を最大限に活用し、ヒープ割り当てを減らすことで、ガベージコレクションの頻度と時間を削減し、全体的なパフォーマンスを向上させる効果があります。特に、暗号処理のように頻繁に実行されるコードパスでは、この種のマイクロ最適化が大きな影響を与える可能性があります。

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

このコミットは、以下のファイルにわたってメモリ割り当ての最適化を行っています。

  • src/pkg/crypto/hmac/hmac.go
  • src/pkg/crypto/md5/md5.go
  • src/pkg/crypto/openpgp/s2k/s2k.go
  • src/pkg/crypto/ripemd160/ripemd160.go
  • src/pkg/crypto/rsa/rsa.go
  • src/pkg/crypto/sha1/sha1.go
  • src/pkg/crypto/sha256/sha256.go
  • src/pkg/crypto/sha512/sha512.go
  • src/pkg/crypto/tls/cipher_suites.go
  • src/pkg/crypto/tls/conn.go
  • src/pkg/crypto/tls/handshake_client.go
  • src/pkg/crypto/tls/handshake_server.go

具体的な変更のパターンは以下の通りです。

src/pkg/crypto/md5/md5.go (および ripemd160, sha1, sha256, sha512 も同様)

変更前:

func (d0 *digest) Sum(in []byte) []byte {
	d := new(digest) // ヒープ割り当て
	*d = *d0
	// ...
	for _, s := range d.s {
		in = append(in, byte(s>>0)) // バイトごとにappend、複数回再割り当ての可能性
		// ...
	}
	return in
}

変更後:

func (d0 *digest) Sum(in []byte) []byte {
	d := *d0 // 値のコピー、ヒープ割り当てなし
	// ...
	var digest [Size]byte // 固定サイズの配列をスタックに割り当て
	for i, s := range d.s {
		digest[i*4] = byte(s)
		// ...
	}
	return append(in, digest[:]...) // 固定配列のスライスを一度にappend
}

src/pkg/crypto/hmac/hmac.go (および openpgp/s2k, rsa も同様)

変更前:

func (h *hmac) Sum(in []byte) []byte {
	sum := h.inner.Sum(nil) // 新しいスライスを割り当て
	// ...
	return h.outer.Sum(in) // 新しいスライスを割り当て
}

変更後:

func (h *hmac) Sum(in []byte) []byte {
	origLen := len(in)
	in = h.inner.Sum(in) // 既存のinスライスに追記、再利用の可能性
	// ...
	copy(h.tmp[padSize:], in[origLen:])
	// ...
	return h.outer.Sum(in[:origLen]) // 既存のinスライスの一部を渡し、そこに追記
}

src/pkg/crypto/tls/cipher_suites.go

変更前:

type macFunction interface {
	Size() int
	MAC(seq, data []byte) []byte // 新しいスライスを返す
}

func (s ssl30MAC) MAC(seq, record []byte) []byte {
	// ...
	digest := s.h.Sum(nil) // 新しいスライスを割り当て
	// ...
	return s.h.Sum(nil) // 新しいスライスを割り当て
}

変更後:

type macFunction interface {
	Size() int
	MAC(digestBuf, seq, data []byte) []byte // 既存のバッファを受け取る
}

func (s ssl30MAC) MAC(digestBuf, seq, record []byte) []byte {
	// ...
	digestBuf = s.h.Sum(digestBuf[:0]) // 既存のdigestBufを再利用
	// ...
	return s.h.Sum(digestBuf[:0]) // 既存のdigestBufを再利用
}

src/pkg/crypto/tls/conn.go

halfConn構造体にinDigestBufoutDigestBufというフィールドが追加され、MAC計算時にこれらのバッファが再利用されるようになりました。

type halfConn struct {
	// ...
	nextCipher interface{} // next encryption state
	nextMac    macFunction // next MAC algorithm

	// used to save allocating a new buffer for each MAC.
	inDigestBuf, outDigestBuf []byte // 新しいフィールド
}

そして、decryptおよびencryptメソッド内でこれらのバッファがhc.mac.MACに渡されるよう変更されています。

コアとなるコードの解説

ハッシュ関数のSumメソッドの最適化

md5.gosha1.goなどのハッシュ関数のSumメソッドにおける変更は、主に以下の2点に集約されます。

  1. digest構造体のコピー方法の変更: d := new(digest); *d = *d0というコードは、digest型の新しいインスタンスをヒープに割り当て、そのポインタをdに代入し、その後d0の値を新しく割り当てられたメモリにコピーしていました。これは、digest構造体が比較的小さい場合でもヒープ割り当てとそれに伴うGCのオーバーヘッドを発生させます。 d := *d0という変更は、d0の値を直接dという新しい変数にコピーします。Go言語では、このような値のコピーは通常スタック上で行われるため、ヒープ割り当てを完全に回避できます。これにより、Sumメソッドが呼び出されるたびに発生していた一時的なヒープオブジェクトの生成がなくなります。

  2. ハッシュ結果の格納方法の変更: 以前は、ハッシュ結果の各バイトをループ内でin = append(in, byte(s>>X))のようにinスライスに逐次追加していました。この方法では、inスライスの容量が不足するたびに新しい基底配列が割り当てられ、既存のデータがコピーされるという再割り当てが複数回発生する可能性がありました。 変更後、var digest [Size]byteという固定サイズの配列を宣言し、ハッシュ結果をこの配列に直接書き込みます。この配列は通常スタック上に割り当てられます。その後、return append(in, digest[:]...)という形で、この固定配列全体を一度にinスライスに追記します。append関数は、inスライスの既存の容量が十分であれば、新しい割り当てなしでdigestの内容を追記できます。容量が不足する場合でも、一度の再割り当てで済むため、複数回の再割り当てとコピーを避けることができます。これにより、メモリ割り当ての回数とコピーの量が削減され、パフォーマンスが向上します。

HMACおよびS2K、RSAにおけるSumの引数変更

hmac.goopenpgp/s2k/s2k.gorsa/rsa.goにおける変更は、hash.HashインターフェースのSumメソッドの引数にnilではなく既存のスライスを渡すことで、メモリ割り当てを削減しています。

  • h.inner.Sum(nil)は常に新しいスライスを返しますが、h.inner.Sum(in)とすることで、inスライスの既存の容量を再利用してハッシュ結果を格納できる可能性が生まれます。
  • s2k.gorsa.goでは、h.Sum(digest[:0])のように、事前に宣言されたdigestスライス(または配列を基底とするスライス)の容量を再利用しています。digest[:0]は、digestスライスの基底配列を共有しつつ、長さが0のスライスを作成するイディオムです。これにより、Sumメソッドがハッシュ結果をこの既存のバッファに直接書き込むことができ、新しいスライスの割り当てが不要になります。

TLS MAC計算におけるバッファの再利用

crypto/tlsパッケージの変更は、TLSのMAC計算において、MAC結果を格納するためのバッファを再利用するメカニズムを導入しています。

  • macFunctionインターフェースのMACメソッドにdigestBuf []byteという引数を追加することで、MAC計算の呼び出し元が結果を書き込むためのバッファを提供できるようになりました。
  • halfConn構造体にinDigestBufoutDigestBufというフィールドが追加され、これらがそれぞれ受信MACと送信MACの計算結果を格納するための再利用可能なバッファとして機能します。
  • MACメソッド内では、s.h.Sum(digestBuf[:0])のように、渡されたdigestBufの基底配列を再利用してハッシュ結果を格納します。これにより、MACが計算されるたびに新しいスライスが割り当てられることを防ぎ、メモリ割り当てを削減します。

これらの変更は、Go言語のメモリモデルとスライスの効率的な利用を深く理解していることを示しており、特にパフォーマンスが重視される暗号ライブラリにおいて、ガベージコレクションの負荷を軽減し、実行速度を向上させるための典型的な最適化手法です。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント
  • Go言語のソースコード (特にsrc/pkg/cryptoディレクトリ)
  • Go言語のスライスとメモリ管理に関する一般的な知識
  • Gitのコミットログと差分表示
  • TLSプロトコルに関する一般的な知識 (MACの役割など)