同じ関数の5つの実装がある。3つは同じ結果を返す。1つは少し違う。1つは例外を投げる。どれが正しい?
ほとんどのチームは多数決をデフォルトにする。出力が同一でエラーが明らかな場合はうまくいく。しかし、実装が微妙に異なる場合や、すべてのバリアントが異なる答えを返す場合、それは崩壊する。N-version programmingは問題の前半、つまり複数のバージョンを実行することしか解決しない。より難しい後半は、どの出力を信頼するかを決定することだ。
N-version programmingとは何か
N-version programmingは、同じ仕様の複数の独立した実装を実行し、その結果を組み合わせるfault-tolerance技法である。古典的な形態はtriple modular redundancyである。3つのシステムが投票し、多数派が勝つ。これはsafety-criticalなハードウェア、例えばavionicsに遡る。そこでは1つのバグが人命を奪う可能性があった。
同じ考え方がAI engineeringに再浮上している。LLMにコード生成を依頼する際、5つの異なるcompletionをサンプリングすることがある。レガシーのパーサーとリライト版を持っている場合、両方を並列に実行して比較することもある。ハードウェアのルーツが見える。私たちはアーキテクチャを借用したが、それを機能させるselection logicを常に借用したわけではない。
ハードウェアでは出力はビットだ。ソフトウェアでは、出力はstructured data、strings、rankings、side effectsである。Majority voteは、equalityの計算が安価で、見つかりやすいことを前提とする。ほとんどのソフトウェア問題では、どちらの前提も成り立たない。
投票の罠:なぜ「最も一般的」が「最も正しい」ではないのか
昨年、search rankingのリファクタリングでこれに遭遇した。私たちは5つのranking algorithmを持っていた。レガシーシステム、2つのmodel-basedアプローチ、そして2つのheuristic baselineだ。サンプルクエリでは、そのうち4つが異なるtop-10リストを返した。どれも完全には一致しなかった。投票できる多数派は存在しなかった。
これは通常のケースであり、エッジケースではない。異なる実装は異なるものを最適化する。1つはrecencyを重視するかもしれない。もう1つはpopularityを重視するかもしれない。3つ目は火曜日だけ現れるバグを持っているかもしれない。exact string matchで投票すれば、最も平凡な実装、つまり最も平凡で驚きのない出力を返すものに縛られてしまう。
Exact-match votingは静かに失敗することもある。2つの実装が同じ間違った答えを返すことがある。それは悪い仮定やコピーされたバグを共有しているためだ。Correlated failuresはN-version redundancyを完全に破壊する。5つのパーサーのうち3つが同じbiased datasetで訓練されていたら、それらの合意は何の意味も持たない。
Differential testingが実際にどう機能するか
より良いアプローチは、structured comparisonを伴うdifferential testingだ。「どの出力が同一か」を問うのではなく、「私が定義し測定できる基準に照らしてどの出力が最善か」を問う。
まず、自分のドメインに対するequivalence functionを定義することから始める。search rankingsでは、normalized discounted cumulative gain(NDCG)を使って比較できる。JSON parsersでは、結果のobject graphsを比較できる。string outputsでは、semantic similarityやdownstream task scoreを使える。重要なのは、equalityがバイナリスイッチではなくスペクトラムになることだ。
次に、各出力をscalarにマッピングするscoring functionを定義する。ここでdomain knowledgeが入ってくる。code-generationタスクのscoring functionは、compilation success、test pass rate、runtime performance、output lengthを組み合わせるかもしれない。正確な重みよりも、それらが明示的であるという事実の方が重要だ。
スコアを手に入れれば、selectionは単純な最適化になる。最も高いスコアの出力を選ぶ。タイブレークの場合は、例えば歴史的エラー率が最も低い実装を優先するようなfallback heuristicを使う。
以下は、適応できるselectorだ。N個のバリアントを実行し、各出力をスコアリングし、最良のものと診断用のメタデータを返す:
from dataclasses import dataclass
from typing import Callable, List, Optional, TypeVar
import statistics
T = TypeVar("T")
@dataclass
class VariantResult:
variant_id: str
output: Optional[T]
error: Optional[Exception]
score: float = 0.0
class NVersionSelector:
def __init__(
self,
score_fn: Callable[[T], float],
equivalence_fn: Optional[Callable[[T, T], float]] = None,
tie_breaker: Optional[Callable[[List[VariantResult]], VariantResult]] = None,
):
self.score_fn = score_fn
self.equivalence_fn = equivalence_fn or (lambda a, b: 1.0 if a == b else 0.0)
self.tie_breaker = tie_breaker
def select(self, variants: List[Callable[..., T]], *args, **kwargs) -> VariantResult:
results: List[VariantResult] = []
for variant in variants:
try:
output = variant(*args, **kwargs)
results.append(VariantResult(
variant_id=variant.__name__,
output=output,
error=None,
))
except Exception as exc:
results.append(VariantResult(
variant_id=variant.__name__,
output=None,
error=exc,
))
# Score only successful runs
for r in results:
if r.output is not None:
r.score = self.score_fn(r.output)
# Filter to successful, scored results
valid = [r for r in results if r.error is None and r.output is not None]
if not valid:
raise RuntimeError("All variants failed")
best_score = max(r.score for r in valid)
candidates = [r for r in valid if r.score == best_score]
if len(candidates) == 1:
return candidates[0]
if self.tie_breaker:
return self.tie_breaker(candidates)
return self._default_tie_break(candidates, valid)
def _default_tie_break(
self, candidates: List[VariantResult], all_valid: List[VariantResult]
) -> VariantResult:
# Prefer the candidate whose output is most similar to other outputs
def consensus_score(candidate: VariantResult) -> float:
similarities = [
self.equivalence_fn(candidate.output, other.output)
for other in all_valid
if other.variant_id != candidate.variant_id
]
return statistics.mean(similarities) if similarities else 0.0
return max(candidates, key=consensus_score)
score_fnは、あなたの問題にとって「最善」が何を意味するかをエンコードする場所だ。equivalence_fnは、出力が他の出力群とどれだけ類似しているかを測定することで、スコアがタイになった場合を処理する。これは、2つの出力が完全に一致しない場合にも機能するものへとmajority voteを一般化する。
Multi-criteria evaluationによる実装のスコアリング
単一のscalar scoreはシンプルだが、あまりにも多くの次元を1つの数値に押し込めると危険だ。私はtwo-tierアプローチを好む。
Tier 1はhard filterだ。compilationに失敗したり、invariantを違反したり、クラッシュしたりする出力は、即座に破棄される。部分点はなしだ。
Tier 2は生き残りに対するsoft scoreだ。これはweighted sumにできるが、重みは明示的で調整可能に保つ。selectorが一貫して速いが間違った答えを優先していると気づいた場合、アーキテクチャを書き換えずにcorrectnessの重みを上げられる。
def score_generated_code(output: str) -> float:
if not compiles(output):
return -1.0 # Hard filter
tests_passed = run_test_suite(output)
execution_time = benchmark(output)
line_count = len(output.splitlines())
# Weighted sum on survivors only
return (
0.6 * tests_passed
+ 0.3 * (1.0 / (1.0 + execution_time))
+ 0.1 * (1.0 / (1.0 + line_count))
)
上記の重みは任意のものだ。held-out validation setに対してチューニングし、要件が変わったら更新する。それらをclassの内部に隠して見えなくならないようにするな。
崩壊する場合:correlated failuresとruntime cost
N-version selectionはタダではない。5つのバリアントを実行することは、5倍のcompute、5倍のlatency、5倍のメンテナンス負担を意味する。実装がLLM callsの場合、そのコストはドルと秒で測定される。microservicesの場合は、queue depthとthread countで測定される。
より大きなリスクはcorrelated failureだ。5つの実装は、bug distributionからの5つの独立したサンプリングを与えてくれるわけではない。それらは言語、ライブラリ、訓練データ、人間の作成者を共有している。5つすべてが日付を解析するために同じregular expressionを使っていれば、同じmalformed inputですべて壊れる。Diversityを強制することは難しい。shared dependenciesとcopied logicを積極的に監査する必要がある。
selector自体が間違っている場合どうするかという問題もある。scoring functionにblind spotがあれば、一貫して悪い出力を選び、気づくことはない。selection distributionを監視する。1つのバリアントが決して選ばれない場合、それは無用か、score functionにbiasがあるかのどちらかだ。どちらにせよ、調査すべきだ。
まとめ:今日からリリースできるselector
始めるのにdistributed systemは必要ない。既存の関数をselectorでラップし、1つの代替実装を追加し、自分にとって重要な単一のscoring criterionを定義する。並列に実行する。出力を比較する。スコアをログに記録する。
1週間のログ記録後、バリアントが意見を異にしたケースをレビューする。その不一致は贈り物だ。仕様が曖昧な場所、score functionが間違っている場所、他の実装にはないバグが1つの実装にある場所を教えてくれる。
徐々にスケールアップする。良いselectorを持つ2つのバリアントは、majority voteを持つ5つのバリアントより優れている。
FAQ:correlated failures、stateful systems、runtime overhead
5つの実装すべてが異なる結果を返したらどうするか?
これは非自明な問題に対する期待されるケースだ。exact matchesを探すのではなく、scoring functionを使って出力をランク付けする。良いscore functionを定義できない場合、問題はselectorではない。あなたのドメインにとって「正しい」とは何かをまだ知らないということだ。
バリアント間のcorrelated failuresをどう防ぐか?
shared dependencies、copied code、common training dataを監査する。少なくとも1つのバリアントに異なる言語やframeworkを使わせる。目標はuncorrelated error distributionsだが、聞こえるよりも難しい。
N-version programmingはstateful systemsで機能するか?
side effectsを持つシステムではうまく機能しない。関数がデータベースに書き込んだり、クレジットカードに請求したりする場合、5回実行することは破壊的だ。N-version selectionはpure functions、read-only queries、またはselection後にside effectを実行できる操作で最も機能する。
5つのバリアントを実行することでどれだけのoverheadが増えるか?
Latencyは、並列に実行しない限り最も遅いバリアントで制限される。CPUとメモリは線形にスケールする。2つのバリアントから始めて、5つにコミットする前に測定する。運用コストはselection logicよりもしばしば重要だ。