[インデックス 13245] ファイルの概要
このコミットは、Go言語の標準ライブラリであるgo/parserパッケージにおけるパフォーマンス改善を目的としています。具体的には、Goソースコードのパース処理を高速化するために、コメントの処理方法を最適化しています。変更されたファイルは以下の2つです。
src/pkg/go/parser/parser.go: Goソースコードの構文解析(パース)を行う主要なロジックが含まれています。このファイルでは、コメントグループを消費するconsumeCommentGroup関数と、次のトークンを読み込むnext関数が変更されています。特にnext関数内で、現在の行位置の計算を最適化する変更が加えられました。src/pkg/go/parser/performance_test.go: このコミットで新たに追加されたベンチマークファイルです。go/parserパッケージのパース性能を測定するためのBenchmarkParse関数が定義されており、parser.goファイルを読み込んでパースする処理の時間を計測します。
コミット
a04d4f02a4ff68e0ef7a222d6e301225877ded90
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/a04d4f02a4ff68e0ef7a222d6e301225877ded90
元コミット内容
go/parser: ~15% faster parsing
- only compute current line position if needed
(i.e., if a comment is present)
- added benchmark
benchmark old ns/op new ns/op delta
BenchmarkParse 10902990 9313330 -14.58%
benchmark old MB/s new MB/s speedup
BenchmarkParse 5.31 6.22 1.17x
R=golang-dev, r
CC=golang-dev
https://golang.org/cl/6270043
変更の背景
このコミットの主な目的は、Go言語のソースコードを解析するgo/parserパッケージのパフォーマンスを向上させることです。コミットメッセージに明記されているように、約15%のパース速度向上を達成しています。
Go言語のツールチェインにおいて、ソースコードのパースはコンパイル、静的解析、コードフォーマットなど、多くの処理の基盤となります。パース処理が高速であればあるほど、これらのツールの実行時間も短縮され、開発者の生産性向上に直結します。
以前の実装では、go/parserが次のトークンを読み込む際に、常に現在の行位置を計算していました。しかし、この行位置の計算は、特にコメントが存在しない場合には不要なオーバーヘッドとなることが判明しました。コメントの処理は、そのコメントが前のトークンと同じ行にあるか、あるいは新しい行で始まるリードコメントであるかを判断するために行位置情報が必要となりますが、それ以外の通常のトークン処理では必ずしも必要ではありません。
この非効率性を解消し、パース処理全体のボトルネックを緩和するために、行位置の計算を「必要な場合のみ」(すなわち、コメントが存在する場合)に限定するという最適化が考案され、このコミットで実装されました。この変更により、パース処理の効率が向上し、ベンチマーク結果が示すように顕著な速度改善が実現されました。
前提知識の解説
go/parserパッケージ
go/parserは、Go言語のソースコードを解析し、抽象構文木(AST: Abstract Syntax Tree)を生成するための標準パッケージです。ASTは、ソースコードの構造を木構造で表現したもので、コンパイラ、リンタ、コードフォーマッタ、IDEなどの様々なツールで利用されます。
パース処理は通常、以下の段階を経て行われます。
- 字句解析(Lexical Analysis / Tokenization): ソースコードを最小単位の「トークン」(キーワード、識別子、演算子、リテラルなど)のストリームに分解します。
go/tokenパッケージがこの役割を担います。 - 構文解析(Syntactic Analysis / Parsing): トークンのストリームを文法規則に従って解析し、ASTを構築します。
go/parserは、go/token.FileSetとgo/astパッケージと密接に連携して動作します。
go/token.FileSet: ソースファイルの位置情報(行番号、列番号、オフセットなど)を管理します。パース中にエラーが発生した場合の正確な位置特定や、デバッグ情報のために不可欠です。FileSetは複数のファイルを扱うことができ、各ファイル内の位置を効率的にマッピングします。go/ast: 抽象構文木(AST)のノード型を定義しています。go/parserが生成するASTは、このパッケージで定義された構造体で構成されます。ast.CommentGroup: Go言語のコメントは、単なる無視される要素ではなく、ASTの一部として扱われます。ast.CommentGroupは、連続するコメントのグループを表す構造体で、コードのドキュメンテーションやツールによる解析に利用されます。
Go言語のベンチマーク
Go言語には、標準でベンチマークテストを記述・実行するための機能が組み込まれています。testingパッケージの一部として提供されており、関数名のプレフィックスをBenchmarkとすることで、その関数がベンチマークであることを示します。
go test -bench=.: カレントディレクトリ内のすべてのベンチマークを実行します。b *testing.B: ベンチマーク関数は*testing.B型の引数を受け取ります。このオブジェクトは、ベンチマークの実行回数(b.N)や、測定対象のバイト数(b.SetBytes)などの情報を提供します。b.N: ベンチマーク関数内のループはb.N回実行されます。testingパッケージは、安定した測定結果を得るために、b.Nの値を自動的に調整します。b.SetBytes(int64(len(src))): このメソッドを呼び出すことで、ベンチマークが処理するバイト数を指定できます。これにより、結果が「MB/s」(1秒あたりのメガバイト数)として表示され、処理スループットを評価するのに役立ちます。ns/op: 1操作あたりのナノ秒。値が小さいほど高速であることを示します。MB/s: 1秒あたりのメガバイト数。値が大きいほどスループットが高いことを示します。
このコミットでは、BenchmarkParseというベンチマークが追加され、go/parserのパース性能が定量的に評価されています。
技術的詳細
このコミットの技術的な核心は、go/parserのnext()メソッドにおける行位置計算の最適化です。
parser構造体には、現在のパース位置を示すp.posというフィールドがあります。p.file.Line(p.pos)は、このp.posがFileSet内のどの行に位置するかを計算するメソッドです。この計算は、FileSetが管理する行オフセットテーブルを検索する必要があるため、ある程度のコストがかかります。
変更前のコードでは、next()メソッドの冒頭で常にline := p.file.Line(p.pos)が呼び出され、現在のトークンの開始行が取得されていました。このline変数は、その後のコメント処理(if p.tok == token.COMMENTブロック内)で、コメントが前のトークンと同じ行にあるかどうかを判断するために使用されていました。
// 変更前 (parser.go)
func (p *parser) next() {
p.leadComment = nil
p.lineComment = nil
line := p.file.Line(p.pos) // current line - ここで常に計算されていた
p.next0()
if p.tok == token.COMMENT {
var comment *ast.CommentGroup
var endline int
if p.file.Line(p.pos) == line { // ここで比較に使われる
// The comment is on same line as the previous token; it
// cannot be a lead comment but may be a line comment.
comment, endline = p.consumeCommentGroup(0)
// ...
}
// ...
}
// ...
}
この最適化では、line := p.file.Line(p.pos)の代わりに、まずprev := p.posとして、現在のトークンの開始オフセット(バイト位置)を保存します。そして、コメントが存在する場合にのみ、p.file.Line(p.pos) == p.file.Line(prev)という形で、現在のコメントの開始行と、前のトークンの開始行を比較します。
// 変更後 (parser.go)
func (p *parser) next() {
p.leadComment = nil
p.lineComment = nil
prev := p.pos // 行番号ではなく、オフセットを保存
p.next0()
if p.tok == token.COMMENT {
var comment *ast.CommentGroup
var endline int
if p.file.Line(p.pos) == p.file.Line(prev) { // コメントが存在する場合のみ行番号を計算し比較
// The comment is on same line as the previous token; it
// cannot be a lead comment but may be a line comment.
comment, endline = p.consumeCommentGroup(0)
// ...
}
// ...
}
// ...
}
この変更により、p.file.Line()の呼び出しが、コメントトークンが検出された場合に限定されるため、コメントが少ない、あるいは全くないGoソースコードのパースにおいて、不要な行位置計算のオーバーヘッドが削減されます。p.posは単なるバイトオフセットであり、その取得はp.file.Line(p.pos)に比べて非常に安価です。この小さな変更が、ベンチマーク結果が示すように、全体として約15%のパース速度向上という大きな効果をもたらしました。
コアとなるコードの変更箇所
src/pkg/go/parser/parser.go
--- a/src/pkg/go/parser/parser.go
+++ b/src/pkg/go/parser/parser.go
@@ -296,14 +296,14 @@ func (p *parser) consumeCommentGroup(n int) (comments *ast.CommentGroup, endline
func (p *parser) next() {
p.leadComment = nil
p.lineComment = nil
- line := p.file.Line(p.pos) // current line
+ prev := p.pos
p.next0()
if p.tok == token.COMMENT {
var comment *ast.CommentGroup
var endline int
- if p.file.Line(p.pos) == line {
+ if p.file.Line(p.pos) == p.file.Line(prev) {
// The comment is on same line as the previous token; it
// cannot be a lead comment but may be a line comment.
comment, endline = p.consumeCommentGroup(0)
src/pkg/go/parser/performance_test.go (新規ファイル)
--- /dev/null
+++ b/src/pkg/go/parser/performance_test.go
@@ -0,0 +1,30 @@
+// Copyright 2012 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package parser
+
+import (
+ "go/token"
+ "io/ioutil"
+ "testing"
+)
+
+var src = readFile("parser.go")
+
+func readFile(filename string) []byte {
+ data, err := ioutil.ReadFile(filename)
+ if err != nil {
+ panic(err)
+ }
+ return data
+}
+
+func BenchmarkParse(b *testing.B) {
+ b.SetBytes(int64(len(src)))
+ for i := 0; i < b.N; i++ {
+ if _, err := ParseFile(token.NewFileSet(), "", src, ParseComments); err != nil {
+ b.Fatalf("benchmark failed due to parse error: %s", err)
+ }
+ }
+}
コアとなるコードの解説
src/pkg/go/parser/parser.go の変更
parser.goのnext()メソッドは、go/parserがソースコードをトークン単位で読み進める際に呼び出される重要な関数です。この関数は、次のトークンを読み込み、そのトークンに先行するコメントや行末コメントを処理する役割を担っています。
変更前は、next()の冒頭で以下の行がありました。
line := p.file.Line(p.pos) // current line
これは、現在のパース位置p.posに対応する行番号をp.file(token.File型)から取得していました。このline変数は、その後のif p.tok == token.COMMENTブロック内で、コメントが前のトークンと同じ行にあるかどうかを判断するために使用されていました。
変更後は、この行が以下のように置き換えられました。
prev := p.pos
ここでは、行番号を計算する代わりに、単に現在のパース位置(バイトオフセット)をprev変数に保存しています。p.posはtoken.Pos型であり、これは単なるintのエイリアスで、ファイル内のバイトオフセットを表します。この操作は、行番号を計算するよりもはるかに高速です。
そして、コメントを処理するif p.tok == token.COMMENTブロック内の条件式も変更されました。
変更前:
if p.file.Line(p.pos) == line {
変更後:
if p.file.Line(p.pos) == p.file.Line(prev) {
この変更により、コメントが存在する場合にのみ、p.file.Line()メソッドが2回呼び出され、現在のコメントの開始行と、前のトークンの開始行(prevオフセットに対応する行)が比較されます。コメントが存在しない通常のトークンを処理する際には、p.file.Line()の呼び出しが完全にスキップされるため、パフォーマンスが向上します。
src/pkg/go/parser/performance_test.go の追加
このファイルは、go/parserのパース性能を測定するための新しいベンチマークテストを導入しています。
readFile(filename string) []byte: ヘルパー関数として、指定されたファイルの内容をバイトスライスとして読み込みます。ベンチマーク対象のソースコードをメモリにロードするために使用されます。var src = readFile("parser.go"):go/parser自身のソースコード(parser.go)をベンチマークの入力として使用しています。これは、実際のGoコードのパースをシミュレートする現実的なシナリオです。func BenchmarkParse(b *testing.B): このベンチマーク関数が、パース処理の性能を測定します。b.SetBytes(int64(len(src))): 処理されるバイト数を設定し、結果にMB/sのメトリクスを含めるようにします。for i := 0; i < b.N; i++: ベンチマークのコアとなるループです。b.Nはtestingパッケージによって動的に調整されます。if _, err := ParseFile(token.NewFileSet(), "", src, ParseComments); err != nil:go/parser.ParseFile関数を呼び出して、src(parser.goの内容)をパースします。ParseCommentsオプションは、コメントもASTに含めるように指定しており、今回の最適化がコメント処理に関連するため、このオプションは適切です。パースエラーが発生した場合は、ベンチマークを失敗させます。
このベンチマークの追加により、将来の変更がパース性能に与える影響を継続的に監視できるようになり、回帰テストとしても機能します。
関連リンク
- Go CL 6270043: https://golang.org/cl/6270043
参考にした情報源リンク
- Go言語の
go/parserパッケージ: https://pkg.go.dev/go/parser - Go言語の
go/tokenパッケージ: https://pkg.go.dev/go/token - Go言語の
go/astパッケージ: https://pkg.go.dev/go/ast - Go言語のベンチマークテスト: https://pkg.go.dev/testing#hdr-Benchmarks
- Go言語のソースコード解析: https://go.dev/blog/go-ast