決定性と再現性(ビルドと実行の再現可能性)
同じ入力なのに結果が変わる、ビルドのたびに成果物が違う。その非決定性の源を時刻・乱数・並行・浮動小数点へ正確に切り分け、固定して、テストとデバッグを安定させられるようになります。
- 1.再現可能ビルドは同一ソースから常にビット単位で同一の成果物を作る性質。タイムスタンプ・ファイル順・絶対パス・並列化の埋め込みを正規化することで達成する。
- 2.実行時の非決定性の主な源は時刻・乱数・スレッドスケジューリング・ハッシュ反復順・浮動小数点の総和順序であり、それぞれ注入と固定で制御する。
- 3.浮動小数点加算は結合則を満たさないため、並列リダクションは分割の仕方で結果が変わる。ビット単位の再現には演算順序まで固定する必要がある。
決定性と再現性は別物
まず2つの語を厳密に分けます。**決定性(determinism)**は「同じ入力に対して常に同じ出力を返す」という、関数・プログラムの性質です。**再現性(reproducibility)**は「ある実行や成果物を、後から別の時刻・別のマシンで同じものとして再生できる」という、運用上の達成です。決定的なプログラムは再現しやすいが、非決定的なプログラムでも非決定性の源を記録・固定すれば再現はできます。
非決定性の源は有限個に整理できます。実行時では時刻、乱数、並行スケジューリング、ハッシュ集合の反復順、メモリアドレス(ASLR)、浮動小数点の演算順序、未初期化メモリやデータ競合といった未定義動作。ビルド時ではタイムスタンプ、ファイル列挙順、絶対パス、並列化の非決定、埋め込まれる環境変数です。源を1つずつ潰すのが本質で、魔法はありません。
再現性が崩れると、テストはたまに落ちるフレーキーテストになり信頼を失います。デバッグでは「手元では再現しない」が最大の障壁になります。セキュリティ面では、配布バイナリが本当にそのソースから来たかを第三者が検証するソース対バイナリの対応付け(サプライチェーン保証)が成立しません。再現性は品質・診断・信頼の土台です。
再現可能ビルド(reproducible builds)
再現可能ビルドとは、同一のソースと同一の宣言された環境から、常にビット単位で同一の成果物を生成する性質です。ハッシュが一致すれば、誰がいつどこでビルドしても同じ物だと数学的に確認できます。崩す犯人は決まっています。
| 非決定の源 | 成果物に混入する形 | 正規化の手法 |
|---|---|---|
| ビルド時刻 | タイムスタンプ埋め込み・アーカイブの mtime | SOURCE_DATE_EPOCH で固定する |
| ファイル列挙順 | リンク順・アーカイブ内の並びがFS依存 | 名前で明示ソートしてから処理する |
| 絶対パス | デバッグ情報やRPATHに作業ディレクトリが残る | パスを正規化・相対化(debug prefix map) |
| 並列ビルド | ジョブ完了順で出力順序が変わる | 出力を決定的順序に再整列する |
| 環境変数・ロケール | ユーザー名・LANG・タイムゾーンが漏れる | ビルド環境を固定・サニタイズする |
実務の標準は SOURCE_DATE_EPOCH 環境変数です。これに Unix 時刻を入れておくと、対応ツールチェーンは __DATE__ 相当やアーカイブの mtime をその固定値に差し替えます。ファイル順は、glob の結果がファイルシステム依存で並ぶため、処理前に明示的にソートします。デバッグ情報の絶対パスは、コンパイラの prefix-map 機能(作業パスを定数文字列へ写像する)で消します。これらをすべて満たして初めて、2回のビルドのハッシュが一致します。
機能的に同一でもバイト列が違えばハッシュは一致せず、再現可能ビルドとしては不合格です。逆に言えば、合格判定は明快で、diff ではなく成果物のハッシュ比較1つで足ります。検証は「2つの独立した環境でビルドしてハッシュが一致するか」という二者照合で行います。
時刻・乱数・環境という外部入力
実行時非決定性の第一群は、プログラムが暗黙に読み込む隠れた入力です。現在時刻、乱数、環境変数、プロセスID、ホスト名はどれも実行ごとに変わります。これらを関数内部で直接呼ぶと、その関数は決定的でなくなり、テストもできません。
解決は依存性注入です。時刻なら「現在時刻を返すクロック」を引数として渡し、テストでは固定時刻を返す偽物に差し替えます。乱数なら、グローバルな乱数源ではなく明示的にシードした乱数生成器を持ち回します。擬似乱数は本来決定的な系列で、同じシードからは同じ列が出る(仕組みは擬似乱数生成器の内部を参照)ので、シードを記録すれば失敗を完全に再現できます。
# 非決定的: 内部で隠れた入力を読む
def make_token():
return hash(time.now() + random()) # 毎回違う、テスト不能
# 決定的: 隠れた入力を引数へ持ち上げる(注入)
def make_token(clock, rng):
return hash(clock.now() + rng.next()) # clock と rng を固定すれば再現可能
この「隠れた入力を引数へ持ち上げる」設計は、純粋関数化と同義です。副作用と外部依存を境界に押し出すほど、中核ロジックは決定的になり、テストの安定性が上がります。
並行・スケジューリングによる非決定性
第二群は並行実行です。スレッドの実行順序はOSスケジューラが決め、実行ごとに変わります。共有状態に対する読み書きの順序が変われば結果も変わり、データ競合があれば結果は未定義になります。ここで「同じ並行プログラムが毎回同じ結果」を保証するのは一般に不可能で、できるのは正しさ(線形化可能性など)を保つことと、テスト時に順序を制御することの2点です。
複数スレッドが同期なしに同じメモリを読み書きし、少なくとも一方が書き込みならデータ競合です。これは単に結果がばらつくだけでなく、多くの言語仕様で未定義動作であり、コンパイラの最適化と相まって直感に反する結果を生みます。再現性の前にまず正しさの問題で、同期で除去すべき欠陥です。順序の前提はメモリモデルとhappens-beforeが規定します。
並行バグの再現は、スケジュールを操作して初めて可能になります。決定的スケジューラを差し込んで全インターリーブを系統的に探索する、競合をサニタイザで動的検出する、操作を記録して再生する(record/replay)といった手法を使います(具体的な技法は並行プログラムのテストで扱います)。要点は、並行性は「シードを固定すれば再現」とはいかず、スケジュール自体を制御対象に含める必要があることです。
暗黙の順序:ハッシュ反復とアドレス
見落としやすい非決定性が、コレクションの反復順です。多くのハッシュ集合・ハッシュマップは内部バケットの並びで反復し、その順序は実装・要素・場合によっては起動ごとのハッシュ種に依存します。for k in some_set の結果順をあてにしたコードは、環境が変われば壊れます。さらに一部言語は、ハッシュ衝突を突く攻撃を防ぐため起動ごとにハッシュ種をランダム化するので、同じ集合でも反復順が毎回変わります。
# 反復順に依存した出力は非決定的になりうる
for key in hashset: # 順序は実装・ハッシュ種依存、保証されない
emit(key)
# 決定化: 反復前に明示ソート、または順序保持コンテナを使う
for key in sorted(hashset):
emit(key)
同様に、ポインタやオブジェクトの既定ハッシュがメモリアドレス由来だと、ASLR(アドレス空間配置のランダム化)で実行ごとに変わります。アドレスを順序や出力に混ぜないこと、出力直前に安定なキーで整列することが鉄則です。
浮動小数点の非結合性
最も厄介なのが浮動小数点です。実数の加算は結合則 (a+b)+c = a+(b+c) を満たしますが、浮動小数点加算は丸めのため結合則を満たしません。各演算後に最近接表現へ丸めるので、足す順序が変われば丸めの入り方が変わり、ビット単位で異なる結果になります(丸めの原理は浮動小数点数の内部)。
# 桁の大きく違う値で順序により結果が変わる例
(1e16 + -1e16) + 1.0 → 1.0 # 先に 1e16 同士が相殺し 0.0、そこへ 1.0 が残る
1e16 + (-1e16 + 1.0) → 0.0 # -1e16+1.0 が 1e16 に丸められ 1.0 が消えてから相殺
この性質が再現性を直撃するのは並列リダクションです。総和を複数スレッドで部分和に分割して最後に合算すると、合算の結合のされ方がスレッド数や分割幅で変わり、実行構成ごとに結果のビットが変わります。GPUや自動ベクトル化、reduceの並列実装は速度のために順序を自由に組み替えるため、これが日常的に起きます。
ビット単位の再現が必要なら、リダクションの結合順序を固定する(決定的リダクションモード)か、逐次総和に落とすか、Kahan の補償総和のように順序依存を小さくする手法を使います。多くの数値ライブラリは「決定的だが遅い」経路と「速いが順序非決定」経路を切り替える設定を持ちます。一方、許容誤差内で良いなら順序を固定する必要はなく、ビット一致を要件にしないのが現実的な選択です。
制御の実務:注入・固定・記録
非決定性を制御する手順は、源ごとに同じ骨格を持ちます。(1) 隠れた入力を境界へ追い出し(注入可能にする)、(2) テスト・本番で値を固定(クロック・シード・スケジュール)、(3) 再現に必要な情報を記録(シードやスケジュールをログ・成果物に残す)。失敗時はその記録から同じ実行を再生します。
| 非決定の源 | 制御手段 | 再現の鍵 |
|---|---|---|
| 時刻 | クロックを注入し固定 | 固定タイムスタンプ |
| 乱数 | 明示シードの生成器を持ち回す | 記録したシード |
| 並行スケジュール | 決定的スケジューラ・record/replay | スケジュールの記録 |
| 反復順 | 出力前に安定キーでソート | 順序を実装非依存にする |
| 浮動小数点 | リダクション順序を固定 | 決定的演算経路 |
| ビルド | SOURCE_DATE_EPOCH・パス正規化・ソート | 成果物ハッシュ一致 |
依存ライブラリのバージョンが揺れれば実装が変わり、反復順や演算順も変わりえます。ロックファイルで依存を固定する話は依存関係管理に属し、再現性の前提条件の1つです。
決定性(同入力→同出力)と再現性(後から再生可能)を区別する。非決定の源は時刻・乱数・並行・反復順・アドレス・浮動小数点順序・未定義動作と、ビルド側の時刻・ファイル順・パス。注入で固定し、シードやスケジュールを記録して再生する。浮動小数点加算は非結合なので並列リダクションはビットが揺れる。再現可能ビルドの合格判定は成果物のハッシュ一致1点であることを押さえる。
まとめ
決定性は性質、再現性は達成であり、両者は分けて考えます。実行時の非決定性は時刻・乱数・並行スケジューリング・ハッシュ反復順・アドレス・浮動小数点の演算順序・未定義動作という有限の源に分解でき、それぞれを境界への注入と値の固定で制御します。並行性だけはシード固定では足りず、スケジュール自体を制御・記録・再生する必要があります。浮動小数点加算が非結合であるため、並列リダクションは構成ごとにビットが変わり、ビット再現には演算順序の固定が要ります。ビルド側では時刻・ファイル順・絶対パスを正規化し、成果物のハッシュ一致という明快な基準で再現可能ビルドを検証します。源を1つずつ潰すこの規律が、フレーキーテストを消し、デバッグを「手元で再現する」状態へ引き戻し、配布物の信頼を支えます。
プログラミング Article
決定性と再現性(ビルドと実行の再現可能性)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
決定性
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 6
導入後に効く点
実行時の非決定性の主な源は時刻・乱数・スレッドスケジューリング・ハッシュ反復順・浮動小数点の総和順序であり、それぞれ注入と固定で制御する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 6
判断チェックリスト
- 自社の用途が「決定性 / 再現性」に近いか確認する。
- 強みである「再現可能ビルドは同一ソースから常にビット単位で同一の成果物を作る性質。タイムスタンプ・ファイル順・絶対パス・並列化の埋め込みを正規化することで達成する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。