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

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

このコミットは、Go言語の標準ライブラリであるgo/printerパッケージと、それを利用するフォーマッタツールgofmtのパフォーマンス改善を目的としています。具体的には、中間出力にbytes.Bufferを使用することで、gofmtの実行速度を20%から30%向上させています。

コミット

commit a0e54aaffa3d67b3caf9a30ffa1d0b1f359d34b1
Author: Robert Griesemer <gri@golang.org>
Date:   Tue Nov 22 15:12:34 2011 -0800

    go/printer, gofmt: 20 to 30% faster gofmt
    
    Buffer intermediate output via a bytes.Buffer and thus avoid
    calling through the entire Writer stack for every item printed.
    There is more opportunity for improvements along the same lines.
    
    Before (best of 3 runs):
    - printer.BenchmarkPrint              50          47959760 ns/op
    - time gofmt -l $GOROOT/src           real        0m11.517s
    
    After (best of 3 runs):
    - printer.BenchmarkPrint              50          32056640 ns/op (= -33%)
    - time gofmt -l $GOROOT/src           real        0m9.070s       (= -21%)
    
    R=r
    CC=golang-dev
    https://golang.org/cl/5432054

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

https://github.com/golang/go/commit/a0e54aaffa3d67b3caf9a30ffa1d0b1f359d34b1

元コミット内容

このコミットは、go/printerパッケージとgofmtのパフォーマンスを20%から30%向上させるものです。これは、中間出力をbytes.Buffer経由でバッファリングすることで、出力される各項目ごとにio.Writerスタック全体を呼び出すことを避けることによって実現されました。同様の改善の機会は他にも存在します。

変更前(3回の実行のうち最良の結果):

  • printer.BenchmarkPrint: 50回実行で47959760 ns/op
  • time gofmt -l $GOROOT/src: リアルタイムで0m11.517s

変更後(3回の実行のうち最良の結果):

  • printer.BenchmarkPrint: 50回実行で32056640 ns/op(-33%改善)
  • time gofmt -l $GOROOT/src: リアルタイムで0m9.070s(-21%改善)

変更の背景

go/printerパッケージは、Goの抽象構文木(AST)を整形し、人間が読める形式のGoコードとして出力する役割を担っています。gofmtツールはこのgo/printerパッケージを内部的に利用して、Goソースコードの自動整形を行います。

従来のgo/printerの実装では、整形されたコードの小さな断片が生成されるたびに、直接io.Writerインターフェースを通じて出力ストリームに書き込まれていました。io.Writerは非常に汎用的なインターフェースであり、ファイル、ネットワークソケット、標準出力など、様々な出力先にデータを書き込むことができます。しかし、この汎用性ゆえに、特に頻繁に小さな書き込みが行われる場合、各書き込み操作には一定のオーバーヘッドが伴います。

特に、go/printertabwriter.Writerのような追加の処理層を介して出力を行う場合、このオーバーヘッドは顕著になります。tabwriter.Writerは、タブ文字やスペースを適切に処理してカラムを揃えるためのロジックを持っており、その処理自体にもコストがかかります。小さなデータを頻繁にtabwriter.Writerに渡すと、その都度内部バッファリングや整形ロジックが起動し、パフォーマンスのボトルネックとなる可能性がありました。

このコミットの背景には、gofmtの実行速度を向上させ、開発者の生産性を高めるという明確な目標がありました。特に大規模なGoプロジェクトでは、gofmtの実行時間が長くなると、コードの整形が開発ワークフローの妨げになることがあります。そのため、出力処理の効率化が求められていました。コミットメッセージにある// TODO(gri) Replace bottleneck []byte conversion // with writing into a bytes.Buffer.というコメントからも、開発者がこのボトルネックを認識しており、bytes.Bufferの導入が以前から検討されていたことが伺えます。

前提知識の解説

このコミットを理解するためには、以下のGo言語の概念と標準ライブラリの知識が不可欠です。

  1. io.Writerインターフェース:

    • Go言語のioパッケージで定義されている基本的なインターフェースの一つです。
    • Write(p []byte) (n int, err error)という単一のメソッドを持ちます。
    • このインターフェースを実装する型は、バイトスライスpを何らかの出力先に書き込むことができます。
    • ファイル、ネットワーク接続、標準出力、メモリバッファなど、様々な出力先がこのインターフェースを実装しています。
    • go/printerのようなライブラリがio.Writerを受け取ることで、出力先を柔軟に選択できるようになります。
  2. bytes.Buffer:

    • bytesパッケージで提供される型で、可変長のバイトバッファをメモリ上に保持します。
    • io.Writerインターフェースを実装しており、Writeメソッドを通じてデータをバッファに追加できます。
    • また、io.Readerインターフェースも実装しているため、バッファからデータを読み出すことも可能です。
    • WriteStringWriteByteReadBytesStringなどの便利なメソッドを提供します。
    • 特に、頻繁に小さなデータを書き込む必要がある場合に、直接io.Writerに書き込むよりも効率的です。これは、bytes.Bufferが内部的にバイトスライスを効率的に拡張し、システムコールを最小限に抑えるためです。
  3. go/printerパッケージ:

    • Go言語のソースコードを整形するためのパッケージです。
    • 抽象構文木(AST: Abstract Syntax Tree)を受け取り、それをGo言語の標準的なフォーマットに従って文字列として出力します。
    • gofmtツールはこのパッケージを基盤としています。
  4. gofmtツール:

    • Go言語の公式なコードフォーマッタです。
    • Goソースコードを自動的に整形し、Goコミュニティ全体で一貫したコーディングスタイルを強制します。
    • 開発ワークフローにおいて、コードの可読性を高め、レビュープロセスを効率化するために広く利用されています。
  5. パフォーマンス最適化の一般的な原則:

    • バッファリング: 頻繁な小さなI/O操作はコストが高いため、データを一時的にメモリに蓄積(バッファリング)し、まとめて大きな塊として書き込むことで効率を向上させます。
    • システムコールの削減: オペレーティングシステムへのシステムコールは、ユーザーモードからカーネルモードへのコンテキストスイッチを伴うため、比較的コストが高い操作です。これを減らすことはパフォーマンス向上に繋がります。
    • メモリ割り当ての最適化: 頻繁なメモリ割り当てと解放はガベージコレクションの負荷を増大させ、パフォーマンスに影響を与えます。bytes.Bufferのように内部的にメモリを効率的に管理するデータ構造は、この問題を緩和します。

技術的詳細

このコミットの核心は、go/printerパッケージにおける出力処理のアーキテクチャ変更にあります。

変更前は、printer構造体が直接io.Writer型のoutputフィールドを持っていました。コードの整形中に生成される文字列の断片は、printer.write0printer.writeといったメソッドを通じて、このoutputフィールドに直接書き込まれていました。

// 変更前のprinter構造体の一部
type printer struct {
	output io.Writer // 直接io.Writerに出力
	// ...
	written int // 書き込まれたバイト数を追跡
}

// 変更前のwrite0メソッド (削除された)
func (p *printer) write0(data string) {
	if len(data) > 0 {
		// TODO(gri) Replace bottleneck []byte conversion
		//           with writing into a bytes.Buffer.
		//           Will also simplify post-processing.
		n, err := p.output.Write([]byte(data)) // ここで直接io.Writerに書き込み
		p.written += n
		if err != nil {
			panic(printerError{err})
		}
	}
}

このアプローチの問題点は、go/printerが非常に多くの小さな文字列(例えば、キーワード、識別子、句読点、スペースなど)を生成し、それらを個別にio.Writerに書き込んでいた点です。特に、tabwriter.Writerのような追加の処理層がio.Writerチェーンに含まれる場合、各Write呼び出しは、tabwriterの内部ロジック(タブの展開、カラムの調整など)をトリガーし、これがパフォーマンスのボトルネックとなっていました。

このコミットでは、この問題を解決するために、printer構造体のoutputフィールドをio.Writerからbytes.Bufferに変更しました。

// 変更後のprinter構造体の一部
type printer struct {
	Config
	fset   *token.FileSet
	output bytes.Buffer // bytes.Bufferを内部バッファとして使用
}

これにより、printerの内部では、整形されたコードの断片が直接io.Writerに書き込まれるのではなく、まずbytes.Bufferに蓄積されるようになりました。bytes.Bufferはメモリ上で効率的にバイトを蓄積できるため、WriteStringなどの操作は非常に高速です。

// 変更後のwriteメソッドの一部
func (p *printer) write(data string) {
	// ...
	// write segment ending in data[i]
	p.output.WriteString(data[i0 : i+1]) // bytes.Bufferに書き込み
	// ...
	// write remaining segment
	p.output.WriteString(data[i0:]) // bytes.Bufferに書き込み
	// ...
}

そして、Config.fprintメソッドの最後で、printerが整形処理を完了した後、bytes.Bufferに蓄積された全てのデータ(p.output.Bytes())が、一度に最終的なio.Writertrimmertabwriterを介して)に書き込まれるようになりました。

// 変更後のConfig.fprintメソッドの一部
func (cfg *Config) fprint(output io.Writer, fset *token.FileSet, node interface{}, nodeSizes map[ast.Node]int) (err error) {
	// ...
	// print node (この中でp.output (bytes.Buffer) に書き込まれる)
	var p printer
	p.init(cfg, fset, nodeSizes)
	if err = p.printNode(node); err != nil {
		return
	}
	p.flush(token.Position{Offset: infinity, Line: infinity}, token.EOF)

	// ... (trimmer, tabwriterの設定)

	// write printer result via tabwriter/trimmer to output
	if _, err = output.Write(p.output.Bytes()); err != nil { // ここでbytes.Bufferの内容をまとめて最終出力に書き込み
		return
	}

	// ... (tabwriterのフラッシュ)
	return
}

この変更により、io.WriterへのWrite呼び出しの回数が劇的に減少しました。これにより、io.Writerインターフェースのオーバーヘッドや、tabwriterのような中間層の処理コストが大幅に削減され、結果としてgo/printerおよびgofmtの全体的なパフォーマンスが向上しました。

また、エラーハンドリングの変更も行われています。以前はprinterErrorというカスタムエラー型を定義し、panic/recoverメカニズムを使用してエラーを伝播していましたが、このコミットでprintNodeメソッドが導入され、エラーを直接error型として返すようになりました。これにより、よりGoらしいエラーハンドリングパターンに移行し、コードの可読性と保守性が向上しています。

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

このコミットにおける主要な変更は、src/pkg/go/printer/printer.goファイルに集中しています。

  1. printer構造体の変更:

    • output io.Writerフィールドが削除され、代わりにoutput bytes.Bufferフィールドが追加されました。
    • written intフィールドが削除されました。
  2. printer.initメソッドのシグネチャ変更:

    • output io.Writer引数が削除されました。
  3. printer.write0メソッドの削除:

    • このメソッドは、直接io.Writerに書き込むためのものでしたが、bytes.Bufferへの書き込みに置き換えられたため不要になりました。
  4. printer.writeメソッド内の変更:

    • p.write0への呼び出しが、p.output.WriteStringへの呼び出しに置き換えられました。
  5. printer.writeItemメソッド内の変更:

    • デバッグ出力部分で、p.write0への呼び出しがfmt.Fprintf(&p.output, ...)に置き換えられました。
  6. printer.writeCommentPrefixメソッド内の変更:

    • p.written == 0という条件がp.output.Len() == 0に置き換えられました。これは、bytes.Bufferの長さでバッファが空かどうかを判断するためです。
  7. printer.printNodeメソッドの新規追加:

    • ASTノードの型に基づいて適切な整形ロジックを呼び出すための新しいヘルパーメソッドが追加されました。以前はConfig.fprint内にあったロジックが分離されました。
  8. Config.fprintメソッドの変更:

    • 戻り値の型が(written int, err error)から(err error)に変更されました。
    • printer構造体の初期化時に、内部のoutputフィールドがbytes.Bufferとして初期化されるようになりました。
    • 以前Config.fprint内にあったASTノードの型に応じた整形ロジックが、新しく追加されたp.printNodeメソッドの呼び出しに置き換えられました。
    • panic/recoverによるエラーハンドリングが削除されました。
    • 最終的に、p.output.Bytes()bytes.Bufferの内容)が、output.Write()を通じて実際のio.Writerに書き込まれるようになりました。
  9. Config.Fprintメソッドの変更:

    • 戻り値のwritten intが常に0を返すように変更されました。これは、内部バッファリングにより、このレベルでは書き込まれたバイト数が直接追跡されなくなったためです。
  10. src/pkg/go/printer/nodes.goの変更:

    • cfg.fprintの呼び出し箇所で、戻り値のwritten intが不要になったため、その部分が削除されました。

コアとなるコードの解説

主要な変更はsrc/pkg/go/printer/printer.goにあります。

printer構造体の変更:

// 変更前
type printer struct {
	// ...
	output io.Writer
	// ...
	written int // number of bytes written
	// ...
}

// 変更後
type printer struct {
	// ...
	fset   *token.FileSet
	output bytes.Buffer // io.Writerからbytes.Bufferに変更
	// ...
}

この変更が最も重要です。printerが直接外部のio.Writerに書き込むのではなく、内部のbytes.Bufferに書き込むようになりました。これにより、Write呼び出しの頻度が大幅に減り、パフォーマンスが向上します。writtenフィールドはbytes.BufferLen()メソッドで代替できるため削除されました。

printer.initメソッドの変更:

// 変更前
func (p *printer) init(output io.Writer, cfg *Config, fset *token.FileSet, nodeSizes map[ast.Node]int) {
	p.output = output
	// ...
}

// 変更後
func (p *printer) init(cfg *Config, fset *token.FileSet, nodeSizes map[ast.Node]int) {
	// p.outputはbytes.Buffer型なので、ここでは初期化不要(ゼロ値で十分)
	p.Config = *cfg
	p.fset = fset
	// ...
}

printerの初期化時にio.Writerを受け取らなくなりました。bytes.Bufferは構造体のゼロ値で利用可能であり、明示的な初期化は不要です。

printer.write0メソッドの削除とprinter.writeの変更:

write0メソッドは、直接io.Writerにバイトスライスを書き込む役割を担っていましたが、bytes.Bufferの導入により不要になりました。

// 変更前のwrite0メソッド (削除された)
// func (p *printer) write0(data string) { ... }

// 変更後のwriteメソッドの一部
func (p *printer) write(data string) {
	// ...
	// 以前は p.write0(data[i0 : i+1]) だった部分
	p.output.WriteString(data[i0 : i+1]) // bytes.Buffer.WriteStringを使用
	// ...
	// 以前は p.write0(data[i0:]) だった部分
	p.output.WriteString(data[i0:]) // bytes.Buffer.WriteStringを使用
	// ...
}

writeメソッド内で、以前p.write0を呼び出していた箇所が、直接p.output.WriteStringを呼び出すように変更されました。これにより、文字列が直接bytes.Bufferに追加され、中間的な[]byte変換やio.Writerへの頻繁な呼び出しがなくなりました。

printer.printNodeメソッドの追加:

func (p *printer) printNode(node interface{}) error {
	switch n := node.(type) {
	case ast.Expr:
		p.useNodeComments = true
		p.expr(n, ignoreMultiLine)
	// ... 他のASTノードタイプ
	default:
		return fmt.Errorf("go/printer: unsupported node type %T", n)
	}
	return nil
}

この新しいメソッドは、与えられたASTノードの型に応じて、適切な整形ロジック(p.expr, p.stmt, p.declなど)を呼び出す役割を担います。以前はConfig.fprint内に直接記述されていたこのロジックが分離され、コードの構造が整理されました。また、エラーをpanicではなくerrorとして返すようになりました。

Config.fprintメソッドの変更:

// 変更前
// func (cfg *Config) fprint(output io.Writer, fset *token.FileSet, node interface{}, nodeSizes map[ast.Node]int) (written int, err error) {
// ...
// 変更後
func (cfg *Config) fprint(output io.Writer, fset *token.FileSet, node interface{}, nodeSizes map[ast.Node]int) (err error) {
	// print node
	var p printer
	p.init(cfg, fset, nodeSizes) // printerを初期化
	if err = p.printNode(node); err != nil { // 新しいprintNodeを呼び出し
		return
	}
	p.flush(token.Position{Offset: infinity, Line: infinity}, token.EOF)

	// ... (trimmer, tabwriterの設定)

	// write printer result via tabwriter/trimmer to output
	if _, err = output.Write(p.output.Bytes()); err != nil { // ここでbytes.Bufferの内容をまとめて出力
		return
	}

	// ... (tabwriterのフラッシュ)
	return
}

Config.fprintは、go/printerパッケージの主要なエントリポイントの一つです。このメソッド内で、printer構造体が初期化され、printNodeメソッドが呼び出されてASTの整形が行われます。整形結果はp.outputbytes.Buffer)に蓄積されます。

最も重要な変更は、整形処理が完了した後、p.output.Bytes()を呼び出してbytes.Bufferの内容全体を取得し、それを一度だけ最終的なoutput io.Writerに書き込んでいる点です。これにより、io.WriterへのWrite呼び出しが1回(またはtabwriterのフラッシュを含めて数回)に削減され、大幅なパフォーマンス向上が実現されました。

また、以前はpanic/recoverで処理されていたエラーが、printNodeからのerror戻り値として直接処理されるようになり、よりGoらしいエラーハンドリングになりました。

関連リンク

参考にした情報源リンク