効率的なGo

効率的なGo

に公開

本記事は、書籍『効率的なGo』(著:Bartłomiej Płotka、訳:山口能迪、オライリー・ジャパン刊、2024年)の要点を章ごとに整理した、個人的な学習メモです。原書は Efficient Go(O’Reilly、2023年)です。

本書の目的

  • ソフトウェアの効率(efficiency) を、勘や流行ではなくデータ(計測)に基づいて改善するための、実践的な考え方と手法を示すこと
  • 「とりあえず速くする」のではなく、要件定義 → 計測 → ボトルネック分析 → 最適化 → 検証という再現可能なサイクルで性能を改善できるようにすること
  • そのために必要な土台(Goランタイム・CPU・メモリの仕組み、オブザーバビリティ、ベンチマーク、プロファイリング)を一冊で見渡せるようにすること

1章 ソフトウェア効率性が重要

本章は本書全体の導入として、「性能(パフォーマンス)」という言葉を正確に定義し直し、効率(efficiency)と速度(speed)の関係を整理します。そのうえで、開発現場で性能最適化を後回しにさせる5つのよくある誤解に反論し、なぜ効率を起点に考えると性能改善が容易になるのかという、本書を貫く考え方を提示します。

性能の背後にあるもの

ソフトウェア技術者の本質的な仕事は、保守可能で有用なソフトウェアをコスト効率よく開発することです。開発コストは初期費用だけでなく、運用・保守・計算コスト(消費電力を含む)を含む**総所有コスト(TCO)**として考える必要があり、しばしば運用・保守コストのほうが初期開発コストを上回ります。

「性能(パフォーマンス)」という言葉は文脈によって意味が異なり、多くの人が単に「速度(スピード)」の同義語として使ってしまいます。ある語が本来と違う意味で広まる現象を**意味拡散(semantic diffusion)**と呼びます。本書では性能を「ソフトウェアがどれだけうまく動くか」と捉え、改善や犠牲の対象になる3つの核となる要素に分解します。

  • 精度(accuracy) — タスク達成中に起こしたエラーの数。Webシステムなら200以外のステータスで終わったリクエスト数などで計測できる。
  • 速度(speed) — 作業をどれだけ速く行うか。レイテンシーやスループットで観察できる。
  • 効率(efficiency) — 供給したエネルギーに対する有用なエネルギーの比率。課題達成に余分なリソース(無駄)をどれだけ使ったかを示す。
性能 = ( 精度 × 効率 × 速度 )

いずれかを改善すれば性能は向上し、いずれかを無視すれば有用性は低下します。本書はとりわけ、精度を落とさずに効率と速度を高めることに焦点を当てます。著者が(リソースを明示せずに)「効率」と言う場合、リソース消費の少なさと速度(レイテンシー)の両方を意味します。

なお、誰かが「性能」という言葉を使ったら、必ず具体的に何を指すのかを確認しましょう。変更履歴やバグ報告でも「性能が向上した」では不十分で、「どの負荷で何秒速くなったか」「RAM使用量がどれだけ減ったか」のように明示すべきです。

よくある効率に関する誤解

最適化を開発の優先順位の最下位に置く言い訳として、いくつもの固定観念が使われます。著者は、最適化の反対概念である早すぎる悲観化(premature pessimization)、つまり同じ計算量で書けるのにわざと非効率なコードを選んでしまうことこそが本当の問題だと指摘し、5つの誤解に反論します。

誤解1: 最適化されたコードは可読性がない

可読性は最重要の品質ですが、効率と可読性は共存できます。バイトシフトやアセンブリのような極端な低レベル最適化はまれで、多くの効率改善は読みやすさを損ないません。むしろ最適化でコードがより読みやすくなることもあります。

[Before] Get() を3回呼ぶ  → 競合状態のリスク・非効率
[After]  got := Get() で1回呼んで再利用
         → 純粋関数に近づき、安全で、意図も明確

事前割り当て(make([]string, 0, n*7))も1行増えるだけで、ループの反復回数という意図を読者に伝えます。読みやすいコードを最適化するほうが、最適化済みコードを読みやすくするより簡単です。これは人間とコンパイラーの双方に当てはまります。可読性を保つコツは、不要な最適化を避ける、複雑さを抽象化(インターフェイス等)の背後に隠す、頻繁に実行される「ホット」コードとめったに実行されない「コールド」コードを分離することです。

誤解2: そんな機能は必要ない(YAGNI)

YAGNI(You Aren’t Going to Need It) は、現在の要件に厳密に必要でない作業を遅らせる有用な原則です。しかし、これは効率を完全に無視する許可証ではありません。要件に明示されていなくても、可読性を下げない小さな効率化(事前割り当てなど)は、バージョン管理や小さなインターフェイスと同じく開発者の仕事の本質的な一部です。過剰なリソース消費は、後でまとめて直せる単一の原因ではなく、無数の小さな積み重ねから生じます。

誤解3: ハードウェアの高速化・低価格化に任せればよい

「ハードウェアの償却額は開発者の時間より安い」という意見は近視眼的です。次の3つの理由でハードウェアの進歩だけでは追いつきません。

  • パーキンソンの法則 — リソースは供給されただけ需要が膨らむ。ソフトウェアはハードウェアが速くなるより速く肥大化(ファットソフトウェア)する。テラバイト規模の割り当ては高トラフィックなサービスで日常的に起こる。
  • 技術的限界ムーアの法則デナード則が約束した1ワットあたり性能の指数的向上は、2006年頃にデナード・スケーリングの物理的限界に達した。クロック速度は3〜4GHzで頭打ちとなり、これ以上はコア数を増やすしかない。
  • ハードウェアを足せばそれを運用・監視するソフトとエンジニアのコストも増え、振り出しに戻る。

さらに、実行が速いほど消費電力は少なくなるため、速度・効率とエネルギー効率には強い相関があります。効率化は古いハードや低速回線のユーザーへのアクセシビリティとインクルーシブネスの向上にもつながります。

誤解4: かわりに水平方向へスケールすればよい

スケーラビリティには2種類あります。

垂直方向: より大きなマシン(CPU/RAM増)で実行 → 単純だが限界・高価・隠れた単一障害点
水平方向: 小さく安価な複数マシンに分散 → 柔軟だが分散システムの複雑さが激増

水平スケーリングはネットワークが絡むためCAP定理(一貫性・可用性・分断耐性のうち2つしか達成できない)に直面し、非常に難しくなります。著者によれば早すぎるスケーラビリティは早すぎる効率最適化より悪いといいます。Thanosのコンパクションの事例では、効率を無視したことで分散システム導入の圧力に追い込まれましたが、コードを再設計して2倍高速化・メモリ削減を達成し、複雑なスケーリングを回避できました。複雑さを避けようとすると、かえって大きな複雑さを招くのです。

誤解5: 市場投入までの時間(time-to-market)がより重要

出荷の遅れは確かに損失を生みますが、性能の悪い製品が生むコストを無視してはいけません。『サイバーパンク2077』は深刻な性能問題により発売5日で株価の3分の1を失いました。逆にAmazonは読み込みの1秒の遅れで年間16億ドルの損失、Googleは検索が遅くなるとトラフィック20%減を確認しています。市場では機能よりも効率(リソース消費の少なさ)のほうが選ばれやすいのです。time-to-marketは重要なので、開発プロセスに適量の効率化作業をバランスよく組み込み、非機能目標(リソースを考慮した性能要件)を早期に設定することが解決策になります。

実用的なコード性能を実現するためのカギ

性能を上げる秘訣は、速度(レイテンシー)にばかり気を取られないことです。多くの場合、低速化の原因はリソースの無駄や不必要な消費であり、効率が悪いまま速度だけ上げると問題のほうが多くなります。だからこそ、性能改善の第一歩は速度より効率の向上に置くべきです。

川を渡ってA→Bへ:速い車で遠回りの橋を渡るより、
泳いで最短ルートを取るほうが速く着くこともある
= 作業量そのものを減らす(効率化)ほうが、より速いハードを買うより安く効く

効率を起点にすべき理由は次のとおりです。

  • 効率的なソフトを遅くするのは難しい — 効率的なコードでは行う作業が少ないため、自然と良い性能を発揮する。
  • 速度は壊れやすい — レイテンシーがCPUの熱スロットリング、他プロセス、ネットワークなど多数の外部要因に左右される。プログラマーにとってもっとも制御しやすいのが効率である。
  • 速度は移植性に劣る — 速度だけ最適化しても環境が変われば再現しない。一方、開発機でDBを2回呼ぶコードは、どんなデバイスでも2回呼ぶ。効率は環境を越えて保たれる。

結論として、効率は可読性の直後に、あるいは可読性と一緒に、設計の初期段階から正すべきものです。まず効率を改善することで、性能の最適化全体が容易になります。

理解度チェック

Q1. 本書における「性能」は、どの3要素の組み合わせとして定義されますか。

A1. 精度(accuracy)・効率(efficiency)・速度(speed)の3要素です。性能 = (精度 × 効率 × 速度) と表され、いずれかを改善すれば性能が向上し、いずれかを無視すれば有用性が低下します。

Q2. 「早すぎる最適化(premature optimization)」と「早すぎる悲観化(premature pessimization)」はどう違いますか。

A2. 早すぎる最適化は、まだ必要かわからない段階で過度なチューニングに時間を費やすことです。一方、早すぎる悲観化は、同じ計算量・可読性で書けるのに、わざと非効率なコード(例: 同じGet()を3回呼ぶ)を選んでしまうことです。後者は害があるのに避けられがちで、著者が本当に問題視しているのはこちらです。

Q3. 「ハードウェアが速く安くなるから効率は気にしなくてよい」という主張が成り立たない理由を挙げてください。

A3.(1)パーキンソンの法則によりリソースが増えれば需要も膨らみ、ソフトウェアはハードより速く肥大化する。(2)デナード・スケーリングの物理的限界に2006年頃到達し、クロック速度は頭打ちでコア数を増やすしかない。(3) ハードを足せばそれを運用・保守するソフトとエンジニアのコストも増える。加えて、速く動くほど消費電力は減るため、効率はエネルギー効率やアクセシビリティの面でも重要です。

Q4. 性能改善で速度よりも効率を優先すべきだと著者が主張する根拠は何ですか。

A4. 効率的なコードは行う作業が少ないため遅くしにくく、良い性能を発揮しやすいこと。速度はCPUの熱・他プロセス・ネットワークなど外部要因に左右されて壊れやすく、環境が変わると再現しない(移植性が低い)こと。一方、効率はプログラマーがもっとも制御でき、環境を越えて保たれます。まず効率を改善すると、性能の最適化全体が容易になります。


2章 効率的なGo入門

この章では、プログラミング言語Goが性能と他のソフトウェア品質とのバランスを達成するための確かな選択肢となる理由を、言語の基礎から高度な機能まで効率の観点で概観します。「Goは速いのか」という問いにも、他言語と比較しながら答えます。

命令型・コンパイル型・静的型付け言語

Goは主にシステムプログラミングのために設計された汎用言語です。命令型言語であり、どのように実行されるかをある程度制御できます。さらに静的型付けかつコンパイル型であるため、コンパイラーが実行前に多くの最適化とチェックを行えます。これだけでも信頼性の高い効率的なプログラムを書く優れた出発点になります。

Goは明示的なインポートとビルドキャッシュにより高速なコンパイルを実現しており、技術的にはコンパイル言語ですが go run でスクリプトのように実行できます。

  • 構文はCをベースにしつつ型派生(自動型検出)を持ち、前方宣言やヘッダーファイルがない。
  • 概念の直交性を重視しており、任意の型にメソッドを追加できるなど、要素を自由に組み合わせ推論しやすい。

2007年頃、Rob Pike・Robert Griesemer・Ken Thompsonの3人がGoを構想しました。当時主流だったC++・Java・Pythonの課題(複雑さ、長いコンパイル時間、リファクタリングのコスト、エラーの発生しやすさ)を改善するためです。そして2012年にバージョン1.0がリリースされました。指針は「安全性や繰り返しの少なさを犠牲にせず、よりシンプルなコードを書ける言語」を作ることでした。

シンプルさ・安全性・可読性

Goの主な指導方針はシンプルさ・安全性・可読性であり、「少ないほうが豊かである(less is more)」のパターンに従っています。慣用的なコーディングスタイルは1つだけで、gofmt というツールがそのほとんどを保証します。フォーマットや命名のような議論になりがちな要素を強制することで、膨大な意思決定の時間を節約できます。

  • エラーの扱い方、オブジェクトの書き方、並行処理の方法など、特定の構成要素を記述する方法が基本的に1つになるよう言語が最小化されている。
  • このミニマリズムは効率化にも役立つ。実行時の作業が少ないほど、通常は高速で無駄のない実行と、より単純なコードにつながる。

パッケージとモジュール

Goのソースコードはパッケージまたはモジュールを表すディレクトリで整理されます。

  • パッケージは同じディレクトリにある .go ファイルの集まりで、各ファイル先頭の package 文で名前を指定する。
  • モジュールは依存関係とそのバージョンを記述した go.mod ファイルを持つディレクトリで、依存関係管理ツールGoモジュールが使用する。
  • main パッケージに func main() があれば実行可能(実行可能パッケージはインポートできない)。

可読性・再利用性・信頼性のため、エクスポートするAPIは最小限にすべきです。Goには private/public キーワードがなく、構成要素名が大文字で始まればパブリック、小文字で始まればプライベートという命名規則でアクセス制御します。これは関数・型・インターフェイス・変数すべてに等しく適用されます(直交性)。internal という名前のサブディレクトリを使うと、親パッケージ以外からのインポートを禁止できます。

デフォルトでの依存関係の透明性

C++ やJavaなどではコンパイル済みライブラリをインポートするのが一般的ですが、実装が壊れていたり効率が悪かったりした場合、ソースコードへのアクセスは非常に貴重です。Goは各パッケージを「インポートパス」というパッケージURIで明示的にインポートさせ、依存関係を透明に保ちます。

  • 未使用のインポートや循環的な依存関係はコンパイルエラーになる。
  • ドメインで始まらないパスは標準ライブラリ、ドメインで始まるパスはインターネット上のモジュール(例:github.com/...)を指す。
  • 公式ツールはバイナリ形式での配布をサポートせず、ソースの同梱(ベンダリング)が推奨される。
  • 依存関係の衛生管理はコードの再利用に優先する。1つの機能のために大きなライブラリを引き込むより、少量のコードをコピーするほうがよい場合もある。

未知のものが少ないということは、主要なボトルネックを素早く検出し、最も価値ある最適化に注力できるという効率上の利点をもたらします。

一貫したツール

Goは当初から go というCLIツールの一部として、強力で一貫したツール群を備えています。

  • go build / go run / go install:ビルド・実行・バイナリのインストール。
  • go fmt(および拡張版 goimports):期待されるスタイルへの自動整形。
  • go get / go mod / go list:依存モジュールの管理と列挙。
  • go test:ユニットテスト・ファジング・ベンチマークの実行。
  • go vet:基本的な静的解析。go tool pprof などの高度なツールも利用できる。

ほとんどの場合、このGo CLIだけで効果的にプログラミングを行えます。

単一のエラー処理方法

多くの言語にはエラー処理の方法が多数あり(例外、整数の戻り値、ステータスコード、モナドなど)、その多様さ自体が、エラーを隠して複雑さと信頼性低下を招く深刻な問題になりえます。Goはエラーを第一級市民の言語機能として扱い、明示的・簡単・統一的に処理させます。

func intOrErr() (int, error) { ... }   // エラーは常に最後の戻り値
  • 関数はシグネチャの一部としてエラーの有無を宣言する。多値戻り値を使い、エラーは常に最後の戻り値に置く。
  • 呼び出し側は if err != nil でチェックし、errnil のときだけ結果に触れる。
  • panic/recover という例外機構もあるが、本番の通常のエラー処理に使うべきではない。
  • エラーは無視せず、必要なら呼び出し側に委譲したり errors.Wrap でコンテキストを付与したりする。意図的に無視する場合は _ = で明示する。

冗長で「悲観的」になるという不満はありますが、プログラムを予測・デバッグしやすくする価値があります。なお異常系は正常系より実行が1桁遅くコスト高になることが多く(エラーメッセージの文字列操作などのため)、一貫した効率的なエラー処理が重要です。

強力なエコシステムとテスト

Goのエコシステムは「若い」言語としては例外的に成熟しています。

  • 標準ライブラリは高品質・堅牢・後方互換性が高く、net/httpcryptomathsort などは多くのユースケースで十分以上に最適化されている。外部パッケージなしでほとんどのことが実現できる。
  • ブラウザで動く Go Playground、テンプレート言語 Go テンプレート(HelmやHugoで利用)なども提供される。
  • 未使用の変数やインポートはビルドエラーになり、コードが明確で適切に保たれる(関数内の変数のみがチェックされ、構造体フィールドなどは対象外)。

テストは開発プロセスの自然な一部で、_test.go ファイルに Test/Fuzz/Example/Benchmark の接頭辞を持つ関数を書きます。同じ関数を異なる入力で読みやすく検証するテーブルテストが推奨され、t.Run でサブテストを定義できます。

コードドキュメントと後方互換性・移植性

Goは最初から godoc を備え、コードとコメントから一貫したドキュメントを生成します。パッケージ説明は Package <名前> で始める、公開要素はその名前から始まる完全な文で説明する、Example<名前> 関数で実行可能な例を示し // Output: で出力を検証する、といった少数のルールに従うだけで構造化された一貫したコメントになります。公開リポジトリは pkg.go.dev で自動的にレンダリングされます。

  • 後方互換性:Go 1.0用の古いコードを壊さないという強い保証があり、アップグレードはほぼスムーズである。ただし効率・速度の特性についてはバージョン間で変化しうるため、性能を重視するなら変更に注意が必要である。
  • 移植性:ソースは各プラットフォーム向けバイナリにコンパイルされ、クロスコンパイルも可能(OSやアーキテクチャが異なると実行時エラーになる)。

Go ランタイム

Java(JVM)やC#(CLR)の多くは移植性のために仮想マシンを使い、中間バイトコードを解釈・JITコンパイルしますが、これはオーバーヘッドと多くの未知数を生みます。

Goは仮想マシンを必要とせず、コードとライブラリはコンパイル時に完全に機械語へコンパイルされます。それでもプログラム起動時にはバックグラウンドで Go ランタイムが動作し、メモリ管理と並行処理の管理を担います。

オブジェクト指向プログラミング

Goにクラスはありませんが、相当するものとして**構造体(struct)**があり、フィールド(属性)とメソッド(ふるまい)を持てます。

  • 埋め込み:構造体に別の構造体を無名で追加すると、そのフィールドとメソッドを借りられ、継承の最も価値ある部分を得られる。
  • レシーバー:状態を変更しないメソッドは値レシーバー、状態を変更する(または他のメソッドが変更する)場合はポインターレシーバーを使う。一貫性のため、1つでもポインターが必要なら全メソッドをポインターレシーバーにする。
  • インターフェイス:必要なメソッドを実装するだけで暗黙的に満たされ、Javaのように明示的なマークは不要(型と直交)。var _ sort.Interface = sortable{} のように、型がインターフェイスを満たすことをコンパイル時に保証できる。

Goは完全な継承(型から型への変換)はサポートしませんが、ほぼすべてのOOPのケースを満たせる機能を備え、手続き的・合成可能なコードとOOPの両方を簡単に書けます。

ジェネリクス

バージョン1.18からGoは**ジェネリクス(パラメトリック多相)**をサポートし、異なる型間で再利用したい機能を型安全に実装できます。導入時は次の2点が大きな議論を呼びました。

  • 同じことをする 2 つの方法:もともとインターフェイスで型安全な再利用が可能だったため、機能が重複する。
  • オーバーヘッド:実装方式により、プログラマー速度(Cのように省略)・コンパイル時間とバイナリサイズ(単相化)・実行時間とメモリ(ボックス化)のいずれかが犠牲になるジレンマがある。

最終的にGoは辞書とステンシルという単相化とボックス化の中間アルゴリズムを採用しました。型パラメーターには constraints.Ordered(現在は cmp.Ordered)のような制約を指定でき、comparable キーワードでmapのキーにできる型を表せます。

性能上は、ジェネリクスは理論的にはインターフェイスより速いものの手書きの特化実装よりは遅いとされますが、実際の差は無視できることが多く、ケースによって逆転もします。

  • まず最も読みやすく保守しやすい選択肢を選び、効率が重要なら必ずベンチマークで確認する。

Go は速いのか

「Goは速いか」という問い自体は形容しがたいものです。どんな言語でも時間と複雑さをかければ機械語まで最適化でき、生の短いベンチマークは実用的な側面(最適化がどれだけ複雑だったか)を示しません。

重要なのは「効率的なコードを書くことがどれだけ実用的で、可読性や信頼性をどれだけ犠牲とせずに済むか」という観点です。Goは基本的なコードを速く簡単に書けるようにしつつ、これらの要素のバランスも優れています。その理由は、密閉されたコンパイル手続き、未知の少ないGoランタイム、使いやすい並行処理、成熟したデバッグ・ベンチマーク・プロファイリングツールにあります。GoはC・Pascal・CSPという巨人の肩の上に設計されています。

ただし効率面のアキレス腱として、メモリ使用量の制御の難しさがあります。割り当ては新規ユーザーには驚きで、ガベージコレクションにはオーバーヘッドがあります。Rustの所有モデルはより効率的ですがコードがはるかに複雑で、GoはGCを自動化する使いやすさのトレードオフを選んでいます。なお、この悪影響を軽減する方法は後続の章で説明されます。

効率的なコードであっても単純性・安全性・可読性が最も重要であり、まず効率を考えずにそれを実現してから最適化に進むべきです。

理解度チェック

Q1. Go がアクセス制御に `private`/`public` キーワードを使わず採用している仕組みは何ですか。

A1. 構成要素名の頭文字による命名規則です。名前が大文字で始まればパッケージ外からアクセス可能(パブリック)、小文字で始まればプライベートになります。これは関数・型・インターフェイス・変数すべてに等しく適用されます(直交性)。さらに internal ディレクトリを使えば、親パッケージ以外からのインポートを禁止できます。

Q2. Go におけるエラー処理の特徴と、効率面での注意点を説明してください。

A2. Goはエラーを第一級市民として扱い、関数のシグネチャでエラーの有無を宣言します。多値戻り値の最後にエラーを置き、呼び出し側は if err != nil でチェックして nil のときのみ結果を使います。本番の通常のエラー処理に panic/recover を使うべきではありません。効率面で注意したいのは、エラーオブジェクト生成時の文字列操作などのため、異常系が正常系より1桁遅くコスト高になることが多い点です。

Q3. Go が JVM や CLR のような仮想マシンを使わないことの意味は何ですか。

A3. Goのコードとライブラリはコンパイル時に完全に機械語へコンパイルされるため、中間バイトコードの解釈やJITコンパイルによるオーバーヘッドと未知数を避けられます。ただし実行時にはバックグラウンドでGoランタイムが動作し、メモリ管理と並行処理の管理を担います。

Q4. Go の構造体で継承に近いコード再利用を実現する仕組みは何ですか。また Go は完全な継承をサポートしますか。

A4. 構造体に別の構造体を無名で追加する「埋め込み」によって、埋め込まれた構造体のフィールドとメソッドを借りられ、継承の最も価値ある部分を得られます。ただしGoはエイリアスや厳密な単一型埋め込みを除き型から型への変換を認めないため、完全な継承はサポートしないとみなされますが、ほぼすべてのOOPのケースを満たす機能を備えています。


3章 効率化の攻略

本章は、ソフトウェアを速くすべきか否かをその場の勘で判断するのではなく、目標を数値で定め、体系的なフローに沿って最適化に取り組む方法を扱います。最適化の定義、判断基準、要件の定式化、そして開発プロセスへの組み込み方を順に押さえます。

無駄を省く:最適化はゼロサムゲーム

効率最適化とは、機能を変えずにコードのリソース消費やレイテンシーを改善することです。大きく分けると、最適化は次の2つのどちらか(または両方)で達成できます。

  • 無駄なリソース消費を排除する
  • あるリソースを別のリソースや他の品質と交換する(トレードオフ

無駄の排除に当たるのが 合理的な最適化(reasonable optimization) です。これは「明らかに不要」な処理を取り除くもので、追加作業を生まず、可読性などの他の品質も犠牲にしません。靴の中の石を取り除くのに計測は要らないのと同じで、見つけ次第その場で行うべき「コーディング衛生」とされます。

一方、機能的に重要な処理の先には ゼロサムゲーム が待ち構えます。リソースA(メモリ)を減らせばリソースB(CPU時間)や可読性・移植性・正確性が犠牲になる状況です。ここで行うのが 意図的な最適化(deliberate optimization) で、トレードオフを理解・計測・評価し、維持するか捨てるかを判断します。意図的な最適化は機能フェーズとは別のフェーズで行うべきだとされます。

合理的な最適化 … 明白な無駄を除去。計測不要、害なし、即実行
意図的な最適化 … トレードオフを伴う。計測・評価して採否を決定

なお合理的な最適化は「早すぎる最適化」に見えますが、害がないと確信できる範囲なら実行してよい、という立場をとります。

最適化の課題

最適化が難しい理由として、本章は以下のような障害を挙げます。

  • プログラマーは性能問題の 原因箇所 を推測するのが苦手(パレートの法則:消費の80%は処理の20%から生じる)
  • リソース消費量 を正確に見積もるのも苦手で、最適化後は必ず計測・検証すべき
  • OS・ハードウェア・依存などが常に変化するため、効率を維持し続ける(リグレッション防止)のが大変
  • 現在の性能を確実に検証すること自体が困難(再現性、ノイジーネイバー等)
  • 最適化は他のソフトウェア品質に影響を与えやすい
  • Goは GC があるためメモリを厳密に制御できない(基本は割り当てを減らす)

そして根本的な問いとして「効率がいつ“十分”なのか」が残ります。だからこそ目標を知ることが極めて重要になります。

目標を理解する:効率化要件を定式化する

最適化のゴールは完璧な効率ではなく「必要なだけ十分に」効率的であることです。利害関係者の要望をそのまま信じられない理由として、XY問題(本当の解決策は別にある)、効率化がゼロサムでありすべての不満に対応できないこと、利害関係者が最適化コストを理解していないことが挙げられます。効率とコストには スイートスポット があり、これを超えると改善コストが利益を急速に上回ります。

通常の開発が 機能要件(FR) から始まるのと同様に、効率や速度に関する要求も前もって定式化すべきです。これは従来 非機能要件(NFR) と呼ばれますが、定型的・官僚的で、標準(ISO/IEC 25010など)が高価・複雑なため実務ではあまり使われません。そこで、より軽量で実用的な仕組みが必要になります。

RAER:リソースを考慮した効率要件

本章の中心概念が RAER(Resource-Aware Efficiency Requirements、リソースを考慮した効率要件) です。RAERは、特定の操作に対する効率要求を記録した集合体で、1レコードに理想的には次の情報を含めます。

  • 対象となる 操作/API/メソッド/関数
  • 操作する データセットのサイズと形式
  • 操作の 最大レイテンシー
  • そのデータセットに対する リソース消費バジェット(メモリ、ディスク、ネットワーク帯域など)
プログラム  : ルーラー
操作内容    : HTTPでストレージから1テナントの警告ルールを取得
データセット: 各1,000ルールを持つ100テナント
最大レイテンシー: P90で2秒
CPUコア: 2 / メモリ: 500MB / ディスク: 1GB

すべての操作・リソースを網羅するのは非現実的なので、以下のように簡略化します。

  • もっとも 利用が多くコストの高い操作 から始める
  • もっとも影響の大きいリソース(CPU時間・メモリ・ストレージ)から定義し、必要に応じて追加する
  • 重要なカテゴリの エッジケース(ワースト/ベスト)のデータセットに焦点を当てる
  • 絶対値でなく、入力とリソース消費の関係(計算量)として記述してもよい(例:メモリ制限 = X + 0.4 × Y MB

RAERは理想的には開発開始前に持ち、FR文書の中の「効率要件」セクションに入れることが推奨されます。

要件の取得は機能目標と同様で、利害関係者にスピード・コスト・制約を尋ねます。数字が出てこなければ、まず大きめの数字を置いて議論の起点にします。自分が開発者かつユーザーである場合(ドッグフーディング)はRAER設定が容易です。提示された要件は達成可能性の観点から自分たちで精査・評価する必要があります。

その評価には ナプキン計算(napkin math、封筒の裏計算) が有効です。簡単な理論的仮定(例:SSDからの8KBシーケンシャル読み取り=約1マイクロ秒)に基づいて概算し、素朴なアルゴリズムを起点に達成可能性を早期に見極めます。本章では1,000万商品のJSON読み込み例を通じ、ナプキン計算とマイクロベンチマークでRAER(例:毎秒33万商品、X × 375バイト)を定義・検証する過程を示します。

効率性の問題への対処(冷静なトリアージ)

性能問題が報告されたとき、まず大切なのは自分や他人を責めない 非難なき文化 です。すべての性能問題に最適化が必要とは限りません。本章は最適化を含まない6ステップのトリアージフローを推奨します。

1. 問題がバグトラッカーに報告される(1問題ずつ=分割統治)
2. 重複をチェックする
3. 機能要件(FR)と照合する(悪用・前提条件違反でないか)
4. RAERと照合する(合意した数値の範囲内なら閉じる)
5. 認識し優先順位を付けてメモし、次へ進む(今は何もしない選択も推奨)
6. 完了:問題はトリアージ済み

この時点で最適化はまだリストに入っていない点が重要です。FRやRAERが現実に合わなければ、要件側を修正することもあります。

最適化設計レベル

最適化は問題を レベル に分けて分離すると攻略しやすくなります。本章は5つのレベルを最高位から示します。

  • システムレベル:どのモジュールを使い、どうリンクし、誰がどの頻度で呼ぶか。複数チームの努力が要るが効率改善は大きい
  • モジュール内のアルゴリズム/データ構造レベル:問題に合ったアルゴリズムとデータ構造の選択
  • 実装(コード)レベル:同じアルゴリズム・同じ出力のまま、コードをより効率的にする(コンパイラーとの相乗効果を含む)
  • オペレーティングシステムレベル:プロセス/スレッド、仮想化レイヤーなど。Go開発者が直接いじることは少ない
  • ハードウェアレベル:CPUキャッシュ、NUMA、メモリバス帯域など、性能の最終的な制約

各レベルで10〜20倍の高速化が可能で、階層的システムでは効果が掛け算になります。ただし1レベルを限界まで最適化すると次は別レベルへ移る必要があり、変更できないレベル(コンパイラーやハードウェア)もあります。自分の経験に引きずられて特定レベルに偏る 最適化バイアス にも注意が必要です。

効率性を考慮した開発フロー(TFBO)

すべてを統合した開発プロセスが TFBOフロー(Test, Fix, Benchmark, Optimize) で、9ステップから成り、体系的かつ高度に反復的です。あらゆる言語・あらゆる設計レベルに適用できます。大きく 機能フェーズ効率化フェーズ に分かれ、両者を分離することで集中が深まり、コンテキストスイッチを避けられます。

機能フェーズ(常に機能から始める)

  • ステップ1:まず機能をテストする(TDD:テストを書く→失敗を確認→小さな変更で通す)
  • ステップ2:機能テストに合格しているか(合格ならステップ4へ)
  • ステップ3:失敗なら不足部分を実装・修正する。ここでは合理的な最適化以外は行わない

ステップ1〜3は小さなループを形成し、早期フィードバックを得ます。

効率化フェーズ(機能に満足してから)

  • ステップ4:効率性の評価(BDO:ベンチマーク駆動型最適化。ベースラインを取得)
  • ステップ5:RAERの範囲内か(範囲内ならステップ9のリリースへ)
  • ステップ6:主なボトルネックの発見(根本原因の理解)
  • ステップ7:レベルの選択(1イテレーションでは単一レベルにこだわる)
  • ステップ8:最適化! 行ったら必ずステップ1〜4へ戻り、機能テストと同じベンチマークで再検証する
  • ステップ9:リリースして楽しむ

最適化後に機能テストを省く誘惑に負けると、後で原因究明が困難になるため、最適化のたびに範囲を限定したユニットテストと同一ベンチマークでの再計測をすることが鉄則です。「斧を研ぐ」ことわざのように、テストとベンチマークへの投資は長期的に労力を節約します。

理解度チェック

Q1. 合理的な最適化と意図的な最適化の違いは何ですか。

A1. 合理的な最適化は「明らかな無駄」を取り除くもので、追加作業を生まず可読性などの他の品質も犠牲にしないため、計測せずその場で実行してよいものです。意図的な最適化はトレードオフ(あるリソースや品質を別のものと交換すること)を伴うため、トレードオフを理解・計測・評価したうえで採否を判断し、機能フェーズとは別のフェーズで行います。

Q2. RAERとは何で、1レコードにはどんな情報を含めますか。

A2. RAER(Resource-Aware Efficiency Requirements、リソースを考慮した効率要件)は、特定の操作に対する効率要求を記録した仕様です。1レコードには、対象の操作/API/関数、操作するデータセットのサイズと形式、操作の最大レイテンシー、そのデータセットに対するリソース消費バジェット(メモリ・ディスク・ネットワーク帯域など)を含めます。すべてを網羅せず、利用が多くコストの高い操作と影響の大きいリソースから定義します。

Q3. 性能問題が報告されたとき、最適化に入る前に行うべきことは何ですか。

A3. まず非難なき文化で冷静になり、最適化を含まないトリアージフローを回します。重複チェックののち、報告状況を機能要件(FR)と照合し(悪用や前提条件違反でないか)、次にRAERと照合します。合意した数値の範囲内なら丁重に問題を閉じ、範囲外でも今は対処せず認識・優先順位付けして記録するだけのこともあります。すべての性能問題に最適化が必要なわけではない、という点が要点です。

Q4. TFBOフローで機能フェーズと効率化フェーズを分ける理由は何ですか。

A4. フェーズを分離すると深い集中が得られ、初期の不明点やミスを抑えられ、機能と効率を同時に考える負荷の高いコンテキストスイッチを避けられるためです。機能フェーズはTDDで「正しく動くこと」だけに集中し(合理的な最適化のみ許可)、機能に満足してから効率化フェーズでBDO(ベンチマーク駆動型最適化)を行い、RAERと比較して必要な場合だけ最適化します。


4章 GoでのCPUリソースの活用方法

この章では、CPUの観点からプログラムの実行を検証します。現代のコンピューターアーキテクチャ、Goコンパイラーの動き、メモリウォール問題、そしてOSとGoのスケジューラーを順に追い、効率的なGoコードを書くための土台を築きます。

現代のコンピューターアーキテクチャとCPU

現代のコンピューターは、メモリに格納した命令セットを使ってプログラムできる フォン・ノイマン型アーキテクチャ をベースにしています。命令とデータを同じメモリアドレスから読み出せる点が特徴です。

アーキテクチャの中心には複数の コア を持つCPUがあり、各コアは RAMレジスタL キャッシュ などのメモリ層に保存されたデータを使って命令します。CPU・RAM・I/Oデバイス(ディスク、ネットワークなど)がコンピューターの本質的な部分です。

Goは 命令型 の言語であり、ハードウェアもまた命令型です。CPUリソースに注目すべきなのは、マシンが計算能力を使い切って他のタスクを実行できないときや、プロセスが予想外に遅くCPU消費が増えているときです。

なお、効率を理解するうえで大切なのは 機械的共感(mechanical sympathy) の考え方です。慣用的な書き方の多くは、すでにハードウェアやOSに同調するよう調整されています。

アセンブリ

CPUは私たちが書いたGoコードを直接は理解できません。コンパイラー が高レベルコードを 機械語(オペコードとオペランドの2進数列)へ翻訳します。機械語は人間には読めませんが、アセンブリ言語 にディスアセンブルすれば、CPUが解釈する内容を読める形で確認できます。

ret += num
MOVQ 0x50(SP), SI   ; メモリ→レジスタSIにコピー
ADDQ AX, SI         ; AXの値をSIに加算

効率の問題をトラブルシュートする際、生成された機械語やアセンブリを検査すると、隠れた低レベルの無駄を発見できる場合があります。ソフトウェアとハードウェアの境界を規定するのが ISA(命令セットアーキテクチャ) で、x86やARMなどがあります。アセンブリを書ける必要はありませんが、おおまかな理解は高度な最適化に有効です。

Goコンパイラーを理解する

go buildコンパイルリンク をまとめて実行し、依存をすべて静的にリンクした実行ファイルを生成します。コンパイラーはパッケージ単位で処理し、トークン化・構文解析、AST(抽象構文木) 構築と型チェック、最適化、SSA形式への変換、機械語生成という段階を踏みます。

主要な最適化として次のものがあります。

  • 関数のインライン化: 小さな関数の呼び出しを本体のコピーで置き換え、呼び出しコスト(数ナノ秒だがホットパスでは効く)を削減する。手動インライン化は最後の手段である。
  • エスケープ解析: 変数を スタック(安価)に置けるか、ヒープ(高価)に割り当てるかを決定する。
  • デッドコード除去・SSA 最適化: 不要な代入や y := 0*x のような式を簡略化する。

-gcflags="-m" で最適化の判断を、-S でアセンブリを確認できます。重要な原則は 「コードが単純であるほどコンパイラーの最適化は効果的」 ということです。巧妙すぎるコードはコンパイラーを混乱させます。ただし最適化は仕様外なので、Goのバージョンを変えたらベンチマークで確認すべきです。

CPUとメモリウォール問題

CPUは サイクル で動作し、1サイクルで1組のデータに1命令を処理します(SISD)。レジスタ は最速のローカルストレージですが、数が少なくサイズも小さいです。

問題の核心は メモリウォール です。CPUコアはすでに十分速い一方、メインメモリ(RAM)アクセスはCPUの1サイクルより平均100倍も遅く、命令やデータの待ちに実行時間の約50%以上が費やされます。業界はこれを階層型キャッシュ、パイプライン、アウトオブオーダー実行、ハイパースレッディングで緩和しました。

速い ← レジスタ < L1 < L2 < L3 < RAM → 遅い

階層型キャッシュシステム

CPUは L1/L2/L3 キャッシュ(オンチップの高速SRAM)を持ち、RAMより先にここを参照します。見つからなければ キャッシュミス として下位の層をたどります。ミス時には キャッシュライン(通常64バイト)単位でまとめて取得します。隣接バイトが次に必要になる確率が高いためです。

この結果、データ構造は小さく整列しているほど効率が良く、連続したシーケンシャルデータへの命令が高速になります。

パイプラインとアウトオブオーダー実行

CPUは パイプライン(IF→ID→EX→MEM→WBの段階)で複数命令を重ねて処理し、ILP(命令レベル並列性) を引き出します。さらに アウトオブオーダー実行 で、データと実行ユニットの空き状況に応じて命令順を入れ替えます。

開発者にとって重要な2つの帰結があります。1つは命令ストリームの切り替え(コンテキストスイッチ)によってパイプラインがリセットされ大きなコストを伴うこと。もう1つは コードの予測性が高いほど良い ことです。if や分岐が多いと 分岐予測投機的実行 が効きにくくなります。このため、連結リストよりも 配列のような連続メモリ構造 が好まれます(ポインターをたどるまで次の命令を予測できず投機が制限されるため)。

ハイパースレッディング

ハイパースレッディング(SMT、同時マルチスレッディング) は、1つの物理コアを2つの論理コアとして見せる技術です。1コアが同時に複数命令を処理するわけではありませんが、アイドル時にコアをビジー状態に保つことで利用率を高めます。Goプログラムでは通常、有効にしておくのが望ましいです。

CPU効率の良いコードの3原則

1. より少ない作業で済むアルゴリズムを使う
2. 分岐の少ない(「ホット」「コールド」を分離した)コードを書く
3. 反復・走査では連続メモリのデータ構造を使う

スケジューラー(OSとGoランタイム)

OSのスケジューラー

Linuxカーネルの スケジューラー は、最小単位である OS スレッド を、限られた物理CPUコアに割り当てます。スレッドは 実行中(Running)・実行可能(Ready)・待機(Blocked) の状態を持ちます。

Linuxは プリエンプティブ にスケジュールし、主要実装の CFS(Completely Fair Scheduler) は各スレッドに短い時間スライス(1〜20ミリ秒)を与えて公平に切り替えます。CPUが過負荷(ノイジーネイバー 等)になると、効率的なコードでも遅くなります。性能評価には実行待ち時間(ウォールタイム)より、実際にコアで「実行中」だった CPU 時間 が重要な指標になります。

Goランタイムスケジューラー

GoはOSスレッドの上に ゴルーチン(goroutine) という軽量な実行レイヤーを多重化します。ゴルーチンがI/Oやシステムコールでブロックすると、Goスケジューラー(OSではなく)が素早く別のゴルーチンに切り替えます。

OS:    1スレッドの切替 ≈ 約12,000命令分のコスト
Go:    ゴルーチン切替  ≈ 200ns / 約2,400命令分

ゴルーチンは数KBから始まる安価な存在で、go func() で簡単に起動できます。ゴルーチン同士は フラットな階層 で対等であり、main() のメインゴルーチンが終わると全体が終了します。

ゴルーチン間の通信・同期の手段は次のとおりです。

  • atomic パッケージ: 単純な数値操作向け。特別なCPU命令に変換され、ロックより効率的なことが多い。
  • ミューテックス(sync.Mutex / RWMutex): 操作が複雑な場合やクリティカルパス外で使う。
  • チャネル: CSPパラダイムに基づき「通信によってメモリを共有する」モデル。可読性に優れるが、CPU効率は最も低くなりがち。

並行処理の制御手段は実用上2つで、ゴルーチンの数(リークに注意)と環境変数 GOMAXPROCS(利用する仮想CPU数、デフォルトはコア数)です。

並行処理の使いどころ

並行処理はあらゆる最適化と同様にトレードオフです。メリット は、作業を分割した高速化、I/OバウンドタスクでのCPUコア利用率向上、確保したCPU時間の均等配分、問題領域への自然な適合(HTTPサーバーなど)です。デメリット は、コードの複雑化と可読性低下、リソース飽和やゴルーチンリークの危険、無視できないオーバーヘッド、調整パラメーター(GOMAXPROCS・ゴルーチン数・バッファ)の増加、ベンチマークの難しさです。

そのため 並行処理は意図的に行う最後の最適化の1つ であるべきです。CPUプロファイラーで本質的な処理にCPU時間が使われていると確認できてから検討します。基本的な最適化後に、実はI/Oバウンドだったと気づくこともあるためです。

理解度チェック

Q1. 「メモリウォール問題」とは何で、なぜ深刻なのですか。

A1. CPUコアはすでに十分高速である一方、メインメモリ(RAM)へのアクセスがCPUの1サイクルより平均100倍も遅く、命令やデータの待ちにアプリケーション実行時間の約50%以上が費やされてしまう問題です。より速いコアは作れても、より速いメモリを作れないことが現代CPUの主な効率性の問題を引き起こしています。これを緩和するために、階層型キャッシュ・パイプライン・アウトオブオーダー実行・ハイパースレッディングが開発されました。

Q2. 反復処理を多用する場面で、連結リストより配列のような連続メモリ構造が好まれるのはなぜですか。

A2. Lキャッシュはキャッシュライン(通常64バイト)単位でデータをまとめて取得するため、連続メモリ構造では隣接要素がキャッシュに乗りやすく、シーケンシャルアクセスが高速になります。一方、連結リストはノードのポインターをたどるまで次のデータや命令がわからず、CPUの分岐予測・投機的実行が制限され、キャッシュミスも増えて非効率になります。

Q3. ゴルーチンと OS スレッドのコンテキストスイッチのコストはどう違いますか。

A3. OSスレッドの切り替えは、間接コストも含めると平均で約12,000命令分(時に1万ナノ秒規模)に達します。一方ゴルーチンの切り替えはすべてアプリケーションレベルで起こり、約200ナノ秒・約2,400命令分のコストで済みます。GoはI/Oバウンドな作業をOSレベルのCPUバウンドな作業へ変え、OSスレッドをビジーに保つことでこの安さを実現しています。

Q4. ゴルーチン間の同期で atomic・ミューテックス・チャネルはどう使い分けますか。

A4. 値の読み書きや加算・比較交換のような単純な数値操作には atomic が適し、特別なCPU命令に変換されるためロックより効率的です。同期したい操作が複雑な場合や、可読性を優先したい・クリティカルパス外の場合は ミューテックス を使います。チャネル は「通信によってメモリを共有する」CSPモデルで可読性に優れたイベントベースの並行処理を書けますが、CPU効率は最も低くなりがちなので使いすぎに注意します。


5章 Goでのメモリリソースの使用方法

CPU命令の唯一の目的はデータを変更することであり、そのデータはメモリ(RAM)に格納されています。この章では、物理メモリチップからOSの仮想メモリ管理、Goランタイムのアロケーターとガベージコレクションまで、メモリリソースの旅を下から上へたどり、なぜメモリ割り当てが性能を左右するのかへの直感を養います。

メモリの重要性と物理メモリ

プログラムはCPUだけでなく、ディスクやネットワークからのデータも扱いますが、それらの外部データはすべてCPUがアクセスできるようにメモリ内へバッファリングされます。そのためメモリは中核的なリソースです。ただしメモリには3つの注意点があります。アクセスがCPUに比べて遅いこと、容量が有限であること、そして電源が落ちると消える揮発性であることです。だからこそディスクという永続的I/Oが補完的に必要になります。

物理メモリ(RAM)はDRAMチップがベースで、各メモリセルがトランジスタとコンデンサで1ビットを記憶します。8ビットで1バイトを形成し、ハードウェアはバイトアドレス方式を採用しています。重要なのは、バイト単位でアクセスできるという「幻想」の裏で、実際にはキャッシュライン(64バイト)など、より大きなチャンクが転送されている点です。

ランダムアクセス(レイテンシー)  → 数十年改善されていない
シーケンシャルアクセス(帯域幅)  → 改善され続けている(10倍以上速いことも)

このため、Goのデータ構造を整え、メモリ上でシーケンシャルなアクセスができる形にすると速度面で大きく有利です。

OSのメモリ管理:仮想メモリ・mmap・オンデマンドページング

OSのメモリ管理の本質的な目的は、複雑さを隠すことに加え、何千ものプロセスが同じ物理メモリを安全に共有できるようにすることです。各プロセス専用の空間確保、外部断片化の回避、メモリ分離、メモリ安全性(境界外アクセスの防止)といった課題を、OSは次の仕組みで解決します。

仮想メモリでは、各プロセスに独自の論理的なアドレス空間が与えられ、あたかもメモリ空間全体を独占しているかのように扱えます。実装にはページングが使われ、仮想メモリはページ、物理メモリはフレームという固定サイズ(デフォルト4KB)の単位に分割されます。アドレス変換は専用ハードウェアのMMUと、変換結果をキャッシュするTLBが担い、TLBミス時はページテーブルを辿るため大きなレイテンシー(約100クロック)が発生します。

ここで核心となるのが、割り当て済み仮想メモリの裏に物理メモリがあるとは限らないという点です。実際にはほとんどの仮想メモリはRAMにバックアップされていません。

VSS(VIRT)= 仮想メモリサイズ … 大きいが当てにならない
RSS(RES) = 常駐物理メモリ  … 実際に使っている量

この「使う分だけ確保する」戦略をメモリのオーバーコミットと呼びます(航空券のオーバーブッキングと同じ発想)。これはmmapシステムコールとメモリマッピングで実装されます。make([]byte, 1024)のように大きなオブジェクトを「作成」しただけでは物理メモリが確保されません。実際にアクセスした瞬間にページフォルトが発生し、初めてOSがRAMフレームを割り当てます。これがオンデマンドページングです。

物理メモリが枯渇すると、OSはファイルバックアップのページ解放、スワップ、最終的にはOOM(メモリ不足)での再起動やSIGKILLによるプロセス強制終了で対処します。頻繁なページ回収やスワップはメモリスラッシングを引き起こし、システム全体を遅くします。重要な結論として、メモリ使用量はオンデマンドページングのせいで正確には測れず、おおまかな見積もりしかできません。

Goのメモリ管理:値・ポインター・スタックとヒープ

Goは、開発者がメモリ配置を意識せずに変数を使える生産性の高い設計です。実行プログラムの仮想アドレス空間は.text/.data(コードとグローバルデータ、静的割り当て)、.bssヒープスタックなどのセグメントに分かれます。

  • スタックLIFO構造で、ローカル変数や引数などコンパイラーが寿命を推測できるデータを自動割り当てする。使用状況の追跡が不要なため非常に高速である。Goはゴルーチンごとに動的にサイズが変わるスタックを持つ。
  • ヒープは、関数スコープを超えて生き残る、寿命が事前に不明な動的割り当てのための領域である。物理メモリ上で最大のデータを格納し、管理コストも大きいため、メモリ最適化ではまずヒープを分析する。

どちらに置くかはエスケープ解析でコンパイラーが決めます。

Goは参照指向ではなく値指向である点が重要です。変数は常に値全体を箱(メモリブロック)にコピーして保持し、参照変数は存在しません。代入は常にコピーであり、値のサイズに比例したレイテンシーが付きます。

int変数     → 8バイトの箱(値をコピー)
ポインター   → 8バイトの箱(アドレスを格納、nilも可)
スライス     → 24バイトの箱(長さ・容量・配列へのポインターの3要素)

関数引数でポインターを渡すべきか値を渡すべきかは、効率に直結します。intのような小さな型はポインターにしても同じ8バイトなので、ヒープ確保を避けるため値渡しが有利です。一方、1億要素の配列を持つような巨大な構造体は、値渡しすると呼び出しごとにヒープへ大量コピーが発生するため、ポインター渡しが適切です。Goの割り当てのほとんどは動的なので、最適化の99%はヒープに注目します。

Goのアロケーター

Goのアロケーターは、ヒープを管理するランタイム内部のGoコードで、ロックや断片化を回避し、遅いシステムコールを抑えるよう最適化されています。コンパイラーがエスケープ解析でヒープ確保が必要と判断すると、アロケーターを呼ぶ命令を生成します。アロケーターはOSへmmapで**プライベートな匿名ページ(ゼロ初期化)**を要求し、それらはアクセス時に物理RAMへ確保されます。

覚えておくべき特徴は次のとおりです。

  • GoogleのC++実装TCMallocがベース。
  • OSの4KBページを意識しつつ、内部では8KBページで動作。
  • メモリブロックをサイズクラス(Go 1.18で67種、最大32KB)ごとのスパンに割り当て、断片化を緩和。
  • 32KB超の大きなオブジェクトはスパンを介さず直接割り当て。
  • OSへの仮想領域要求は1MB以上のまとめ単位で行い、システムコールのレイテンシーを償却。

make([]byte, 600MB)の例では、Goのアロケーターは複数の匿名マッピングを作成しヒープサイズは600MB超と報告しますが、アクセス前のRSSはごくわずかです。要素にアクセスして初めて物理メモリがオンデマンドで確保されます。ここで、Goが追跡するヒープサイズと実際のRSSは大きく食い違うことに注意してください。数KBレベルの細かいメモリ消費を追うのは現実的とはいえません。

ガベージコレクション(GC)

ヒープ管理のもう一方の役割が、未使用オブジェクトを回収するガベージコレクションです。GCの頻度が低すぎるとゴミがメモリを占有し続け、高すぎるとCPU時間がGCに奪われます。実行ペースは時間ではなく2つの構成変数で決まります。

  • GOGC(デフォルト100):「GCのパーセンテージ」。ヒープが前回GC終了時のサイズの100%まで成長したら次のGCを実行する、という意味である。
  • GOMEMLIMIT(Go 1.19導入、デフォルト無効): ソフトメモリ制限。設定値に近づくとGCを頻繁に実行する。ただし「ソフト」なので、制限以上に割り当て・使用することは防げず、その場合GCがほぼ連続実行されCPUの25%を奪う。

GoのGCはコンカレント・非世代別・三色マークアンドスイープです。

マークフェーズ:
  ① STW(Stop the World)+ 書き込みバリア注入(平均10〜30μs)
  ② CPU能力の25%で使用中オブジェクトを並行マーク
  ③ 書き込みバリア除去(再びSTW)
スイープフェーズ:
  マークされなかったオブジェクトを「遅延」解放
  → アロケーターが新たに割り当てる際にスイープを実行
  (回収コストがアロケーションのレイテンシーに計上される)

重要なのは、GCは即座にOSへメモリを返さないことです。空きブロックはプールとして次の割り当てに再利用され(sync.Poolのプーリングに近い)、十分溜まってからmadviseMADV_DONTNEED)でOSへ「解放」を助言します。そのためruntime.GC()を呼んでもヒープサイズは即下がりますが、マッピング自体は再利用のために残されます。

GCがプログラム効率に与える主な悪影響は次の3つです。ポインターを多用するオブジェクトの走査によるCPUオーバーヘッド、STWと書き込みバリアによるレイテンシー増加(さらにキャッシュ階層を破壊する)、そしてメモリオーバーヘッドです。極端には、GCが新規割り当てに追いつけないとメモリリークすら起こります。

結局、メモリ効率問題のほぼすべての根本原因は割り当てすぎです。GCやアロケーターはその原因を隠してしまうため、最良の解決策は「ゴミを減らす=割り当てを少なくする」ことに尽きます。

理解度チェック

Q1. プロセスのメモリ使用量を評価するとき、VSS(仮想メモリサイズ)を当てにすべきでないのはなぜですか。

A1. オンデマンドページングにより、割り当て済みの仮想メモリページの裏に物理メモリがあるとは限らないためです。プロセスはマシン全体より多くの仮想メモリ(VSS)を割り当てられますが、実際に物理RAM上に常駐しているのは一部(RSS)だけです。したがってメモリ使用量の評価には、VSSではなくRSSやヒープサイズを参考にします(ただしRSSも遅延解放やページキャッシュのため完全には正確でない)。

Q2. `make([]byte, 600*1024*1024)` を実行した直後、物理メモリ(RSS)はほとんど増えないのに、Goは600MB超のヒープサイズを報告します。なぜこのような食い違いが起きるのですか。

A2. Goのアロケーターはヒープに600MB分の仮想メモリ(匿名マッピング)を確保し、ヒープサイズとして計上します。しかしOSのオーバーコミット戦略により、物理メモリ(フレーム)は実際にスライス要素へアクセスした瞬間にページフォルト経由で初めて割り当てられます。アクセス前は物理メモリがほぼ確保されていないため、RSSは小さいままです。要素を全走査すれば、最終的にRSSはヒープサイズに近づきます。

Q3. GoのGCにおける「マークフェーズ」と「スイープフェーズ」はそれぞれ何を行いますか。また、GCはメモリをすぐにOSへ返しますか。

A3. マークフェーズはSTWと書き込みバリアの下で、ヒープ上のまだ使用中のオブジェクトを三色方式で並行マークします。スイープフェーズはマークされなかった(ゴミの)オブジェクトを解放しますが、これは遅延実行され、ゴルーチンが次に割り当てる際にスイープしてから割り当てるため、コストはアロケーションのレイテンシーに計上されます。さらに、解放されたメモリは即OSへ返されず、プールとして再利用され、十分溜まってからmadviseでOSへ解放を助言します。

Q4. 小さな型(`int`など)の関数引数は値渡しが有利な一方、巨大な構造体ではポインター渡しが推奨されるのはなぜですか。

A4. intはポインターにしても箱は同じ8バイトで、コピーコストも変わりませんが、ポインターにすると指す先がヒープ上に置かれヒープ確保のコストがかかるため、値渡しのほうが有利です。逆に巨大な構造体(例: 約100MB)を値渡しすると、呼び出しごとにヒープへ大量のメモリを割り当ててコピーするためCPU時間とメモリを浪費します。ポインター渡しなら8バイトのコピーだけで済み、ヒープ上の実体を再利用できます。


6章 効率性オブザーバビリティ

ソフトウェアの効率を改善するには、変更前後を正確に計測しなければなりません。この章では、TFBOフローの効率化フェーズを支えるオブザーバビリティの考え方と、レイテンシー・CPU・メモリといった効率性メトリクスのセマンティクスを学びます。

オブザーバビリティとは

オブザーバビリティ(可観測性) とは、外部に現れるシグナルからシステムの内部状態を推論する能力のことです。「計測していないものは改善できない」という言葉どおり、非効率なソフトウェアが生む無駄は推測では捉えられず、すべての最適化判断はデータ駆動でなければなりません。本番環境の観測こそが効率改善の出発点になるのは、このためです。

業界が扱う外部シグナルは、一般的に4種類へ分類されます。

メトリクス … 一定間隔で計測した数値。集計・予測に強い
ログ      … タイムスタンプ付きの個別イベント記録
トレース  … 因果関係のある分散イベント列(ログの構造化表現)
プロファイル … 継続収集が最近注目され始めた4つ目のシグナル

どのツールを使っても、効率を語るうえで最終的に必要なのは数値化されたメトリクスのセマンティクスです。テイルレイテンシーや最大メモリ使用量など、集計を通じて効率を評価します。

計装の分類

観測は必ず計装(インスツルメンテーション) から始まります。これは必要なシグナルを公開するためコードに処理を追加・有効化するプロセスで、いくつかの軸で分類できます。

  • 手動計装: Prometheusクライアントやgo-kitロガーなどをインポートしてコードにフックする方式。コード変更は必要だが、文脈の豊かな「オープンボックス」情報を得られる。
  • 自動計装: サービスメッシュやcgroups・eBPFなどからツールが情報を導出する方式。コード変更が不要で「クローズドボックス」情報を表す。

粒度の面では、各イベントを個別に残す 生のイベント捕捉 と、あらかじめ集約する 集計情報の取得 に分かれます。前者は文脈が豊かである一方、高コストで取りこぼしの危険があります。後者は安価で扱いやすい反面、後から別の質問をするには再計装が必要となり、カーディナリティを失います。収集モデルにはアプリ側から送るプッシュと、中央プロセスで集めるプルがあり、どちらも工夫次第で同等にスケールします。

例:レイテンシーのための計装(ログ・トレース・メトリクス)

実用目標として「操作のレイテンシー計測」を題材に、3シグナルを比較します。Goでは time.Now()time.Since() で経過時間を取得するのが基本です。

ログは最も理解しやすいシグナルです。生イベントをそのまま出力する方法と、合計と回数から平均を事前集計する方法があり、Goのベンチマークツールも後者の自動計装にあたります。実運用ではgo-kitのような構造化ロガーを使い、キーバリュー形式で機械解析しやすくします。ただしログは構造が一貫しにくく、メトリクスを導出する解析は困難で高価になりがちです。

トレースは、リクエスト/レスポンスのようなトランザクションを中心に構造化された情報で、ステータスコードやレイテンシーがネイティブに符号化されます。最大の価値はコンテキスト伝播で、これが分散トレーシングを可能にし、traceID を介してプロセスをまたいだスパンを連結します。一方で、生のイベント扱いゆえ高コストなのでサンプリングが必須となり、数ミリ秒以下の高速処理には不向きです。

メトリクスは集約情報の観測のために設計され、効率化の目標を解く最も現実的な手段です。Prometheusの client_golang ではヒストグラム型でレイテンシー分布を捉え、/metrics のHTTPエンドポイントを公開してPrometheusがスクレイピング(プル)します。

operation_duration_seconds_bucket{le="0.1"} 1   # 0.1秒以下が1件
operation_duration_seconds_bucket{le="1"}   2   # 1秒以下が2件
operation_duration_seconds_sum   0.2786...       # 合計
operation_duration_seconds_count 2               # 観測数

プル方式によりGoクライアントは超軽量になり、バッファリングや送信先管理が不要で、スクレイプできなければ不健全と判断できる利点もあります。注意点はカーディナリティを低く保つことで、エラーメッセージのような一意な情報はログ/トレースに任せます。オブザーバビリティに銀の弾丸はなく、ユースケースに応じて各シグナルを使い分けます。

効率性メトリクスのセマンティクス

メトリクスは セマンティクス(何を、どんな単位で計測するか)と 粒度(どこまで詳細か)で定義されます。効率化で計測したい主なリソースは次の3つです。

  • レイテンシー: 処理開始から成否までの時間。一貫性のため秒(基本単位)での計測が推奨される。計測区間に準備や後処理を混ぜないことが重要で、短すぎる処理(100ナノ秒以下)は time.Now/time.Since 自体のコストやノイズで信頼性が落ちる。HTTPサーバーなら「クライアント側合計」「サーバー側サービス時間」「関数レイテンシー」など複数の粒度があり、何を測ったかの文書化が欠かせない。決定的に重要なのは、平均ではなく分布(パーセンタイル) で見る点。平均は遅い操作(テイルレイテンシー)を覆い隠すため、P50(中央値)やP99を用いて分布を評価する。これがヒストグラムを使う理由である。
  • CPU 使用率: CPUの効率は CPUサイクルCPU命令CPU時間 で捉える。サイクル/命令はコア周波数やメモリレイテンシーに左右されず、アルゴリズム比較へ向く。実務ではCPU時間(ユーザー時間とシステム時間)に頼ることが多く、Prometheusの ProcessCollectorrate() で計測する。CPU時間が上限へ張り付き平坦なら CPU 飽和のサインであり、最初に確認すべき効率上の問題となる。
  • メモリ使用量: 仮想メモリや共有ページのため、すべての値は推定。情報源は Go ランタイムのヒープ統計runtime/metricsgo_memstats_heap_alloc_bytes 等。ヒープ割り当てが最も効果的な指標)と、OS のメモリページ統計の2系統。後者にはVSS(仮想)、RSS(RAM常駐)、PSS(按分)、WSS(ワーキングセット)があり、実務ではRSSかWSSを使う。WSSはKubernetesのOOMトリガーにも使われ、cAdvisorがcgroupメトリクスをPrometheusへ変換する。

理解度チェック

Q1. 効率改善において「オブザーバビリティ」が出発点になるのはなぜですか。

A1. 非効率が生む無駄は目に見えず、推測は外れやすいため、変更前後を計測しなければ最適化の効果を判断できないからです。オブザーバビリティの提供する数値があってこそ、最適化をデータ駆動で進められます。「計測していないものは改善できない」という原則がその根拠です。

Q2. ログ・トレース・メトリクスの3シグナルは、それぞれどんな性質に向いていますか。

A2. ログは生イベントを残すのに最も平易な反面、構造の一貫性を欠き解析が高価です。トレースは構造化され、コンテキスト伝播による分散トレーシングでプロセス間のフローを追える一方、高コストでサンプリングが要ります。メトリクスは集約情報の観測に特化し、安価でスケールしやすいため効率評価に最も現実的です。

Q3. レイテンシーを平均ではなくパーセンタイル(分布)で見るべきなのはなぜですか。

A3. 平均は遅い操作(テイルレイテンシー)の影響を覆い隠すからです。たとえば大半が高速でも一部が極端に遅い場合、平均だけ見ると最適化が成功したと誤判断しかねません。P50で典型値、P99/P99.9で最悪ケースを把握できるよう、ヒストグラムでレイテンシー分布を捉えます。

Q4. メトリクスでカーディナリティを低く保つべきなのはなぜですか。

A4. ラベルにユーザー IPやエラーメッセージのような一意な値を入れると、サンプル数の少ない短命なメトリクスが大量に生成され、実質的に生イベント化してしまいます。メトリクス向けシステム(Prometheus等)でこれを扱うと高価で遅くなるため、一意な情報が必要ならログやトレースに切り替えます。


7章 データ駆動の効率性評価

本章では、効率を「計測」するだけでなく、計測する価値のある状況をどう引き起こすか、つまり実験の技術を扱います。理論的な計算量解析から始め、ベンチマークの本質、実験の信頼性、そしてベンチマークのレベル選択へと進みます。

計算量解析

経験的データがまだ得られない段階でも、エンジニアは計算量解析(Complexity Analysis)でソリューションを評価できます。計算量には、操作に必要なCPU時間を表す時間計算量と、メモリやディスクなどの空間要件を表す空間計算量があります。

著者はまず**「推定」効率計算量を区別します。これは定数まで含めた具体的な関数です。たとえば Sum 関数の空間計算量は space(N) = 872 + 30.4 × N bytes と推定できます。これに対し漸近的計算量**は、実装やハードウェアのオーバーヘッドを無視します。そして入力サイズに対する成長の速さだけに注目します。一般に「計算量」という語は漸近的計算量を指しがちです。しかし両者を意識して使い分けることが重要です。

漸近的計算量はビッグオー記法で表現します。Donald Knuthが整理した3つの表記があります。

O(f(n))  上界:多くてもf(n)のオーダー(最悪ケース)
Θ(f(n))  タイトな限界:正確にf(n)のオーダー(平均・典型)
Ω(f(n))  下界:少なくともf(n)のオーダー(最良ケース)

業界では本来Θを使うべき典型ケースの説明にOを使う誤用が多く見られます。たとえばクイックソートはΘ(N×logN)・Ω(N×logN)・最悪O(N²)ですが、しばしば「O(N×logN)」と言われます。ΘはいつでもOに置き換えられますが、逆はできません。

実践への応用として、計算量解析は次の場面で真価を発揮します。

  • ベンチマークなしで予想リソース量を判断でき、キャパシティプランニングに役立つ
  • 理論値と計測値のズレから、簡単にできる最適化やメモリリークの兆候を発見できる
  • アルゴリズム改善案を実装せずに評価できる(Sum の例ではバッファ化で空間計算量をΘ(N)からO(1)へ、約7,800倍の削減)
  • ソースコードにマップすれば、効率のボトルネック(bytes.Split 由来の定数24など)を特定できる

真価を発揮するのは、経験的計測と理論を組み合わせたときです。

ベンチマークの極意

私たちはリソース消費の見積もりが苦手なため、推測を減らすには手を動かして実際に動かす経験的評価が最良の方法です。正確性ではなく効率を検証する特別なテストをベンチマークと呼びます(ストレステスト・負荷テストなども本質的に同義)。

ベンチマークは次の4要素で記述できます。

ベンチマーク = N × (実験 + 計測) + 比較

実験は機能をシミュレートして効率的な動作を学ぶ行為、計測は実験全体を通じてレイテンシーやリソース消費を観察すること、Nは信頼性を得るための繰り返し回数(多いほど良いがコストとのバランスが必要)、比較はRAERや基準値とどれだけ離れているかを認識する段階です。

機能テストとの違い:テストケース形成やテーブル駆動、自動化などは共有できますが、重要な相違があります。

  • テストデータを別途用意する必要がある。正確性テストは障害モードなどのエッジケースに注目するが、効率性テストは大きなリクエストや多数の小リクエストなど異なる効率問題を誘発する典型的入力に注目する
  • 書き込みと実行のどちらもコストが高い(特に大規模負荷テスト)
  • 期待値が具体的でないif maxMemory < 200MB のような単純なアサーションは結果のばらつきや分離の難しさから自動化が困難で、人間や複雑な異常検知が必要になる

そのため絶対値より相対的な結果(変更前との比較)を重視します。

**「ベンチマークは嘘をつく」**という固定観念について、著者は「ベンチマークは嘘をつかない。私たちが結果を誤解しているだけだ」と訂正します。ベンダーの性能マーケティングのように、効率評価は複雑で再現コストが高いため、誤解を招く結論が生まれやすく、ベンチマークはゲーミフィケーションされた不正の温床になりがちです。ただし多くは悪意ではなく、統計の誤謬や直感に反するパラドックスが招いた正直な間違いです。解決策は、ベンチマークの注意深い利用者・開発者となり、データサイエンスの基本を学ぶことです。

実験の信頼性

信頼性の低い評価は努力を無駄にします。一般的な課題はヒューマンエラー本番環境の関連性性能の非決定性の3つです(これらはボトルネック解析のプロファイルにも当てはまります)。

ヒューマンエラー:手作業が多いため、間違ったコードをテストするなどのミスが起きやすいです。対策は、変更を小さなイテレーションで行いシンプルに保つことです。さらにコードとベンチマーク自体をバージョン管理してアトミックな単位で計測し、仕事を整理・構造化してメモを残します。そして説明できない**「良すぎる」結果を疑う**ことも欠かせません。

本番環境の再現:最適化の目的は開発マシンを速くすることではなく、本番環境で十分効率的にすることです。本番の状況(RAM・CPU・OS・依存関係のバージョン)と負荷(データとトラフィック)を可能な範囲でシミュレートします。完全な再現は不可能なので、すべてを再現する必要はなく、作業負荷を制限する主要な特性を表現できれば十分です。たとえば200GBのファイルを作らず、5〜10MBのファイルと計算量解析から大規模時の挙動を推論できます。

性能の非決定性:現代のコンピューターは複雑な最適化の積み重ねで、結果にノイズが乗ります。a += 4 という1命令でも、レジスタ・Lキャッシュ・メモリのどこからデータを取るかで0.3ナノ秒から50ナノ秒、OS負荷次第ではミリ秒単位まで変動します。

圧縮性リソース   :CPU時間、I/Oスループット → 飽和しても遅くなるだけ。動的で予測しにくい
非圧縮性リソース :メモリ、ディスク空間 → 飽和するとクラッシュ。動作はより決定的

非決定性への対策のベストプラクティスです。

  • 結果の**分散(標準偏差)**を計算し、まず再現性を確認する(経験則として5%までのばらつきは許容範囲)
  • 統計を使いすぎない。安定性は統計へ頼る前から制御できる
  • マシンを安定状態に保つ(バックグラウンドスレッド・サーマルスケーリング・電源管理を抑える)。アイドル状態を保ち、ノートPCは電源接続、膝や枕の上に置かない
  • 共有インフラに警戒する。バースト可能/プリエンプト可能VMや無料枠のCI(GitHub Actionsなど)は安定したリソース割り当てができないため避ける
  • マシンの限界(GOMAXPROCS、RAM上限)に注意する
  • 実験を少し長く行いウォームアップ段階の影響を抑える
  • 古い実験結果との比較を避ける(環境が変われば全コードが10%遅くなることもある)

これらにより差異を安全な2〜5%レベルまで減らせます。

ベンチマークのレベル

ベンチマーク手法には複数のレベルがあり、横軸に「設定・維持の難しさ」、縦軸に「単発テストの有効性」を取ると整理できます。成熟したプロジェクトはこれらをすべて使います。

まず最も素朴な手法がプロダクション監視です。顧客が「実験」を実行し私たちが計測するだけで便利ですが、計測条件を集めにくく、問題状況が再発する保証もなく、フィードバックループが長くなります。最後の検証手段としては有効ですが、単独では限定的です。

本番環境でのベンチマーク … 最も信頼できるが、問題発見コストが高くループが長い
マクロベンチマーク       … 信頼性とループ速度のバランスが良い。クローズドボックス
マイクロベンチマーク     … 最もアジャイル。オープンボックスで関数単位を評価
  • 本番環境でのベンチマーク:本番を使い、定義済みのテストケースで自動ベンチマークを行う。SaaSなら容易。QoS確保のため極端な負荷はかけられず、フィードバックループは長い
  • マクロベンチマーク:本番の依存関係を備えたステージング環境やe2eフレームワークで評価する。プログラムをクローズドボックスとして扱えQAに委ねられ、本番に影響しない。一方でスイートの構築・維持コストが高く、API安定後の成熟プロジェクト向き
  • マイクロベンチマーク:分割統治によりオープンボックスとして小さな単位を評価する。Go標準の go test を用いて書け、実装10分・実行20分・破棄も容易なほど安価かつアジャイル。ただしボトルネックの誤特定リスクや、結合時のみ現れる問題、テストデータ選択の難しさがある。単一の生の数値に固執して最適化のドツボにはまらないよう、全体像を忘れないことが重要

どのレベルを使うべきかに「最適」な唯一解はなく、「知的に怠惰な」アプローチが推奨されます。(1)利害関係者が効率問題に不満なら、実機でボトルネック分析しその箇所にマイクロベンチマークを追加して有機的に育てる、(2)正式なRAERが確立したらマクロベンチマークに投資する、(3)本番をコントロールできるなら本番環境でのベンチマークを検討する。なお機能テストと違い、ベンチマークのコードカバレッジは測らないこと。最適化すべき重要箇所だけにベンチマークを置くのが理想です。

理解度チェック

Q1. 「推定」効率計算量と漸近的計算量(ビッグオー)の違いは何ですか。

A1.「推定」効率計算量は定数まで含めた具体的な関数(例:872 + 30.4 × N bytes)で、実際に何MB割り当てるかといった具体値を判断できます。一方、漸近的計算量は実装やハードウェアのオーバーヘッドと定数を無視し、入力サイズに対する成長の速さだけに注目します(例:Θ(N))。漸近的計算量を求めるのに正確な計算量は不要ですが、実務では定数の大小が効率を左右するため、両者を組み合わせると真価を発揮します。

Q2. クイックソートを「O(N×logN)」と表現するのが厳密には誤りである理由は何ですか。

A2. Oは上界(最悪ケース)を表す記法であり、クイックソートの最悪計算量はO(N²)です。平均・典型ケースを表すには本来タイトな限界Θ(N×logN)を使うべきです。ΘはいつでもOに置き換えられますが逆はできないため、典型ケースの説明にOを使うのは不正確になります。

Q3. 「ベンチマークは嘘をつく」という固定観念に対する本章の立場と、その理由は何ですか。

A3.「ベンチマークは嘘をつかない。私たちが結果を誤解しているだけだ」が本章の立場です。効率評価は複雑で再現コストが高いため誤った結論を出しやすく、その多くは悪意ではなく統計の誤謬や直感に反するパラドックスによる正直な間違いです。対策は、ベンチマークの注意深い利用者・開発者となり、データサイエンスの基本を学ぶことです。

Q4. マイクロベンチマークとマクロベンチマークの使い分けの考え方を説明してください。

A4. マイクロベンチマークはGoの関数やアルゴリズム単位をオープンボックスとして評価する手法であり、go test で安価かつアジャイルに書け、開発初期やボトルネック箇所の迅速な評価に向きます。マクロベンチマークは本番相当の依存関係を備えたステージング環境などでクローズドボックスとして評価し、信頼性は高いが構築・維持コストが大きいため、API安定後の成熟プロジェクトやRAER確立後のエンドツーエンド検証に向きます。唯一の最適解はなく、まずボトルネックにマイクロベンチマークを足して有機的に育て、必要に応じてマクロ・本番へと広げる「知的に怠惰な」アプローチが推奨されます。


8章 ベンチマーク

この章では、コードの効率を実測する手段としてのベンチマークを扱います。アジャイルなマイクロベンチマークから、コンテナと依存関係を含む実践的なマクロベンチマークまで、Goでの具体的な実行方法と結果の読み解き方を見ていきます。

マイクロベンチマークの基本

マイクロベンチマークは、単一の独立した機能に焦点を当てた小さなコードを単一プロセスで実行する計測です。コードレベル・アルゴリズムレベルの最適化を評価するのに向きます。複数機能をまとめて扱う、長時間(5〜10秒以上)稼働する、巨大なリソースを要するといったケースには不向きで、その場合はより小さく分割するかマクロベンチマークを検討します。

マイクロベンチマークは「P95が1分以下か」といった絶対的なRAER検証には向かず、ベースラインやパターンとの相対比較に注目すべきです。具体的には、入力サイズに応じた実行時計算量の理解や、たった1点だけ変えた版同士を比べるA/Bテストで力を発揮します。同じマシンで環境を変えずに連続実行し、版Aと版Bのレイテンシー差を見るのが要点です。

Goでは go test に組み込まれたフレームワークを使います。ベンチマーク関数とみなされる条件は3つです。ファイル名が _test.go で終わること、関数名が Benchmark で始まること、引数が *testing.B 1つだけであることです。

func BenchmarkSum(b *testing.B) {
	b.ReportAllocs()  // 割り当て回数・量を報告(-benchmem 相当)
	// 初期化処理
	b.ResetTimer()    // 初期化を計測外にする
	for i := 0; i < b.N; i++ {
		_, _ = Sum("testdata/test.2M.txt")
	}
}

b.N のループは必須で、削除してはいけません。go test は1反復の所要時間を見積もり、合計が目標時間(デフォルト最低1秒)になるよう最小の b.N を探します。ループ内でループ変数 i を関数入力に使うのもアンチパターンです。

テストデータは**本番の作業負荷をできるだけシミュレートした「典型的な例」を、最小サイズで選びます。開発環境で数分以内に終わることが望ましく、本番より数倍小さいデータで計測して結果を外挿(extrapolate)**する手もあります。

実行コマンドの例です。

$ go test -run '^$' -bench '^BenchmarkSum$' -benchtime 10s -count 6 \
    -cpu 4 -benchmem \
    -memprofile=v1.mem.pprof -cpuprofile=v1.cpu.pprof | tee v1.txt
  • -run '^$': ユニットテストを実行させず、ベンチマークに集中する。
  • -bench: RE2正規表現で対象を絞る。
  • -benchtime: 反復の時間(10s)または回数(100x)を指定する。
  • -count: ベンチマークサイクルを繰り返し、分散計算を可能にする(benchstatの新版は信頼水準のため最低6回を要求)。
  • -cpu: 利用CPUコア数(GOMAXPROCS)を制御する。
  • -benchmem / プロファイルオプションはわずかなオーバーヘッドを足すが、超低レイテンシーでなければ常時オンが安全である。

結果の読み方とbenchstat

go test の結果は一貫したフォーマットで出力されます。各行が1回の実行を表し、左から「CPU数の接尾辞付き名前」「反復回数」「ns/op(1操作あたりナノ秒)」「B/op(操作ごとのヒープ割り当てバイト数)」「allocs/op(操作ごとのヒープ割り当て回数)」が並びます。b.ReportMetric で独自メトリクスも追加できます。B/op・allocs/opはヒープのみで、スタックや手動マッピング分は含まれない点に注意します。

生の出力は単位が基本単位のままで読みにくく、複数計測値の選択や版間比較も困難です。そこでGoコミュニティの benchstat を使います。

$ benchstat v1.txt          # 平均と分散(±%)を人間に読める単位で表示
$ benchstat v1.txt v2.txt   # 2ファイルで比較モード(deltaカラム)

benchstatは複数実行の平均と、± で表す分散を計算します。だから -count で複数回実行することが不可欠です(1回だと分散が無限大表示)。比較モードでは差分を delta 列にパーセント表示し、Mann-WhitneyのU検定p値(デフォルト0.05未満が有意)を計算します。有意差検定が失敗すると ~ を表示します。n=6+6 は両側のサンプルサイズです。

使うときの心得は、6回以上実行すること、± の分散が3〜5%を超えていないか確認すること、分散が大きい結果の差分を信じる前にp値を見ることです。

マイクロベンチマークのヒントとコツ

分散が大きすぎる: 5%以上の差はノイズの可能性があり、結果を信頼しきれません。ベンチマークを長く実行する、再設計する、環境条件を変える(著者はブラウザを閉じ -benchtime を5秒から15秒に延ばして19%→2%に改善)といった対処をします。

自分のワークフローを確立する: gitブランチをベースに、ベースライン(v1.txt)取得 → ボトルネック解析 → 別ブランチで最適化 → まずユニットテスト → ベンチマーク(v2.txt)→ benchstatで比較、というサイクルを回します。発見はコミットメッセージやPRに残し、.txt は破棄します。ローカルでは複雑な自動化を書かず、インタラクティブに保つのがコツです。

正しい対象を測る: 最適化はバグを混入しやすいので、対象機能のユニットテストを優先して書きます。さらにベンチマークの反復内に testutil.Ok(b, err) のような軽いエラーチェックを入れると、入力ファイル名のタイプミス等で「速すぎる成功」をしている事故を防げます。testutil.TB を使えば、同じ本体をベンチマークとユニットテストの両方として実行できます。

func benchmarkSum(tb testutil.TB) {
	for i := 0; i < tb.N(); i++ {
		ret, err := Sum("testdata/test.2M.txt")
		testutil.Ok(tb, err)
		if !tb.IsBenchmark() {
			testutil.Equals(tb, int64(6221600000), ret)  // 高コストな確認はベンチ外
		}
	}
}

チーム(と未来の自分)と共有する:「ベンチで30%速くなった」だけでは検証やリグレッション防止につなげられません。マイクロベンチマークを本番コードの横にコミットし、推奨実行コマンドや前提条件をコメントで添えます。入力は b.TempDir()createTestInput で動的生成し、os.Stat でキャッシュして初期化コストを抑える工夫も有効です。

さまざまな入力でのベンチ: b.Run を使ったテーブルベンチマークで、複数の入力サイズを一度に評価できます。推定計算量を素早く把握でき、チームで再利用しやすくなります。

b.Run(fmt.Sprintf("lines-%d", tc.numLines), func(b *testing.B) {
	b.ReportAllocs()  // b.Run の外の設定は無視されるので再指定
	// ...
})

サブ関数内では b をシャドウせず、常に同じ変数名 b を使うと落とし穴を避けられます。

メモリ管理とコンパイラー最適化への注意

マイクロベンチマーク vs メモリ管理: go test が報告するメモリ統計は多くを語りません。ベンチマークには特別なGCスケジュールがなく本番同様に動くため、GCレイテンシー最大メモリ使用量はマイクロレベルでは確実にはわかりません。手動の runtime.GC() 起動も本番を反映しないので避けます。割り当て数・サイズの相対比較や計算量との照合には有効ですが、最終的な判断はマクロレベルに移って検証する必要があります。

コンパイラー最適化との戦い: コンパイラーはベンチマークコードも本番同様に最適化します。マイクロベンチマークは「他コードと並走しない」「同じ呼び出しをループする」「戻り値を使わない」という特殊性があり、**デッドコード削除(DCE)**や定数の事前計算(intrinsic)で計測が無意味になることがあります。10億回(最大反復)まで回り 0.2344 ns/op のような怪しい結果が兆候です。

対策は3つです。マクロレベルへ移行するより複雑な機能を測る(複雑なコードはインライン化されにくい)、それでも測りたければコンパイラーを出し抜くことです。出し抜きには、入力をエクスポートされたグローバル変数に逃がして定数だと悟られないようにし、戻り値をSinkパターンでグローバル変数に代入してDCEを防ぎます。

var Input uint64 = math.MaxUint64
var Sink uint64

func BenchmarkPopcnt(b *testing.B) {
	var s uint64
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		s = popcnt(Input)
	}
	Sink = s
}

ただしSinkを至る所に置くのは不要です。結果が明らかに最適化されてからSinkを入れるのが賢明で、//go:noinline は本番のインライン化を再現できなくなるため非推奨です。そもそもこうした超低レベルなベンチマークへの依存は避け、より高レベルな機能を測るのが著者の推奨です。

マクロベンチマークの基本と実行

マクロベンチマークは製品レベル(アプリ・サービス・システム)のコードを、機能・効率要件に近いところで測るもので、統合テストやe2eテストに相当します。Sum を使う labeler マイクロサービス(HTTP GET /label_object でオブジェクトの統計を返す)を例に、マイクロとの違いを整理します。

  • 別プロセスのGoプログラム: 本番同様の方法・構成でプロセスを実行するため、go test のベンチマークフレームワークは使えません。
  • 依存関係: 主要な依存(オブジェクトストレージ等)を含めて全体効率を測る。現実の依存を使う、フェイク/アダプタを作る、最も単純なフェイク(ローカルのMinioなど)を使う、の3通りがある。
  • オブザーバビリティ: 組み込みの計測がないため、Prometheusなどで自前のレイテンシー・割り当て・カスタムメトリクスを収集する。
  • 負荷テストツール: ユーザートラフィックを再現するため、k6のような既製ツールを使います(自前実装は非効率になりがち)。
  • CI/CD: 複雑なシステムでは、デプロイと負荷テストのスケジューリングを自動化する。

Goのe2eフレームワーク (github.com/efficientgo/e2e) を使うと、GoコードとDockerコンテナで1台のマシン上に実験環境を組めます。t.Skip やビルドタグでガードした通常の go test として実装し、Minio(依存)、Prometheus+cadvisor(監視)、labeler(対象、CPU制限付き)、k6(負荷)の各コンテナを起動します。1コンテナ1プロセスを守り、補助プロセスはサイドカーに分けます。

k6は1つの仮想ユーザー(VUS)でHTTP GETを呼び、check で出力の正しさを検証し、各呼び出し後に500msスリープさせます。e2einteractive.RunUntilEndpointHit() で、指定URLにアクセスするまでテストを保持し、その間にメトリクスを調査できます。コンテナイメージは :latest を避けてバージョン固定します。

結果と考察、マクロのワークフロー

マクロベンチマークを5分(実測7分程度)実行するのは、システム全体を安定させGCの影響を観察するため、そしてPrometheusの推奨スクレイプ間隔(15〜30秒)を複数回通すためです。

k6の出力では、http_req_duration が総レイテンシー(avg 128.9ms、p90 160ms)、http_reqs(約1.6 req/s)が呼び出し総数を示します。テイルレイテンシー(パーセンタイル)が重要です。再現性確認のため同じテストを2回実行し、分散が小さいことを確かめます(benchstat的な分析も可能)。

Prometheusのメトリクスで多面的に評価します。サーバー側レイテンシーは http_request_duration_seconds ヒストグラムから把握します。CPU時間は container_cpu_usage_seconds_total から測ります(labelerは0.25〜0.27 CPU秒で、4コア中ほぼ飽和していない)。メモリは go_memstats_heap_alloc_bytes(ヒープ)と container_memory_rss(物理RAM)から見ます。rate には [1m] の範囲ベクトルを使い、ゲージの瞬間スパイクや長期間クエリの解像度低下には max_over_time などで対処します。Sumのマイクロ結果(60.8MB/op)と整合する118〜120MBのヒープ増加が観察され、即時リークがないことも確認できました。

一般的なマクロベンチのワークフロー: チームで要素・依存・負荷を計画する → コードを全コミットしてクリーンな状態を保つ → 共有Googleドキュメントへ環境条件とバージョンを記録、という準備を整えます。次に、ベンチマーク実行(機能エラー確認、k6結果とリソース観察のスクリーンショットを文書に集約、必要ならプロファイル収集)→ 最適化を新コミットで保存し再ベンチして版間A/Bテスト、という流れで進めます。マクロベンチマークは「本番環境でのベンチマーク」とツールを相互流用でき、一貫した唯一の手順はなく、ソフトウェアの種類や投資できる金額に依存します。

理解度チェック

Q1. `b.N` のループはなぜ必須で、ループ変数 `i` を関数入力に使ってはいけないのはなぜですか。

A1. go test は1反復の所要時間を見積もり、合計が目標時間(デフォルト最低1秒)になるよう最小の b.N を自動で探します。このループがないとベンチマークは1反復しか実行されず(b.Nに依存しないため1秒を超えない)、結果が不正になります。また、i を入力に使うと反復ごとに入力が変わり、計測対象が安定しません。popcnt(uint64(i)) のような書き方はアンチパターンです。

Q2. benchstat で版を比較したとき、delta 列に `~` が表示されるのはどういう意味ですか。

A2. 有意差検定(デフォルトはMann-WhitneyのU検定)が失敗したことを意味します。すなわち、有意確率が閾値(デフォルト0.05)を超え、2つの平均値の差を統計的に有意とは言えない状態です。改善するには -count を増やします。ただし、両者の値が十分大きく差を無視できる場合(例: 60.8MB同士)は、有意性を気にせず一般的な推論で済ませてよいこともあります。

Q3. マイクロベンチマークが報告するメモリ統計(B/op, allocs/op)で「わからない」ことは何ですか。

A3. GCレイテンシー(GCがCPUに与える影響)と最大メモリ使用量です。ベンチマークには専用のGCスケジュールがなく本番同様に動くため、GCがいつ何回走るかは非決定的で、60MB割り当てても実際の最大使用量は10MBから120MBまで変動し得ます。割り当て数・サイズの相対比較や計算量との照合には使えますが、最終判断はマクロベンチマークで検証する必要があります。

Q4. コンパイラーのデッドコード削除でベンチマークが無意味になったとき、低レベル関数を測り続けたい場合の対策は何ですか。

A4. コンパイラーを出し抜きます。入力をエクスポートされたグローバル変数に逃がして「定数だ」と悟られないようにし、戻り値をSinkパターン(グローバル変数への代入)で受けてデッドコード削除を防ぎます。//go:noinline は本番のインライン化を再現できなくなるため非推奨です。そもそもこうした超低レベルなベンチマークへの依存は避け、より複雑な機能を高レベルで測るほうが安全です。

---

9章 データ駆動ボトルネック分析

効率化で最大の価値を得るには、まず最大の寄与者(ボトルネック/ホットスポット)を特定する必要があります。プログラマーの勘は外れがちなので、推測ではなくデータを集めて分析します。本章ではその主役であるプロファイルと、Goのpprofエコシステムを学びます。

効率化のための根本原因分析

効率性のボトルネック分析は、システム障害における**根本原因分析(RCA)**と本質的に同じ営みです。実際、CPU飽和でリクエストがタイムアウトするなど、効率性の問題は多くのインシデントを引き起こします。

複雑なシステムでは、症状(根本状況が引き起こす目に見える影響。OOMなど)や目くらまし(red herring)(調査の本筋とは無関係な予期せぬ挙動)、複数のボトルネックが絡み合い、調査は難しくなります。第6章で扱ったメトリクス・ログ・トレースは引き続き有効で、リソース使用の絞り込み、ステージごとのレイテンシー計測、リクエストパスの遅延要因の特定に役立ちます。

ただしコードを1つずつ無効化する試行錯誤は大規模システムでは非現実的です。原因となる正確なコード行を数秒で教えてくれるシグナルこそがプロファイルであり、しばしばオブザーバビリティの第4の柱と呼ばれます。

Goにおけるプロファイルとpprof形式

プロファイルは動的コード解析の一種で、特定のコード行が消費するリソース(経過時間・CPU時間・メモリ・ゴルーチン数など)の使用状況を表します。Goの標準ライブラリには6つのプロファイル実装があり、すべてが同じ規則と形式(pprof形式)に従うため、どのプロファイルでも同じ可視化・分析ツールが使えます。

pprof形式の実体はgzip圧縮されたprotobufバイナリ(拡張子 .pprof / .pb.gz など)で、スキーマはgoogle/pprofで定義されています。シンプルなカスタムプロファイルなら pprof.Profile 構造体だけで実装でき、Add / Remove でオブジェクトを記録し WriteTo で書き出します。

pprof.NewProfile("fd.inuse")  // グローバル名で登録
profile.Add(obj, 2)           // 第2引数はスキップするスタックフレーム数

pprofデータモデルの主な構成要素は次のとおりです。

  • Mapping: バイナリのアドレス対応。デバッグシンボルがなくてもシンボル化で後からソース行へ変換できる(Go標準プロファイルはメタデータを内包するため通常不要)。
  • Location: コード行(スタックフレーム)。定義元の関数とファイル名を持つ。
  • Function: 関数のメタデータ。
  • ValueType: プロファイルの次元(値の単位と意味)。ヒープは複数の型を持つ。
  • Sample: あるスタックトレースによる計測値(寄与=contribution)。ラベルも付けられる。
  • period: サンプリング周期。period: 1 なら全イベント捕捉(100%)を意味する。

CPUプロファイルは全サイクルを追跡できないためサンプリングを前提とします。統計的プロファイルは100%正確とはいえず、寄与の少ないコードを見逃すこともあります。それでもボトルネック分析(=大半を占めるものを探す)には十分有用で、ほぼ全プログラムで安全に常時有効化できます。-raw 出力で period を確認する習慣を持ちましょう。

go tool pprof -raw fd.pprof   # 半人間可読の生出力。periodを確認できる

go tool pprof のレポートとビュー

.pprof の解析には go tool pprof が定番です。Webビューアーを起動するのが最も便利です(graphvizが必要)。

go tool pprof -http :8080 fd.pprof

ビューを理解する鍵が直接値(Flat)累積値(Cum)です。直接値はそのノードがリソースを直接消費した分、累積値はそのノードが呼び出した先も含めた合計です。累積値はコストの高い「フロー」を、直接値はボトルネックの「原因」を教えてくれます。なおゴルーチンをまたぐと累積値は誤解を招く点に注意します。Goは各ゴルーチンのスタックを分離するため、生成元との親子リンクがありません。

主要な6ビューは次のとおりです。

  • Top: 関数(粒度は -functions / -files / -lines / -address で切替)ごとに直接値・累積値・Flat% / Sum% / Cum% を表形式で表示。最速で大寄与者を見つけられるが、フロー間のリンクは分からない。
  • Graph: 有向非巡回グラフ。ノードの大きさ=直接値、枠線・塗りの色(金→赤)=累積値、エッジの太さ=累積寄与度を表す。関係(階層)が直感的で、不慣れなコードベースでも扱いやすい。読みやすさを優先して約80ノード・しきい値で一部が非表示となる点に注意。
  • フレイムグラフ: スタックトレース集合を逆さの「つらら」で可視化。**重要なのはセグメントの幅(累積寄与度)**で、高さ(深さ)や色・順序は意味を持たない。コンパクトで主要ボトルネックの有無を一目で把握でき、上級者に好まれる。
  • Peek / Source / Disassemble: 粒度に依存せず行・アドレスレベルを表示。Sourceはソースコードの文脈に寄与をマッピングでき、計算量解析にも有用。Disassembleはアセンブリレベルで、プロファイル取得時と同一バイナリの提供が必要。

REFINE(Focus/Ignore/Hide/Show)や正規表現検索でフィルタでき、同じことはCLIの -focus 等でも可能です。これらの表現はPolar Signals・Grafana Phlare・Pyroscope・各種クラウドプロファイラーやIDEでも共通して使えます。

プロファイルシグナルの取得

プロファイルは第4のオブザーバビリティシグナルとして、計装と信頼できる実験が必要です。最適化ワークフローはTFBOループに組み込めます。すなわち、ベンチマークで効率を計測し、結果に不満なら同じベンチマークを再実行してプロファイルを取得します。

プロファイルの取得(トリガーと保存)は主に3パターンに集約されます。

  • プログラムでトリガー: WriteTo(w io.Writer) 系を呼び、記録済みサンプルを書き出す。CPUの場合は StartCPUProfile(w) / StopCPUProfile() で明示的に区間を区切るものもある。開発時の素早いテスト向き。
  • Goベンチマーク統合: go test のフラグ(-memprofile -cpuprofile -blockprofile -mutexprofile)で標準プロファイルを取得。マイクロレベルで有用。
  • HTTPハンドラー: マクロ・本番環境で最も一般的。net/http/pprof を登録すると /debug/pprof/ 配下でプロファイルを公開できる。
m.HandleFunc("/debug/pprof/", pprof.Index)
m.HandleFunc("/debug/pprof/profile", pprof.Profile)  // CPUは個別登録が必要

グローバルな http.DefaultServeMux ではなく専用Muxへ明示登録するのが推奨です。HTTP経由の使い方は次のとおりです。ブラウザで ?debug=1 を付けてテキスト表示する、ダウンロードして go tool pprof で開く、pprofをURLに直接向ける、Parca等で定期収集するといった選択肢があります。eBPFによる自動計装も可能ですが、Linux限定で移植性に課題があり、本書はネイティブのインプロセスプロファイラーを中心に扱います。

共通のプロファイル計装(ヒープ・ゴルーチン・CPU・Off-CPU)

ヒープ(heap / alloc): ヒープ割り当ての主な寄与者を見つけます。runtime.MemProfileRate(デフォルト512KBごとに1サンプル、変更非推奨)でサンプリングされます。4つの値型を持ちます。

  • alloc_space: 開始以降の累積割り当てバイト数。GC済みも含むので巨大になりがち。過去の大量割り当てやGC負荷の発見に有用。
  • alloc_objects: 累積割り当てブロック数。頻繁な割り当てによるレイテンシー要因の発見に。
  • inuse_space: 現在生存中のバイト数。特定時点のメモリボトルネックやメモリリークの発見に最適。
  • inuse_objects: 現在生存中のオブジェクト数。GCのマーク負荷をよく表す。

ヒープはHTTPハンドラーで ?seconds=30s を付ければリモートでデルタプロファイルを取得できます。

ゴルーチン(goroutine): 実行中のゴルーチン数と内容(I/O・ロック・チャネル待ちを含む)を表します。サンプリングなしで全ゴルーチンを捕捉します。同じ作業をするゴルーチンが増え続けるならリークの疑いがあります。プロファイルには runtime.gopark(待機による一時停止)、runtime.chanrecv / chansend(チャネル送受信待ち)、runtime.selectgo(select待ち)、runtime.netpollblock(ネットワークI/O待ち)などが現れます。

CPU: CPU時間を最も使うコードを見つけます。Goランタイム統合のプロファイラーで、pprof.StartCPUProfile(w) / StopCPUProfile()、ベンチの -cpuprofile、HTTPの /debug/pprof/profile?seconds=N で取得します。OSタイマーと SIGPROF シグナルでスタックを採取し、レートは100Hz(10ミリ秒あたり1サンプル)にハードコードされています。低サンプリングのため関数粒度での閲覧が無難です。HTTPハンドラーは指定秒数(既定30秒)の実行を終えてから応答する点に注意します。

Off-CPU時間: ゴルーチンの大半はCPUで実行するのではなく待機しています。実行時間は4カテゴリに分けられます。

  • CPU時間: CPUを能動的に使った時間。
  • ブロック時間: ミューテックス・チャネル等の同期待ち。runtime.SetBlockProfileRate(int) で有効化(既定無効)、-blockprofile/debug/pprof/block で取得。
  • ミューテックス時間: ロックコンテンション。runtime.SetMutexProfileFraction(int) で有効化(既定無効)、-mutexprofile/debug/pprof/mutex で取得。
  • 追跡されないoff-CPU時間: スリープ・I/O・システムコールなど。標準プロファイルでは捕捉できないため、トレースや fgprof(Full Go Profiler) を使う。fgprofはCPU時間とoff-CPU時間の両方を追跡できる。

レイテンシーのボトルネックには(分散)トレースの利用が推奨されます。プログラムが遅い主因はCPUではなく、依存先(例: DBの応答待ち)であることも多いためです。

ヒントとコツ(共有・継続的プロファイリング・比較)

プロファイルの共有: スクリーンショットでは対話的機能(ビュー切替・値型・絞り込み)が失われます。pprof.me(Polar Signals提供の無料サイト)にアップロードしてリンク共有すれば、相手がダウンロードせず go tool pprof 相当のビューで分析できます(公開される点に注意)。

継続的プロファイリング(continuous profiling): 手動トリガーではなく、一定間隔でプロファイルを自動収集し「常にオン」にする手法です。過去の事象を後から振り返れ、バージョン間や時点間の比較が行番号レベルで可能になります。概念自体は古く、Googleの大規模事例(2010年)が有名です。オープンソースの Parca はPrometheus同様、HTTPハンドラーから定期的にプロファイルをスクレイプし(scrape_interval、本番では1分間隔程度を推奨)、FrostDBに保存します。各プロファイルのサンプル値の合計から時系列メトリクスも抽出できます。オーバーヘッドは常時かかるため、標準プロファイラーではCPUの5%以内を目標にし、間隔やサンプリング、レプリカ1台のみのプロファイルなどで制御します。

プロファイルの比較と集計: pprof形式は複数プロファイルの操作に対応します。

  • 差分(diff): go tool pprof heap-AB.pprof -base heap-B.pprof でAの純粋な影響を抽出。ノイズ削減に有用。
  • 比較(compare): -diff_base で正負のデルタを表示。最適化前後の寄与変化の計測に。
  • マージ(merge): 複数プロファイルを1つに集約。google/pprof/profile.Merge で実装可能(翻訳注: 現在は go tool pprof -proto a.pprof b.pprof でも可能)。

Parcaのような高度なUIには Compare / Merge ボタンがあり、これらの操作を直感的に選べます。

最後に、本章の教訓は 「プロファイルが先、質問は後」 です。原因を推測する前に、まずプログラムからプロファイルを取得しましょう。pprofという共通形式のおかげで、ツール間の相互運用やeBPFによる安価で言語横断なプロファイルが今後さらに進むと期待されます。

理解度チェック

Q1. プロファイルにおける「直接値(Flat)」と「累積値(Cum)」の違いを説明してください。

A1. 直接値はそのノード(関数・行)がリソースや時間を直接消費した寄与を表し、累積値はそのノードが呼び出した先も含めた寄与の合計です。直接値は潜在的なボトルネックの原因を、累積値はどのコードフローがコスト高かを示します。ただしゴルーチンをまたぐと、Goは各ゴルーチンのスタックを分離し親子リンクを持たないため、累積値が誤解を招くことがあります。

Q2. なぜCPUプロファイルやヒーププロファイルはサンプリングされるのですか。それでも有用なのはなぜですか。

A2. 全サイクル・全割り当てを追跡するとスタックトレースの記録コストが膨大になり、プログラムを大幅に遅くする(CPUは事実上不可能)ためです。それでも有用なのは、プロファイルの目的がボトルネック分析、すなわち「大半を占めるもの」を探すことにあるからです。統計的に、最もリソースを使うコードはサンプリング下でも上位に現れます。CPUは100Hz、ヒープは既定512KBごとにサンプリングされます。

Q3. ヒーププロファイルの4つの値型のうち、「メモリリークの調査」に最も適したものはどれですか。理由も述べてください。

A3. inuse_space(および inuse_objects)です。現在ヒープ上に生存しているメモリ/オブジェクトを表すため、割り当てられたまま一度も解放されないメモリがプロファイル上で目立ち、リークを発見しやすいからです。一方 alloc_space / alloc_objects は開始以降の累積で、GC済みも含むため特定時点の生存量は分かりません。

Q4. 標準のCPU・ヒープ・ゴルーチンプロファイルで捕捉できない「Off-CPU時間」を分析するには、どんな手段がありますか。

A4. ブロック時間は runtime.SetBlockProfileRate で有効化したblockプロファイル、ミューテックス時間は runtime.SetMutexProfileFraction で有効化したmutexプロファイルで取得できます。スリープ・I/O・システムコールなど追跡されないoff-CPU時間には、(分散)トレースや、CPU時間とoff-CPU時間の両方を追える fgprof(Full Go Profiler) が有効です。プログラムが遅くなる主因は依存先の待ち時間であることも多いため、CPU時間だけを見ないことが重要です。


10章 最適化の例

本章は、ファイルから整数を読んで合計する素朴な Sum 関数を題材に、TFBO(テスト・修正・ベンチマーク・最適化)の流れを3つの異なる効率性要件へ適用する実践的なケーススタディです。重要なのは個々の最適化テクニックそのものではなく、計測 → ボトルネック特定 → 一度に 1 つだけ改善 → 再計測という一連の思考プロセスを身に付けることです。

目標設定とベースライン計測

最適化には「ただ速くする」ではなく、具体的な目標が欠かせません。本章では同じ Sum に対して3つの要件を順に課します。すなわち、1CPUで1行あたり最大10ns、メモリは入力によらずヒープ最大10KB、4CPUで1行あたり最大2.5nsです。

合計問題のビッグオー実行時計算量は最低でもO(N) なので、「100ms以内」のような絶対的なレイテンシー目標は機能しません(入力をいくらでも大きくできるため)。そこで目標は入力サイズに依存する関数、つまり償却レイテンシー(1行あたり何ナノ秒)として定義します。

TFBOに従い、最適化前にまずベンチマークを取ります。200万整数のファイルで1CPU限定・複数回計測したベースラインは101ms、60.8MB割り当て、160万回のアロケーションでした。これを基準(約50 × Nナノ秒)とします。

レイテンシーの最適化(一度に 1 つずつ)

ボトルネック特定にはプロファイルによる解析が最善です。CPUプロファイルのフレイムグラフから、bytes.Splitstrconv.ParseIntruntime.slicebytetostring(文字列変換に伴うメモリ割り当て)、GC(gcBgMarkWorker)の4つが主要因と判明します。ただしCPU時間がボトルネックとは限らないため、fgprof(フルゴルーチンプロファイル)でCPU時間とOff-CPU時間の両方を確認します。結果はI/O約5%・CPUが大部分の混合バウンドで、まずCPU時間を最適化できると分かりました。鉄則は 「一度に 1 つの最適化を繰り返す」 こと。最も大きい bytes.Split(CPU約36%、メモリ割り当て約78.6%)から着手します。

第1段(Sum2)では bytes.Split を排除します。標準関数は複数文字セパレーターなど多くのエッジケースに対応するため、特定用途には過剰です。改行をハードコードした in-place ループに置き換えるだけで、不要な2次元スライス確保や繰り返し走査が消えます。

for i := 0; i < len(b); i++ {
    if b[i] != '\n' {
        continue
    }
    num, _ := strconv.ParseInt(string(b[last:i]), 10, 64)
    ret += num
    last = i + 1
}

結果は50ms・12.8MBと、このボトルネックをほぼ完全に除去できました。教訓は 「標準関数がすべてのケースに最適とは限らない」——クリティカルパスにはカスタム実装で簡単に効率を上げられる余地があります。

第2段(Sum3)の次のボトルネックは string(b[last:i]) による文字列変換です。Goの string は不変であるため、[]bytestring 変換はバイト列をコピーします。[]bytestring はメモリレイアウトを共有するため、unsafe を使ったゼロコピー変換で回避できます(Prometheus等で実運用される yoloString 手法)。

func zeroCopyToString(b []byte) string {
    return *((*string)(unsafe.Pointer(&b)))
}

これで25.5ms、割り当ては入力サイズ相当の7.2MBまで縮小。ただし unsafe意図的なトレードオフであり、目標達成に必要な場合のみ正当化して使うべきです。

第3段(Sum4)ではCPUの72.6% を占める strconv.ParseInt を、必要十分な独自実装に置き換えます。標準実装は空文字列を2回チェックし、任意の基数・ビットサイズに対応する汎用機能を持ちますが、ここでは基数10・64bitのみで十分です。[]byte を直接受け取る ParseInt を自作することで、コードが簡素になり、危険な unsafe 変換も不要になります。

func ParseInt(input []byte) (n int64, _ error) {
    factor := int64(1)
    k := 0
    if input[0] == '-' { factor *= -1; k++ }
    for i := len(input) - 1; i >= k; i-- {
        n += factor * int64(input[i]-'0')
        factor *= 10
    }
    return n, nil
}

最終的に13.6ms(約6.9 × Nナノ秒)となり、最初の目標を達成。1,000万整数でも6.78 × Nナノ秒で、O(N) どおりスループットが保たれることを確認しました。目標を達成したら、それ以上磨く必要はないのです。

メモリ使用量の最適化

2つ目のシナリオは、IoTデバイス想定で入力サイズに関係なくヒープ最大10KBという要件です。Sum4 はファイル全体を os.ReadFile で読むため空間計算量がO(N) で、目標には程遠い状態です。ヒーププロファイルでも os.ReadFile が99.6% を占めます。

解決策はストリーミングアルゴリズム(外部メモリアルゴリズム)への移行です。全バイトを同時にメモリへ載せず、チャンク単位で読みます。まず標準の bufio.ScannerSum5)を使うと、4KB程度のメモリで動きメモリ目標は満たすものの、ScanLines がCR削除など不要な処理をするため遅く、15.6 × Nナノ秒でレイテンシー目標を割ります。

そこで bufio.Scanner をやめ、固定バッファによる自前ストリーム読み込み(Sum6)を実装します。8KBのバッファ(10KB制限に余裕を持たせ、OSページ4KBの2ページ分)を使い、行をまたいだ未処理バイトはバッファ先頭へ copy でシフトして残します。読み込みロジックを io.Reader を取る Sum6Reader として分離し再利用性も確保します。

結果は1,000万行で69.4ms(約6.9 × Nナノ秒)、約8KBの安定したヒープ割り当て。runtime.MemProfileRate = 1 で全割り当てを記録し、入力サイズによらず8,328バイト一定であることを検証して、定数の空間計算量を達成しました。

並行処理によるレイテンシー最適化

3つ目は4CPUを使い1行あたり2.5nsを狙います。ファイル全体を先読みすれば残りはCPUバウンドなので、ゴルーチン間の調整を最小化しつつ作業を均等分割するのが課題です。加算は可換・結合的なので並列化しやすい性質を利用します。4つのアプローチを段階的に試します。

  1. 素朴な並行処理(ConcurrentSum1:1行ごとにゴルーチンを起動し atomic.AddInt64 で合計する。結果は約540msと逐次版の約40倍遅い。プロファイルに runtime.scheduleruntime.newproc が現れ、3つの問題が露呈する。すなわち(1)作業が小さすぎてゴルーチンのオーバーヘッドに見合わない、(2)数百万のゴルーチンを4コアで捌けない、(3)行数に依存する無制限の並行処理である。

  2. 分配を利用したワーカー(ConcurrentSum2ワーカーパターン(ゴルーチンプーリング)で固定数のワーカーを起動し、別ゴルーチンがチャネル workCh 経由で行を分配する。各ワーカーは部分和を計算して最後に加算する。それでも207msと逐次版の15倍遅い。プロファイルで runtime.chansendchanrecvschedule が支配的であり、作業の調整と分配が作業そのものより多くの CPU を消費していた。

  3. 連携しないワーカー=シャーディング(ConcurrentSum3:ファイルサイズが事前に分かることを利用し、チャネル通信なしで各ワーカーに担当範囲を決定的に割り当てる協調性のない(coordination-free)アルゴリズムshardedRange でバイト列を分割し、各ワーカーが独立に部分和を出す。これで6.9msと逐次版のほぼ2倍速に。ただし3.4 × Nナノ秒で目標2.5にはまだ届かない。

  4. ストリーム化&シャード化ワーカー(ConcurrentSum4:fgprofで見ると os.ReadFile が実時間の約50% を占めるOff-CPUボトルネックと判明。そこでファイル読み込み自体も並行化する。io.NewSectionReader で各ワーカーにファイルの担当セクションを直接読ませ、メモリ最適化で作った Sum6Reader を再利用する。結果は200万行で4.5ms、約2.3 × Nナノ秒で目標達成。割り当ても0.03MBまで縮小した。

おまけ:独創的に考える

ハードな最適化が常に正解とは限りません。同じファイルが繰り返し使われる前提なら、map[string]int64 による最も単純なキャッシュSum7)を4行足すだけで、1億行のファイルでも228ns・0割り当てを実現できます。入力が変化する場合はファイルのチェックサムでキャッシュする手もあります。本格用途にはHashiCorpのgolang-lru、Dgraphのristretto、分散ならMemcached/Redis/groupcache等が候補です。「難しく考えず、賢く考える」——膨大な最適化作業を回避できるスマートな解があるなら、その価値を見極めるべきです。

総括として、本章は Sum の実行時計算量を約50.5 × Nから2.25 × N(約22倍高速、O(N)の定数を最適化)へ、空間計算量をO(N) から定数8KBへと改善しました。常にベンチマークとプロファイルで自分の仮説を確認することが、最適化の旅を通じての最大の教訓です。

理解度チェック

Q1. なぜ Sum の最適化目標を「100ms 以内」のような絶対値ではなく「1 行あたり N ナノ秒」という関数で定義したのですか。

A1. 合計問題は全行を最低1回走査するため実行時計算量がO(N) で、レイテンシーは入力サイズに比例します。絶対的なレイテンシー目標を置くと、それを必ず破る大きな入力が存在してしまいます。そこで入力(スループット)に依存する償却レイテンシー関数として目標を定義します。

Q2. 「一度に 1 つの最適化を繰り返す」ことが推奨されるのはなぜですか。

A2. 各最適化は他の最適化に影響し未知の要素を増やすため、まとめて変更すると効果の切り分けが難しくなります。1つずつ進めればプロファイル間の寄与度比較などから信頼性の高い結論が得られ、また最初の1つで要件を満たせるなら残りのボトルネックを触る必要もなくなります。

Q3. 素朴な並行処理(1 行 1 ゴルーチン)が逐次版より約 40 倍も遅くなったのはなぜで、最終的にどう解決しましたか。

A3. 1行ごとの作業が小さすぎてゴルーチン生成・スケジューリングのオーバーヘッドに見合わず、行数分の無制限なゴルーチンを4コアで捌くスケジューラ負荷も加わったためです。解決には、固定数ワーカーで作業をグループ化し、さらにチャネル通信を排した協調性のないシャーディングにし、最後にファイル読み込みも io.SectionReader で並行化することで目標の2.5 × Nナノ秒に到達しました。

Q4. メモリ最適化で標準の bufio.Scanner を使わず自前のバッファ読み込み(Sum6)を実装した理由は何ですか。

A4. bufio.Scanner はメモリ目標(約4KB)は満たしましたが、ScanLines がCR制御文字の削除など本ケースに不要な処理を行うため遅く、レイテンシー目標を割っていました。8KBの固定バッファで行をまたぐ残りバイトを先頭へシフトしながら読む自前実装にすることで、定数8KBのヒープでレイテンシー目標も同時に満たせました。


11章 最適化パターン

本章は、前章までに学んだ評価手法を前提に、Goコードで繰り返し現れる再利用可能な最適化パターンとよくある落とし穴を整理します。ここで紹介する最適化はどれも意図的なものであり、開発時間とメンテナンスコストを正当化できる根拠(オブザーバビリティとベンチマークによる裏付け、YAGNI)があって初めて採用すべきものだという前提を忘れないでください。

よくある4つのパターン

最適化の旅で繰り返し現れる発想を、4つの一般的なパターンとして概観します。

作業を減らすは最初に注目すべきパターンです。切り口としては、不要なロジックをスキップする、汎用的な実装を避ける、不変量の再検証や再ソートを「一度だけ行う」などがあります。さらに数学を使って計算量を減らしたり、事前計算済みの知識を活用したりする方法もあります。たとえば前提条件を利用すれば、strconv.ParseInt のような汎用関数が持つ余計なチェックを切り捨てられます。汎用的・抽象的な実装は安全で便利ですが、効率は最初の犠牲になりがちです。

メモリ削減も「作業を減らす」の一例です。値を使わないマップやチャネルでは空構造体を使うと無駄な領域を回避できます。

dup := make(map[T]struct{}, len(slice)) // any より省メモリ・約22%高速
ch := make(chan struct{})               // 同期専用チャネル

残りの3つは交換(トレードオフ)のパターンです。機能を効率と交換するは、あまり使われない機能(例: 負数サポート)を削って実装コストを下げます。空間を時間に交換するは、メモリを使って時間を節約する発想です。具体的には事前計算、キャッシュ(LRUなど)、データ構造の補強(サイズを隣に格納する等)、圧縮の解除といった手段を取ります。時間を空間に交換するはその逆で、より強く圧縮する、余計なフィールドを削る、再計算するといった手段でメモリを節約します。キャッシュを取り除くのも有効です。これらの交換は直感に反することもあり、メモリ節約のためにいったん多めの割り当てを必要とする場合もあります。

3つのRの最適化法

メモリ割り当てとGCオーバーヘッドに特に効くフレームワークが、Reduce(削減)・Reuse(再利用)・Recycle(リサイクル) の3つのRです。理想的にはこの順序で検討します。

Reduce(割り当ての削減) は最優先です。ヒープに置くオブジェクト数や割り当て回数を減らすほどGCに優しくなります。スライスの事前割り当てのような明白なものから、文字列のインターン化、安全でない []bytestring 変換、変数をスタックに収める工夫まで幅があります。アルゴリズムレベルで空間計算量を改善するのが先決です。

Reuse(メモリの再利用) は、変数・スライス・マップをループごとに作り直さず明示的に使い回す手法です(TCP接続の維持もシステムレベルの再利用例)。ただし複雑な構造体の再利用は、状態リセットの難しさと並行利用時のデータ競合という2つの危険をともないます。

Recycle(リサイクル) はGCにメモリをOSへ返させる仕組みで、基本はGC任せです。さらに踏み込むなら、オブジェクト内のポインター数を減らしてGCとLキャッシュに優しくする、GOGCGOMEMLIMIT でGCをチューニングする、といった手があります。極端な手段として runtime.GC() の手動トリガー(ライブラリでは通常アンチパターン)や、mmapGOEXPERIMENT=arena によるオフヒープ割り当てもありますが、これらは最後の手段です。Kubernetesでは GOMEMLIMIT をPodメモリ制限の90〜95%に設定すると有効な場合もあります。

リソースを漏らさない

リソースリークはGoプログラムの効率を蝕む一般的な問題です。同じ負荷に対して際限なくリソースを消費し、いずれ枯渇する場合がリークです(一時的にメモリを浪費してもいずれ解放されるならリークではありません)。次のサイクルで終了する予定でも、作ったものは必ずクリアすることが推奨されます。

ゴルーチンのライフサイクル管理が第一です。「どう停止させるか」「終了を待つべきか」を常に自問します。下のコードは一見制御できているようで、キャンセル時にゴルーチンを待たず放置するため永続的にリークします。

// 悪い例: キャンセル時にゴルーチンを待たない
select {
case <-r.Context().Done():
    return // respCh を読まないままゴルーチンが残る
case resp := <-respCh:
    ...
}

バッファ付きチャネルにすれば取り残されたゴルーチンはいずれ終了しますが、リソース計算の不透明さ・遅延した枯渇・本当に終わったか不明、という点で依然リークです。正しくは常に結果チャネルを待ち、context をゴルーチンへ伝搬してキャンセルに即応します。リークはUberの goleakdefer goleak.VerifyNone(t))を使えばユニットテストレベルで検出できます。並行コードのベンチマークでは sync.WaitGroup などで完了を必ず待つこと(待たないとスケジュール時間しか測れません)。

確実に閉じることも重要です。クローザー(CloseCancelStopcontext.CancelFuncos.Remove 等)を持つものは必ず呼び出します。Close のエラーも握り潰さず処理すべきで、errcapturelogerrcapture を使えば名前付き戻り値経由でクローズエラーを返せます。複数ファイルをエラー時に閉じる場合は defer が使えないため、開いた分をまとめて閉じるヘルパー(merrorserrors.Join)が必要です。

使い切る必要がある実装もあります。代表が http.Response.Body で、Close だけでなくEOFまで読み切らないとTCP接続が再利用されず、レイテンシー悪化やファイル記述子枯渇を招きます。ExhaustClose のようなヘルパーで読み切ってから閉じます(実測で約29%高速化)。イテレーターを Next()false になるまで回し切るのも同様です。

できるだけ事前に割り当てる

最終的なサイズが分かっているなら、コンテナを事前に確保すれば再割り当てとコピーの作業を減らせます。

slice := make([]T, 0, n)      // 容量だけ確保して append
m := make(map[int]string, n)  // マップも容量指定可
buf.Grow(n); builder.Grow(n)  // Buffer / Builder

スライスやマップの事前割り当ては約8倍高速・メモリも大幅減で、ほぼデメリットがないため合理的な最適化です。io.ReadAll はサイズ引数を取りませんが、Content-Length などでサイズが分かれば bytes.Buffer.Growio.Copymakeio.ReadFull で大幅最適化できます(実測8倍以上高速・メモリ5分の1)。連結リストのような構造でも、ノードを個別割り当てする代わりに大きな []Node プールを一度に確保すれば高速化できますが、こちらはトレードオフがあるため慎重に行います。

配列でメモリを使いすぎない

スライスは強力ですが、大きな配列の一部だけを参照し続けると疑似メモリリークが起きます。GCは「連続したメモリブロックがまだ参照されている」ことしか判断できず、その1%しか使われていなくても切り取って解放してはくれません。プールしたノードや buf[1:2] のようなサブスライス、ゼロコピー関数の使用でこの問題は容易に発生します。

// 解決策: 必要分だけの新しいスライスへコピーし直して古いブロックを解放
l.pool = make([]Node, objs)
// ... 各要素を新プールへコピー、head ポインターも貼り替える

この問題はGoのベンチマーク(マイクロ)では現れにくいため、疑わしいときはマクロベンチマークで検証します。事前割り当てやバッファ再利用をやめろという話ではなく、Goのメモリ管理を常に意識せよというリマインダーです。

メモリの再利用とプール

操作ごとに大きな structslice を割り当てるのは、ゼロ化コスト・GC負荷・最大ヒープの肥大化の点で無駄です。単純なバッファ再利用(buf = buf[:0] で長さを0に戻して append)でも約2倍高速・割り当てゼロにできます。ただしポインターを含むスライスを再利用する際は、残った古い要素を nil 化しないとGCされない点に注意します。

動的に変化するゴルーチン数で再利用したい場合のために標準ライブラリには sync.Pool があります。これはキャッシュ(キーと値の対応)ではなく、交換可能な値の集合(プール)で、次のGCまでメモリを保持する一時キャッシュです。GetPut で出し入れしますが、誤用しやすい構造です。

buf := p.Get().([]byte)
buf = buf[:0]
defer p.Put(buf) // ❌ defer の引数は登録時に評価されるため append で別配列になると再利用されない

さらに sync.Pool はGCのたびに保持オブジェクトを完全に解放するため、GCを挟むマクロベンチマークではマイクロベンチと逆の結果(素朴な割り当てより遅い)になり得ます。著者の実験(labelerサービス)でも、バッファリングなしの実装が最良のレイテンシーと最小の最大ヒープを示し、プーリングは必ずしも勝たないと分かりました。マイクロベンチマークだけでは全体像を見誤るという教訓です。

sync.Pool が真に有効なのは、(1)大量のオブジェクト割り当てレイテンシーを減らしたい、(2)中身は不問でメモリブロックだけ必要、(3)数が変動する複数ゴルーチンから再利用、(4)1GCサイクル内の高速な計算間で再利用、という狭いケースに限られます。標準ライブラリにあるからと過剰に使われがちですが、まずシンプルな静的バッファを試し、ユースケースが一致すると確認できるまで sync.Pool は避けるのが著者の推奨です。

ここから先へ

最適化の旅はジェットコースターのようなもので、成功と徒労が交互に訪れます。重要なのは早期に失敗して非効率な版をリリースしないことです。著者は実用的な自動車整備士のたとえを挙げ、「機能的・効率的な目標の達成に必要なことだけをする」現実的でプロフェッショナルな姿勢を説きます。著者は3つのアドバイスを示します。第1に、常に明確でデータ駆動の効率目標を設定します(目標がなければあらゆる最適化が時期尚早になり得る)。第2に、Prometheusなどのオープンソースのオブザーバビリティツールへ小さく投資します。第3に、実際のプロジェクトで学んだプラクティスを試します。ディスク・ネットワーク・GPU/FPGA、構造体パディング、境界チェック削除など、さらに学べる領域も豊富にあります。

理解度チェック

Q1. 3つのRとは何で、どの順序で検討すべきですか。

A1. Reduce(割り当ての削減)・Reuse(メモリの再利用)・Recycle(リサイクル)の3つで、理想的にはこの順序で検討します。まず割り当て自体を減らし、減らせないなら明示的に再利用し、それでも難しければGCのリサイクル効率(ポインター削減やGCチューニング)を改善します。

Q2. `http.Response.Body` を `Close` するだけでは不十分なのはなぜですか。

A2. net/http の実装はボディがEOFまで読み切られることを前提にしており、読み切らずに閉じると基礎となるTCP接続をkeep-aliveで再利用できなくなります。その結果、余分なレイテンシーやファイル記述子の枯渇を招きます。ExhaustClose のように読み切ってから閉じる必要があります。

Q3. 大きな配列から作ったプールやサブスライスで「疑似メモリリーク」が起きるのはなぜですか。

A3. スライスは基礎となる連続メモリブロックを参照し続けます。GCはそのブロックがまだ参照されているか否かしか判断できず、たとえ全体の1%しか使われていなくても、残りを切り取って解放するほど賢くありません。そのため小さなサブスライスが巨大な配列全体を生かし続けてしまいます。必要分だけの新しいスライスへコピーし直し、古いブロックへの参照(headポインターを含む)を断つことで解放できます。

Q4. `sync.Pool` を安易に使うべきでないと著者が主張するのはなぜですか。

A4. sync.Pool のユースケースは狭く(割り当てレイテンシー削減、中身不問、変動するゴルーチン数、1GCサイクル内の高速計算)、しかも誤用しやすい構造です(例: defer p.Put(buf) の引数評価タイミング問題)。さらにGCのたびに全オブジェクトを解放する特性のため、マクロベンチマークでは素朴な割り当てより遅くなることもあります。まず作業と割り当てを減らし、シンプルな静的バッファで足りないか確認し、ユースケースが一致するまでは避けるのが推奨です。


付録A ナプキン計算用のレイテンシー

最適化を設計・評価するとき、基本的な操作のレイテンシーを概算で頭に入れておくと、計測する前に「どこが効きそうか」の当たりを付けられます。本書の付録は、Simon Eskildsenによる napkin-math を下敷きにした、丸めた平均レイテンシーのチートシートです。代表的な値を抜粋します(3GHz CPU・2021年時点のサーバー級ハードウェアが目安。あくまで桁感をつかむための概算)。

操作                              レイテンシー    スループット
CPU L1 キャッシュアクセス          ~1 ns          —
CPU L2 キャッシュアクセス          ~3 ns          —
シーケンシャルメモリ読書(64B)       ~5 ns          ~10 GB/s
CPU L3 キャッシュアクセス          ~20 ns         —
ランダムメモリ読書(64B)            ~50 ns         ~1 GB/s
システムコール                     ~500 ns        —
SSD シーケンシャル読込(8KB)        ~1 μs          ~4 GB/s
コンテキストスイッチ                ~10 μs         —
同一リージョン内ネットワーク         ~250 μs        —
ランダム HDD シーク                 ~10 ms         ~0.7 MB/s
ネットワーク 北米東 ↔ 北米西        ~60 ms         —
ネットワーク 欧州西 ↔ シンガポール   ~180 ms        —

ポイントは、メモリ階層を下るほど、そしてネットワークが遠いほど、レイテンシーは桁で跳ね上がるという点です。L1とRAMで約50倍、RAMとディスクやネットワークで更に数桁の差が開きます。だからこそ「割り当てを減らす」「キャッシュに収める」「往復を減らす」といった最適化が効いてきます。


全体を貫く3つの思想

  1. 効率は機能の一部であり、データで扱う。 速さを勘や流行で語らず、要件として定義し、計測(ベンチマークやプロファイル)の結果に基づいて判断する(推測するな、計測せよ)。
  2. 最適化はトレードオフ(ゼロサム)である。 何かを速くすれば、別の何か(可読性・開発時間・別リソース)を支払うことになる。だからこそ要件(RAER)を満たしたらやめる。時期尚早な最適化も、効率の軽視も、どちらも避けるべきだ。
  3. 機械の仕組みを理解し、再現可能な手順で改善する。 CPU・メモリ・Goランタイムの挙動を踏まえた「機械的共感」を土台に、計測から始めてボトルネックを特定し、最適化して再計測するサイクルを回す。推測ではなく事実で前に進む。

関連記事