Pythonの動的計画法(DP)で午前2時の本番環境クラッシュを解決した話

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

午前2時14分、PagerDutyの呼び出し

午前2時14分、私のスマートフォンが鳴り響きました。ロジスティクスエンジン(複数倉庫にまたがる注文の最小配送コストを計算するサービス)が単に遅いだけでなく、完全にハング(停止)していました。prod-worker-04ノードのCPUメトリクスは、100%に張り付いたままの直線を描いていました。一方、リクエストキューは、通常の20件前後から、わずか3分足らずで8,500件以上に急増していました。

私はサーバーにSSHでログインし、ログを確認しました。犯人はcalculate_min_costという名前のレガシーな関数でした。通常よりわずか15%ほど多いデータセットを処理しようとして、悲鳴を上げていたのです。これはデータベースのデッドロックでもネットワークの不具合でもありませんでした。ナイーブな再帰アルゴリズムが自重で崩壊した、典型的な事例でした。その瞬間、動的計画法(DP)は教科書の中の理論ではなく、生き残るための必須スキルとなったのです。

根本原因:総当たり(ブルートフォース)の罠

この問題は「お釣り支払い問題(Change-Making Problem)」の変種でした。特定の容積を満たすために必要な最小のコンテナ数を求めるというものです。以前の開発者が書いた、読みやすくクリーンな再帰関数は、次のようなものでした。

def calculate_min_containers(volumes, target):
    if target == 0:
        return 0
    if target < 0:
        return float('inf')

    res = float('inf')
    for v in volumes:
        sub_res = calculate_min_containers(volumes, target - v)
        if sub_res != float('inf'):
            res = min(res, sub_res + 1)
            
    return res

ロジックは優雅ですが、計算量は過酷です。例えば、コンテナのサイズが [1, 5, 10] で、目標容積(target)が 100 の場合、この関数は凄まじい勢いで分岐していきます。

このコードは、容積50のコストを一度だけ計算するのではなく、異なる分岐をたどるたびに、同じ値を何千回も再計算してしまいます。時間計算量は、コンテナの種類をV、目標値をTとすると、おおよそ O(V^T) になります。本番環境では、リストに新しいコンテナサイズが追加されるたびに、実行時間が指数関数的に増大することを意味します。

解決策1:メモ化(トップダウン型の応急処置)

午前2時30分の時点で、アーキテクチャを根本から書き換える余裕はありませんでした。再帰を最適化する最速の方法はメモ化(Memoization)です。すべての計算結果を「メモ」(辞書や配列)に保存し、同じ問題を二度解かないようにします。

Pythonでは、標準ライブラリの functools.lru_cache を使うことで、これを信じられないほど簡単に実現できます。以下が、私が本番環境に投入したパッチです。

from functools import lru_cache

def solve_with_memo(volumes, target):
    @lru_cache(None)  # リクエストの間、無制限にキャッシュする
    def helper(rem):
        if rem == 0: return 0
        if rem < 0: return float('inf')

        res = float('inf')
        for v in volumes:
            sub_problem = helper(rem - v)
            if sub_problem != float('inf'):
                res = min(res, sub_problem + 1)
        return res

    result = helper(target)
    return result if result != float('inf') else -1

効果はてきめんでした。残りの容積が50の場合のコストを再計算する代わりに、関数はキャッシュから値を即座に取得するようになりました。これにより、計算量は指数関数から、管理可能な O(V * T) へと変化しました。デプロイから数秒以内に、ワーカーノードのCPU使用率は100%から4%へと急降下しました。

解決策2:タブ化(ボトムアップ型のプロフェッショナルなアプローチ)

メモ化でその場はしのげましたが、長期的な解決策としては完璧ではありませんでした。Pythonにはデフォルトで1,000という再帰回数の上限があります。もし顧客が目標容積5,000の注文を出した場合、メモ化バージョンでも RecursionError を起こしてクラッシュしてしまいます。より堅牢なシステムを構築するために、タブ化(Tabulation)が必要でした。

タブ化は「ボトムアップ」のアプローチです。大きな目標を分割するのではなく、最小のサブ問題(目標0)から始めて、最終目標に到達するまでテーブルを埋めていきます。

def solve_with_tabulation(volumes, target):
    # 起こりうる結果よりも大きい値でテーブルを初期化
    dp = [float('inf')] * (target + 1)
    dp[0] = 0

    # 1からtargetまで、反復処理でテーブルを埋めていく
    for i in range(1, target + 1):
        for v in volumes:
            if i - v >= 0:
                dp[i] = min(dp[i], dp[i - v] + 1)

    return dp[target] if dp[target] != float('inf') else -1

これは、高パフォーマンスなPythonアプリケーションにおける標準的な手法です。何千回もの関数呼び出しのオーバーヘッドがなくなり、再帰制限も完全に回避できます。反復的で予測可能であり、ストレステスト中のプロファイリングもはるかに容易です。

2つの戦略の比較

特徴 メモ化(トップダウン) タブ化(ボトムアップ)
実装方法 再帰 + キャッシュ 反復処理 + テーブル
ロジック 直感的かつ「遅延評価的」 ループの事前設計が必要
パフォーマンス スタックフレームにより低速 高速(単純な反復処理)
リスク 再帰上限に達する可能性あり テーブル用に固定メモリを使用

最適なアプローチ:どちらをいつ使うべきか?

障害報告書を提出し、コーヒーで一息ついた後、私はチームと今後の基準について話し合いました。現在、私たちはDP問題に対して以下のシンプルな経験則に従っています。

  • メモ化を選択する場合:「状態空間」が疎(まばら)であるとき。10,000通りの可能性のうち、特定の50個の値だけを計算すればよいのであれば、フルテーブルを作る時間は無駄です。必要なものだけを計算しましょう。
  • タブ化を選択する場合:答えを導き出すために、ほぼすべてのサブ問題を解く必要があるとき。Pythonでは、コールスタックに何千ものフレームを追加しないため、メモリ効率がより高くなります。

午前3時に学んだこと

冗長な計算は、スケーラビリティの静かなる刺客です。ロジスティクスサービスをリファクタリングした際、再帰ロジックをタブ化メソッドに置き換えました。それ以来、注文ボリュームが3倍になったホリデーシーズンのピーク時でさえ、そのモジュールでCPUスパイクが発生することはありませんでした。

もしあなたが、最適値を返す再帰関数を書いているなら、一度立ち止まって計算量を確認してください。「同じサブ問題を二度解いていないか?」と自問してみてください。もし答えが「Yes」なら、それは動的計画法で解決すべき問題です。状態を整理し、戦略を選びましょう。そうすれば、午前2時にページャーが鳴ることなく、未来の自分から感謝されるはずです。

「動く」コードから「スケールする」コードへの脱皮は、往々にしてこうした小さなアルゴリズムの転換にかかっています。Pythonは強力なツールを提供してくれますが、テーブルがどのように構築されるかを理解することこそが、単なるコーダーとシステムエンジニアを分ける境界線なのです。

Share: