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

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

このコミットは、Go言語のcmd/apiツールにおけるパフォーマンス改善を目的としています。具体的には、parser.ParseFileの呼び出し結果をキャッシュすることで、APIチェックの速度を約2倍に向上させています。これにより、開発者のマシンでの実行時間が5秒短縮される効果がありました。この変更は、GoのAST(Abstract Syntax Tree)の深いコピー(クローン)メカニズムを一時的に導入することで実現されており、関連するIssue 4380が修正されれば、このクローン処理は不要になる予定です。

コミット

commit aeca7a7cd21cb512ac19fa2f8d0555fb13bdd8f3
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date:   Mon Nov 19 13:50:20 2012 -0800

    cmd/api: speed up API check by 2x, caching parser.ParseFile calls
    
    Saves 5 seconds on my machine. If Issue 4380 is fixed this
    clone can be removed.
    
    Update #4380
    
    R=golang-dev, remyoudompheng, minux.ma, gri
    CC=golang-dev
    https://golang.org/cl/6845058

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

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

元コミット内容

cmd/api: speed up API check by 2x, caching parser.ParseFile calls

Saves 5 seconds on my machine. If Issue 4380 is fixed this
clone can be removed.

Update #4380

変更の背景

このコミットの主な背景は、cmd/apiツールの実行速度の改善です。cmd/apiは、Goの標準ライブラリのAPI互換性をチェックするためのツールであり、その性質上、多くのGoソースファイルを解析する必要があります。parser.ParseFile関数は、Goのソースファイルを解析してASTを構築する処理を行いますが、この処理は比較的コストが高いです。

コミットメッセージによると、この変更により開発者のマシンで5秒の高速化が実現され、APIチェックの速度が約2倍になったとされています。これは、開発サイクルにおいて大きな改善となります。

また、この変更は「Issue 4380が修正されれば、このクローンは削除できる」と明記されており、一時的な解決策であることが示唆されています。Issue 4380は、go/parserパッケージが返すASTノードが、内部的に共有される可能性のある構造を持っていることに関連していると考えられます。もしASTノードが共有されている場合、キャッシュされたASTを直接再利用すると、後続の処理でASTが変更された際に、キャッシュされたASTも意図せず変更されてしまう可能性があります。これを避けるために、キャッシュから取得したASTを深いコピー(クローン)して使用する必要がありました。

前提知識の解説

cmd/apiツール

cmd/apiは、Go言語の標準ライブラリのAPI互換性を検証するためのツールです。Go言語では、後方互換性が非常に重視されており、新しいバージョンでAPIが変更される際には、既存のコードが壊れないように細心の注意が払われます。cmd/apiは、Goのリリースプロセスの一部として、APIの変更が互換性を損なわないことを確認するために使用されます。具体的には、Goのソースコードを解析し、公開されているAPI(エクスポートされた型、関数、変数など)のシグネチャや構造を抽出し、以前のバージョンと比較することで、互換性のない変更がないかを検出します。

go/astパッケージ

go/astパッケージは、Go言語のソースコードの抽象構文木(Abstract Syntax Tree, AST)を表現するためのデータ構造を提供します。ASTは、プログラムのソースコードを木構造で表現したもので、コンパイラやリンター、コード分析ツールなどがソースコードを理解し、操作するために利用します。go/astパッケージには、ファイル、宣言、式、ステートメントなどのGo言語の構文要素に対応する様々な構造体(例: ast.File, ast.FuncDecl, ast.Exprなど)が定義されています。

go/parserパッケージ

go/parserパッケージは、Go言語のソースコードを解析し、go/astパッケージで定義されたASTを構築するための機能を提供します。このパッケージの主要な関数の一つがparser.ParseFileです。parser.ParseFileは、指定されたGoソースファイルのパスを読み込み、その内容を解析して*ast.File型のASTルートノードを返します。このASTは、その後のコード分析や変換の基盤となります。

ASTの深いコピー(Deep Copy)と浅いコピー(Shallow Copy)

  • 浅いコピー(Shallow Copy): オブジェクトの最上位の構造のみをコピーし、そのオブジェクトが参照している他のオブジェクトはコピーせず、元のオブジェクトと同じ参照を共有します。つまり、コピーされたオブジェクトと元のオブジェクトが同じ内部オブジェクトを指すため、一方のオブジェクトの内部オブジェクトを変更すると、もう一方のオブジェクトにも影響が及びます。
  • 深いコピー(Deep Copy): オブジェクトとそのオブジェクトが参照しているすべてのオブジェクトを再帰的にコピーします。これにより、コピーされたオブジェクトは元のオブジェクトとは完全に独立した新しいオブジェクトツリーを持つため、一方のオブジェクトを変更してももう一方には影響しません。

このコミットでは、parser.ParseFileが返す*ast.Fileオブジェクトが、内部的に共有される可能性のある構造を持っているため、キャッシュから取得したASTを安全に利用するために深いコピー(クローン)が必要とされました。

Issue 4380

GoのIssue 4380は、「go/parserが返すASTノードは、go/token.FileSetの内部構造を共有しているため、go/token.FileSetが変更されるとASTノードも変更される可能性がある」という問題に関連しています。この問題が修正されれば、parser.ParseFileが返すASTは完全に独立したものとなり、深いコピーを行う必要がなくなると考えられます。

技術的詳細

このコミットの技術的な核心は、cmd/apiツールがGoソースファイルを解析する際のパフォーマンスボトルネックを解消するために、ASTのキャッシュと深いコピー(クローン)を導入した点にあります。

  1. parser.ParseFileのキャッシュ化:

    • goapi.goparsedFileCacheというmap[string]*ast.File型のグローバル変数が追加されました。これは、ファイルパスをキーとして、解析済みの*ast.Fileオブジェクトを保存するためのキャッシュです。
    • parseFileという新しいヘルパー関数が導入されました。この関数は、まずparsedFileCacheに指定されたファイルパスのASTが存在するかどうかを確認します。
    • もしキャッシュに存在すれば、そのキャッシュされたASTを返します。
    • キャッシュに存在しない場合は、parser.ParseFileを呼び出してファイルを解析し、結果をキャッシュに保存してから返します。
  2. ASTの深いコピー(クローン):

    • キャッシュされたASTを直接返すと、その後の処理でASTが変更された場合に、キャッシュされたASTも変更されてしまい、予期せぬ副作用を引き起こす可能性があります。これを防ぐため、キャッシュからASTを取得する際に、clone関数を使用してASTの深いコピーを作成しています。
    • clone.goという新しいファイルが追加され、clone関数が定義されています。この関数は、interface{}を受け取り、GoのASTパッケージで定義されている様々なASTノード型(*ast.File, *ast.GenDecl, *ast.FuncTypeなど)に対応するswitch文を持ち、それぞれの型に対して再帰的に深いコピーを作成します。
    • clone関数は、reflect.DeepEqualを用いたデバッグ用のチェックも含まれており、クローンが元のオブジェクトと等しいことを検証できます(debugClonetrueの場合)。
  3. cmd/apiの既存コードの変更:

    • src/cmd/api/goapi.goWalkPackage関数内で、ファイルの解析に直接parser.ParseFileを呼び出す代わりに、新しく導入されたparseFileヘルパー関数を使用するように変更されました。これにより、ファイルの解析結果がキャッシュされるようになります。

このアプローチにより、同じファイルを複数回解析する必要がある場合に、2回目以降の解析ではキャッシュからASTを取得し、その深いコピーを使用することで、parser.ParseFileのコストの高い処理をスキップできるようになり、全体的なパフォーマンスが向上します。

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

src/cmd/api/clone.go (新規ファイル)

// 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 main

import (
	"fmt"
	"go/ast"
	"log"
	"reflect"
)

const debugClone = false

// TODO(bradfitz): delete this function (and whole file) once
// http://golang.org/issue/4380 is fixed.
func clone(i interface{}) (cloned interface{}) {
	if debugClone {
		defer func() {
			if !reflect.DeepEqual(i, cloned) {
				log.Printf("cloned %T doesn't match: in=%#v out=%#v", i, i, cloned)
			}
		}()
	}
	switch v := i.(type) {
	case nil:
		return nil
	case *ast.File:
		o := &ast.File{
			Doc:      v.Doc, // shallow
			Package:  v.Package,
			Comments: v.Comments, // shallow
			Name:     v.Name,
			Scope:    v.Scope,
		}
		for _, x := range v.Decls {
			o.Decls = append(o.Decls, clone(x).(ast.Decl))
		}
		for _, x := range v.Imports {
			o.Imports = append(o.Imports, clone(x).(*ast.ImportSpec))
		}
		for _, x := range v.Unresolved {
			o.Unresolved = append(o.Unresolved, x)
		}
		return o
	// ... (他のastノード型のクローン処理が続く)
	case *ast.Ident, *ast.BasicLit:
		return v
	// ... (他のastノード型のクローン処理が続く)
	}
	panic(fmt.Sprintf("Uncloneable type %T", i))
}

func cloneExpr(x ast.Expr) ast.Expr {
	if x == nil {
		return nil
	}
	return clone(x).(ast.Expr)
}

func cloneExprs(x []ast.Expr) []ast.Expr {
	if x == nil {
		return nil
	}
	o := make([]ast.Expr, len(x))
	for i, x := range x {
		o[i] = cloneExpr(x)
	}
	return o
}

src/cmd/api/goapi.go

--- a/src/cmd/api/goapi.go
+++ b/src/cmd/api/goapi.go
@@ -353,6 +353,21 @@ func fileDeps(f *ast.File) (pkgs []string) {
 	return
 }
 
+var parsedFileCache = make(map[string]*ast.File)
+
+func parseFile(filename string) (*ast.File, error) {
+	f, ok := parsedFileCache[filename]
+	if !ok {
+		var err error
+		f, err = parser.ParseFile(fset, filename, nil, 0)
+		if err != nil {
+			return nil, err
+		}
+		parsedFileCache[filename] = f
+	}
+	return clone(f).(*ast.File), nil
+}
+
 // WalkPackage walks all files in package `name\'.
 // WalkPackage does nothing if the package has already been loaded.
 func (w *Walker) WalkPackage(name string) {
@@ -386,7 +401,7 @@ func (w *Walker) WalkPackage(name string) {
 
 	files := append(append([]string{}, info.GoFiles...), info.CgoFiles...)\n 	for _, file := range files {\n-\t\tf, err := parser.ParseFile(fset, filepath.Join(dir, file), nil, 0)\n+\t\tf, err := parseFile(filepath.Join(dir, file))\n \t\tif err != nil {\n \t\t\tlog.Fatalf(\"error parsing package %s, file %s: %v\", name, file, err)\n \t\t}\n```

### `src/cmd/api/testdata/src/pkg/p1/p1.go`

このファイルはテストデータであり、新しいASTノードタイプ(`...string`, `&S{}`, `(1+5)`, `func(){}`, `map[string]int`, `chan int`, `interface{}`, `ifaceVar.(int)`, `m["foo"]`)が`clone`関数で正しく処理されることを確認するために追加されたものです。

```diff
--- a/src/cmd/api/testdata/src/pkg/p1/p1.go
+++ b/src/cmd/api/testdata/src/pkg/p1/p1.go
@@ -167,3 +167,25 @@ const (
 	foo2  string = "foo2"
 	truth        = foo == "foo" || foo2 == "foo2"
 )
+
+func ellipsis(...string) {}
+
+var x = &S{
+	Public:     nil,
+	private:    nil,
+	publicTime: time.Now(),
+}
+
+var parenExpr = (1 + 5)
+
+var funcLit = func() {}
+
+var m map[string]int
+
+var chanVar chan int
+
+var ifaceVar interface{} = 5
+
+var assertVar = ifaceVar.(int)
+
+var indexVar = m["foo"]

コアとなるコードの解説

clone.go

このファイルは、GoのASTノードを深いコピーするためのclone関数を定義しています。

  • clone(i interface{}) (cloned interface{}):
    • この関数は、interface{}型の引数iを受け取り、その深いコピーを返します。
    • debugClonetrueの場合、reflect.DeepEqualを使ってクローンが元のオブジェクトと等しいかどうかの検証を行います。これはデバッグ目的で、クローン処理が正しく行われているかを確認するために使用されます。
    • switch v := i.(type)文を使って、入力されたinterface{}の具体的な型を判別します。
    • caseブロックでは、対応するastパッケージの構造体(例: *ast.File, *ast.GenDecl, *ast.FuncTypeなど)に対して、新しいインスタンスを作成し、そのフィールドを再帰的にクローンしていきます。
    • 例えば、*ast.Fileの場合、Doc, Package, Comments, Name, Scopeなどのフィールドは浅いコピー(または直接代入)されますが、Decls, Imports, Unresolvedなどのスライスやポインタを含むフィールドは、clone関数を再帰的に呼び出して深いコピーを作成しています。
    • *ast.Ident*ast.BasicLitのようなプリミティブなASTノード(それ以上内部にポインタを持たないもの)は、直接値を返しています。
    • 対応する型が見つからない場合はpanicを発生させ、クローンできない型であることを示します。
  • cloneExpr(x ast.Expr) ast.Expr:
    • ast.Expr型の式をクローンするためのヘルパー関数です。clone関数を呼び出し、結果をast.Expr型にキャストして返します。
  • cloneExprs(x []ast.Expr) []ast.Expr:
    • ast.Expr型のスライスの要素をそれぞれクローンするためのヘルパー関数です。新しいスライスを作成し、各要素に対してcloneExprを呼び出して深いコピーを作成します。

このclone関数は、go/parserが返すASTが内部的に共有される可能性のある構造を持っているため、キャッシュから取得したASTを安全に操作するために必要とされました。Issue 4380が解決されれば、このファイル全体が削除される予定です。

goapi.go

このファイルは、cmd/apiツールの主要なロジックを含んでいます。

  • var parsedFileCache = make(map[string]*ast.File):
    • ファイルパスをキーとし、解析済みの*ast.Fileを値とするマップです。これがASTのキャッシュとして機能します。
  • func parseFile(filename string) (*ast.File, error):
    • この新しい関数は、ファイルの解析処理をカプセル化し、キャッシュロジックを追加します。
    • まず、parsedFileCachefilenameが存在するかどうかを確認します。
    • oktrue(キャッシュヒット)の場合、キャッシュされたfclone(f).(*ast.File)として深いコピーを作成し、それを返します。これにより、キャッシュされたASTが後続の処理で変更されることを防ぎます。
    • okfalse(キャッシュミス)の場合、parser.ParseFileを呼び出してファイルを実際に解析します。
    • 解析が成功した場合、結果のfparsedFileCacheに保存し、そのfの深いコピーを返します。
  • func (w *Walker) WalkPackage(name string)内の変更:
    • 以前はparser.ParseFile(fset, filepath.Join(dir, file), nil, 0)を直接呼び出していましたが、このコミットでparseFile(filepath.Join(dir, file))に置き換えられました。
    • これにより、WalkPackageがファイルを解析する際に、キャッシュが利用され、パフォーマンスが向上します。

この変更により、cmd/apiツールが同じGoソースファイルを複数回解析する必要がある場合に、2回目以降の解析ではキャッシュからASTを取得し、その深いコピーを使用することで、parser.ParseFileの実行をスキップできるようになり、全体的な処理時間が大幅に短縮されます。

関連リンク

参考にした情報源リンク