statistical debuggingはprintf時代を終わらせるはずだった。コードを計測し、数千回の実行からトレースを収集し、相関分析を実行し、ツールがクラッシュを引き起こす可能性ですべての分岐とnullチェックをランク付けするのを見守る。2000年代半ばの論文では見事に機能した。実際には、それを試したほとんどのチームはノイズ、オーバーヘッド、誰も信じないダッシュボードを得ただけだった。

このアイデアは死んでいないが、延命治療を受けている。優雅な理論的基盤を持つ手法がなぜ標準的なツーリングにならなかったのか疑問に思うなら、答えは現実がそのほとんどの仮定を破っているからだ。

statistical debuggingが実際に何であるか

statistical debuggingはバグの局在化を分類問題として扱う。実行中にprogramを計測してpredicates、x > 0ptr == NULLreturn_code != 0のようなものを観察する。一部の実行はクラッシュしたりテストに失敗したりする(負のサンプル)。他は成功する(正のサンプル)。そして各predicateの存在が失敗とどれだけ強く相関するかによってスコアを付ける。

古典的なメトリックはincrease scoreだ。

increase(p) = P(failure | p is true) - P(failure | p is false)

increase scoreが1に近いpredicateは、プログラムが失敗するときはほぼ常に真であり、成功するときはほぼ常に偽だ。そのpredicateはバグの場所の有力な候補となる。

このアプローチは、Ben Liblit、Alex AikenらによるCooperative Bug Isolation(CBI)の画期的な研究から生まれた。CBIはGCCを計測し、数千人のユーザーからトレースを収集した。管理された研究では、bcexifrhythmboxのようなプログラムの実際のバグを隔離することができた。

計測が実際にどう機能するか

実装レベルでは、軽量なprobeを挿入している。probeはpredicateをチェックし、カウンターを増やす。以下に、Pythonでのpredicate samplerの簡略化された版を示す。

import atexit
import json
from collections import defaultdict

class PredicateSampler:
    def __init__(self):
        self.observations = defaultdict(lambda: {"true": 0, "false": 0})
        self.outcomes = defaultdict(lambda: {"true": 0, "false": 0})

    def observe(self, predicate_id: str, value: bool, failed: bool):
        bucket = "true" if value else "false"
        self.observations[predicate_id][bucket] += 1
        if failed:
            self.outcomes[predicate_id][bucket] += 1

    def compute_increase(self, predicate_id: str) -> float:
        obs = self.observations[predicate_id]
        out = self.outcomes[predicate_id]
        total_true = obs["true"] + obs["false"]
        if total_true == 0:
            return 0.0

        p_fail_given_true = out["true"] / obs["true"] if obs["true"] > 0 else 0
        p_fail_given_false = out["false"] / obs["false"] if obs["false"] > 0 else 0

        return p_fail_given_true - p_fail_given_false


sampler = PredicateSampler()

# Example probe inserted before a suspicious branch
user_id = 42
sampler.observe("user_id > 0", user_id > 0, failed=False)

実際のシステムでは、これらのprobeはコンパイル時またはバイトコード書き換えを通じて注入される。データは各実行後に中央のcollectorにアップロードされる。

サンプルサイズの壁:ほとんどのプロダクトは十分なクラッシュを生成しない

ここで崩れる最初の仮定だ。statistical debuggingは信号とノイズを区別するのに十分な失敗サンプルを必要とする。CBIは計測されたGCCビルドを実行する数千人のボランティアユーザーに依存した。そのモデルは巨大なユーザーベースを持つオープンソースコンパイラには機能する。顧客が50人のB2B SaaSプロダクトには機能しない。

数学は容赦しない。バグが実行の1%で発現し、相関スコアについて95%の信頼区間を求めるなら、ランキングが安定する前に数百の失敗が必要だ。多くの本番バグはさらに稀だ。特定の負荷条件下で千回に一回発生する競合状態は、数か月間statistical debuggingからは見えない。

現代のobservabilityツールも同じ希少性の問題に直面するが、異なる方法で解決する。distributed tracingはバグが発生したときに正確な失敗経路を捉える。千の例は必要ない。十分なコンテキストを持つ1つのtraceでよい。

計測のオーバーヘッド:観測者効果は実在する

2番目の問題はコストだ。すべてのpredicateチェックはCPUサイクルとメモリ圧力を追加する。初期のCBI実装は10%から100%のオーバーヘッドを報告した。研究には問題ない。ブラックフライデーのチェックアウトサービスには問題がある。

研究者たちは後に、sparse sampling strategiesを開発した。例えば、predicate評価の一部だけをサンプリングする、またはめったに見られない分岐に焦点を当てるadaptive schemesを使う。これらは役立つが、新しい問題を引き起こす:クラッシュ中の実行でサンプリングしていなかったため、失敗を説明する正確なpredicateを見逃す可能性がある。

本番チームはすでにp99レイテンシの1ミリ秒ごとに戦っている。週に2回発生するバグを検出するためにすべてを15%遅くするプロファイラーを追加することは、どのエンジニアリングマネージャーにとっても売り込みが難しい。

偽陽性が信号を埋め尽くす

十分なデータと低オーバーヘッドがあっても、ランキングは嘘をつく。predicateは因果関係がなくても失敗と高く相関することがある。古典的な例:エラーハンドリングパスでのみ実行されるログ出力文だ。logger.error()は失敗した実行の100%で真であり、成功した実行の0%で真だ。そのincrease scoreは完璧だ。しかしそれは完全に無罪でもある。

相関と因果関係を区別するには、統計モデルが持たないドメイン知識が必要だ。結果として、トップ10リストの3つが無害なエラーログ、2つが実際のバグの後に発動する防御的チェック、1つがサードパーティライブラリからのred herringになる。実際のバグは7位にランク付けされている。

これが実際に試したチーム内での導入を殺した部分だ。開発者は統計レポートを信頼できなかったため、開くのをやめた。信頼できないツールはツールがないより悪い。偽の手がかりを調査する時間を無駄にし、本物の手がかりを無視し始める。

分散システムがシングルプロセスモデルを破壊した

statistical debuggingは、シングルプログラムを計測し、シングルトレースを収集し、そのプログラム内のpredicatesに失敗を帰属できると仮定する。現代のソフトウェアはそうは機能しない。

失敗したAPIリクエストは、ロードバランサー、3つのマイクロサービス、2つのキャッシュ、メッセージキュー、データベースに触れる可能性がある。バグはサービスAのタイムアウト、サービスBの欠落したリトライ、サービスCの古いキャッシュエントリかもしれない。statistical debuggingにはservice boundariesを越えて失敗を帰属させるメカニズムがない。

predicate correlationは、失敗が局所的で決定論的なときに機能する。独立してデプロイされたサービス間の相互作用効果から失敗が生じるとき、それは崩壊する。この研究はモノリシックなCプログラムのためのものであり、Kubernetesクラスターのためのものではない。

代わりに機能するもの:ターゲットを絞ったobservability

statistical debuggingは何を探すべきか知らずにバグを見つけようとした。聞こえるよりも難しい問題だ。ほとんどのチームは、特定の高価値な信号に焦点を当てたツールからより良い結果を得る。

correlation IDsを持つstructured loggingは、1つのリクエストが触れるすべてのサービスを追跡できるようにする。千の失敗は必要ない。1つの完全なtraceでよい。

stack trace groupingを伴うエラー追跡は、クラッシュがどこに集まるかを教えてくれる。Sentryのgrouping algorithmsは本質的に簡略化された形の統計的クラスタリングを行うが、任意のpredicatesではなくstack tracesを操作する。モデルがコード構造を理解するため、信号はより強い。

sanitizersやfuzzersのようなdynamic analysisツールは、統計的有意性を待つことなく決定論的にバグを見つける。AddressSanitizerはuse-after-freeが発生した正確な瞬間に捉える。パターンを見るのに千回の実行は必要ない。

良いアイデアを拝借する方法

statistical debuggingはスタンドアローンのプラットフォームとしては失敗したが、その一部の技法はまだ拝借する価値がある。

A/Bテストやcanary deploymentsを実行する場合、相関ロジックを運用メトリックに適用できる。canaryとコントロールグループの間で、cache_hit == falseretry_count > 0のようなpredicatesを比較する。数千のサンプルと管理された環境を持つ自然実験ができる。

軽量なpredicate samplingを本番サービスではなくデバッグ支援として使うこともできる。CI上で統合テストスイートに対して実行する。特定の分岐やnull checkが、すべての失敗テストで真であり、すべてのパスしたテストで偽なら、ブレークポイントをどこに設定すべきかの強力な手がかりとなる。

JUnit XML出力に対して実行して怪しいpredicatesを見つける最小限のスクリプトを以下に示す。

import xml.etree.ElementTree as ET
from collections import defaultdict


def find_suspicious_predicates(xml_path: str, predicate_log_path: str):
    tree = ET.parse(xml_path)
    failures = {
        tc.get("name")
        for tc in tree.iter("testcase")
        if tc.find("failure") is not None
    }

    predicate_counts = defaultdict(lambda: {"pass": 0, "fail": 0})

    with open(predicate_log_path) as f:
        for line in f:
            test_name, pred, value = line.strip().split(",")
            bucket = "fail" if test_name in failures else "pass"
            predicate_counts[pred][bucket] += 1

    for pred, counts in predicate_counts.items():
        total = counts["pass"] + counts["fail"]
        if total < 10:
            continue
        fail_rate = counts["fail"] / total
        if fail_rate > 0.8 and counts["fail"] >= 3:
            print(f"Suspect: {pred} (fail rate: {fail_rate:.2f})")


# Run this after a test suite that logs predicate evaluations
find_suspicious_predicates("test-results.xml", "predicates.log")

これはCBIではない。現代のワークフローに実際に適合する、同じアイデアの狭く管理された版だ。

statistical debuggingを本番で失敗させる仮定

statistical debuggingは、研究者が仮定した形でほとんどのチームが持たない問題に対する見事な解決策だった。巨大な規模、低オーバーヘッドの計測、モノリシックなcodebase、統計的有意性に達するほど頻繁に現れるバグが必要だ。これらのいずれかを取り除くと、数学は機能しなくなる。

研究から恩恵を受けたチームは、その核心的な洞察であるcorrelation analysisを、仮定が成り立つ文脈に適応させたチームだった。Canary metrics。Fuzzing feedback。テストスイート分析。残りの私たちは、tracing、structured logging、決定論的なdynamic analysisからより良い結果を得た。

オリジナルの研究に興味があるなら、Ben LiblitのCooperative Bug Isolationに関するPhD論文は今でも読む価値がある。ただし、来四半期に主要なデバッグ戦略としてデプロイできると期待しないでほしい。