物体検出システムの後処理ボトルネックを解消した話

序文

こんにちは。データ部ML Product Devチームに所属している谷澤です。 ML Product Devチームは「機械学習を活用した競合優位性のあるプロダクト開発」をミッションとし、プロダクト開発チームと協力して日々開発を行っています。 今回のブログでは、とある物体検出システムの後処理ボトルネック解消に取り組んだ話を共有します。

TL;DR

  • 対象は物体検出機能
  • 物体検出部分ではなく後処理で時間がかかっていた
  • 5個の施策を打ち、特定の状況下で処理時間を97.8%削減した。
改善策 処理時間 [s] 時間削減率 [%]
ベースライン 135.68
cached_property 導入 45.13 66.7 ↓
nested function 廃止 37.48 72.4 ↓
numba 導入 4.58 96.6 ↓
numba+型アノテーション 3.69 97.3 ↓
Rust 化 2.98 97.8 ↓

計測環境: Intel i7-1065G7 / 32 GB RAM / Python 3.12

背景

プロジェクトの経緯

この機能は一言で言えば物体検出を行うものです。 現在は Proof Of Concept フェーズにあり一部のお客様にのみ提供しています。 開発時は想定された入力データに対して遅くとも数秒でレスポンスを返せていたということもあり、パフォーマンスチューニングは特に行っていませんでした。

レイテンシ問題の発覚

お客様へ機能を提供してから数ヶ月が経った頃、監視システムからレイテンシが増大している旨Warningが上がりました。 直近のレイテンシを調査したところ、ほとんどのリクエストに対しては数秒程度でレスポンスを返せているものの、残りの数%のデータに対してはレスポンス完了まで1分近くかかっている状態でした。 リクエストに添付されているデータを調査したところ、それらには検出対象となる物体が想定よりも多く含まれていました。

ボトルネック調査

有名な「推測するな、計測せよ」という言葉に倣い、まずはボトルネックの特定から始めました。 今回テーマとなったML機能は、下記のような流れで処理を行っています。 1. 入力データを画像化 2. ある物体の領域を検出する 3. 検出結果に対して後処理を実行 4. 結果を出力

問題となっているソフトウェアはPythonで書かれているため、Pythonプロファイラを用いてプロファイルを行いました。結果の可視化には snakeviz を用いました。

計測条件

計算機

  • CPU: Intel(R) Core(TM) i7-1065G7
  • Memory: 32GB
  • OS: Ubuntu 22.04 LTS
  • Python: 3.12

データ

入力データには人工的に作成したデータを用いました。検出対象の物体が含まれている量を変化させ、「small」「medium」「large」の3つのバージョンを準備しました。

サイズ 要素数
small 1,000
medium 10,000
large 50,000

結果

  • small / medium / large
    small
    medium
    large

プロファイリング結果をみると、上から5番目の紫の部分の関数がボトルネックとなっていることがわかりました。 この関数は、検出された物体間の空間的な関係性を計算する処理で、具体的には:

  • 各物体ペアの距離計算
  • 重なり判定
  • 近接物体のグルーピング

などを行っています。 この部分を改善していくと良さそうです。

パフォーマンス改善の積み重ね

対策1: cached_propertyを利用する

当初は、プロパティにアクセスするたびに計算が走るコードとなっていました。 可読性という点ではメリットがありますが、アクセス回数が多い場合は無駄な計算が大量に発生してしまいます。

対策としては「初回アクセス時のみ計算を行い、結果は変数にキャッシュしておく」という方針が考えられます。幸いなことに Python の標準ライブラリである functools モジュールには、 cached_property というクラスのメソッドの計算結果を自動でキャッシュしてくれる機能があり、今回の目的にぴったりです。 適用してみましょう。

元のコード

class ClassA:
    @property
    def foo(self):
        return <何かしらの計算>

差分

+ from functools import cached_property

class ClassA:
+    @cached_property
-    @property
    def foo(self):
        return <何かしらの計算>

この対策の結果、「large」データにおける処理時間を66.7%削減することができました。

改善策 処理時間 [s] 時間削減率 [%] メモリ [MB] メモリ増減率 [%]
ベースライン 135.68 134
cached_property 導入 45.13 66.7 ↓ 160 19.4 ↑

対策2: nested functionの廃止

当初は、呼び出し回数が多い関数内で、下記のようにnested functionを用いて計算していました。 関数のスコープを限定でき、コードリーディング時の認知コストを下げられるというメリットがありますが、外側の関数を実行するたびに関数オブジェクトが生成されるなど、パフォーマンスに悪影響を与えてしまいます。 対策としては「nested functionを廃止して通常の関数にする」という方針が考えられます。 書き換えてみましょう。

元のコード

def foo(x, y):
    def bar(x):
        ...
    def baz(y):
        ...
    return bar(x) > baz(y)

差分

+ def bar(x):
+     ...

+ def baz(x):
+     ...

def foo(x, y):
-    def bar(x):
-        ...
-    def baz(y):
-        ...
    return bar(x) > baz(y)

この対策の結果、「large」データにおける処理時間を72.4%削減することができました。

改善策 処理時間 [s] 時間削減率 [%] メモリ [MB] メモリ増減率 [%]
ベースライン 135.68 134
cached_property 導入 45.13 66.7 ↓ 160 19.4 ↑
nested function 廃止 37.48 72.4 ↓ 160 19.4 ↑

対策3: numba の導入

当初は、下記のような2重ループで要素間の関係を計算する箇所がありました。 アルゴリズム的な工夫を入れて実質的な計算量をO(n^2)からO(nlogn)に落としていたものの、やはりボトルネックになってしまっていました。 対策として、「numba のような高速化ツールの導入や PyPyなど別の Python 処理系を利用する」という方法が考えられます。 今回はパフォーマンス改善度合いの期待幅と導入の容易さのバランスを考慮してnumbaを導入しました。

  • numbaとは:
    • numbaはPythonの関数をJIT(Just-In-Time)コンパイルして高速化するライブラリです。特に数値計算やループ処理において、C言語に近い速度を実現できます。

numbaには高速化にあたって複数のモードが存在しますが、今回は最も速くなるNoPythonモードを利用しました。このモードでは、Python固有の機能を使わずに、純粋な数値計算のみで関数を実装する必要がありますが、その分大幅な高速化が期待できます。

差分は下記のようになりました。

元のコード

def foo(sorted_items: list[ClassA]) -> list[tuple[int, int]]:
    combinations = []
    for i in range(len(sorted_items)):
        for j in range(i + 1, len(sorted_items)):
            if <要素間の関係の計算>:
                combinations.append((i, j))
            if <処理打ち切り判定>:
                break
    return combinations

差分

+ # 関数内のすべての値の型が推論可能なときはNoPythonモードが利用できる。最も速い。
+ @numba.njit
+ def foo(xs: np.ndarray, ys: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
- def foo(sorted_items: list[ClassA]) -> list[tuple[int, int]]:
+   combinations_is = []
+   combinations_js = []
-   combinations = []
    for i in range(len(xs)):
        for j in range(i + 1, len(ys)):
            if <要素間の関係の計算>:
+               combinations_is.append(i)
+               combinations_js.append(j)
-               combinations.append((i, j))
            if <処理打ち切り判定>:
                break
+   # 引数と戻り値の型を高速に扱えるものに変更
+   return np.array(combinations_is), np.array(combinations_js)
-   return combinations

この対策の結果、「large」データにおける処理時間を96.6%削減することができました。

改善策 処理時間 [s] 時間削減率 [%] メモリ [MB] メモリ増減率 [%]
ベースライン 135.68 134
cached_property 導入 45.13 66.7 ↓ 160 19.4 ↑
nested function 廃止 37.48 72.4 ↓ 160 19.4 ↑
numba 導入 4.58 96.6 ↓ 251 87.3 ↑

メモリ使用量について

numbaの導入により、メモリ使用量が87.3%増加しています。これは主に以下の要因によるものです。 - JITコンパイルされたマシンコードがメモリ上に保持される - 型情報やコンパイル時の中間表現がキャッシュされる - NumPy配列の事前確保による一時的なメモリ使用

処理時間の大幅な短縮というメリットや、本システムはCloud Run上で動作しているためメモリの追加が容易である点を考慮すると、このメモリ使用量の増加は許容範囲と判断しました。

対策4: numba に型アノテーションを付与

numbaでは、引数と戻り値に型アノテーションを付与することで実行時の型推論処理を削減できます。 適用してみましょう。

元のコード

@numba.njit()
def foo(xs: np.ndarray, ys: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    combinations_is = []
    combinations_js = []
    for i in range(len(xs)):
        for j in range(i + 1, len(ys)):
            if <要素間の関係の計算>:
                combinations_is.append(i)
                combinations_js.append(j)
            if <処理打ち切り判定>:
                break
    return np.array(combinations_is), np.array(combinations_js)

差分

+ # 引数と返り値の型アノテーションを付与
+ signature=types.Tuple((int64[:], int64[:]))(float64[:], float64[:])
+ @numba.njit(signature)
- @numba.njit()
def foo(xs: np.ndarray, ys: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    combinations_is = []
    combinations_js = []
    for i in range(len(xs)):
        for j in range(i + 1, len(ys)):
            if <要素間の関係の計算>:
                combinations_is.append(i)
                combinations_js.append(j)
            if <処理打ち切り判定>:
                break
    return np.array(combinations_is), np.array(combinations_js)

この対策の結果、「large」データにおける処理時間を97.3%削減することができました。

改善策 処理時間 [s] 時間削減率 [%] メモリ [MB] メモリ増減率 [%]
ベースライン 135.68 134
cached_property 導入 45.13 66.7 ↓ 160 19.4 ↑
nested function 廃止 37.48 72.4 ↓ 160 19.4 ↑
numba 導入 4.58 96.6 ↓ 251 87.3 ↑
numba + 型アノテーション 3.69 97.3 ↓ 248 85.1 ↑

番外編: 関数をRust化

Rustは非常に高速な言語です。 数ヶ月前に開催されたKaggle Competition2位となった解法でもRustが利用されており、シミュレータ実行や特徴量生成などの重い処理がPythonからRustにオフロードされています。

余談ですが、数年前に筆者が似たような作業に取り組んだ際は、Rust成果物のPython環境へのインストールに手間取った覚えがありました。しかし、現在ではmaturinというライブラリが上記プロセスをサポートしてくれており、コマンド一つで成果物のビルドからPython環境へインストールまでを済ませることができます。当時との違いに驚かされました。(maturinについては、また別の機会に記事を書きたいと思います)

Rustコードの開発には、GitHub Copilot (Claude Sonnet 4)を用いました。対象の関数が単純なロジックだったということもあり、Copilotと数回やり取りするだけで正常に動作するRustコードが得られました。

得られたRustコード

#[pyfunction]
fn build_char_connections(
    py: Python,
    xs: PyReadonlyArray1<f64>,
    ys: PyReadonlyArray1<f64>,
) -> PyResult<(PyObject, PyObject)> {
    let xs = xs.as_array();
    let ys = ys.as_array();
    
    let n = xs.len();
    let mut combinations_is = Vec::new();
    let mut combinations_js = Vec::new();

    for i in 0..n {
        for j in (i + 1)..n {
            if <要素間の関係の計算>
            {
                combinations_is.push(i);
                combinations_js.push(j);
            } else if <処理打ち切り判定> {
                break;
            }
        }
    }

    Ok((
        combinations_is.into_pyarray(py).into(), 
        combinations_js.into_pyarray(py).into(),
    ))
}

この対策の結果、「large」データにおける処理時間を97.8%削減することができました。

改善策 処理時間 [s] 時間削減率 [%] メモリ [MB] メモリ増減率 [%]
ベースライン 135.68 134
cached_property 導入 45.13 66.7 ↓ 160 19.4 ↑
nested function 廃止 37.48 72.4 ↓ 160 19.4 ↑
numba 導入 4.58 96.6 ↓ 251 87.3 ↑
numba + 型アノテーション 3.69 97.3 ↓ 248 85.1 ↑
Rust 化 2.98 97.8 ↓ 201 50.0 ↑

結果一覧

処理時間の推移を可視化したものが下記のグラフです。 cached_propertyとnumbaの導入が大きく効いていることがわかります。

処理時間の推移

まとめ

最終的な採用施策

本記事では5つの改善策を検証しましたが、メリット・デメリットを総合的に検討した結果、実際のアプリケーションには「numba+型アノテーション」までを採用しました。 「Rust化」は改善効果(97.3%→97.8%)に対して保守コストが高いこと、チーム内に有識者がおらず継続的なメンテナンスが困難になる可能性があることから採用は見送られました。

得られた効果

今回のパフォーマンス改善により以下のような効果が得られました。

  • ユーザー体験の向上
    • largeサイズのデータを扱う際の待ち時間が大幅に減少しました
  • 処理能力の向上
    • 1リクエストあたりの処理時間が短縮されたことで、同じリソースでより多くのリクエストを処理できるようになりました
  • インフラコストの削減
    • スケールアウトせずに負荷に対応できるようになり、サーバコストの増加を先送りできました

所感

「推測するな、計測せよ」という原則に従い、プロファイリングから始めて段階的に改善を進めることで、処理時間を97.3%削減することができました。パフォーマンス問題は、適切なアプローチによって解決可能であることを改めて実感しました。

採用募集

アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を大募集しています。

アンドパッドにおけるデータ活用の可能性は無限大で、AI・MLOps・データサイエンス・データアナリティクス・データエンジニアリング、どの切り口においても取り組むべき事柄がたくさんあります。様々な技術的課題にチームで挑戦する中で成長を遂げることができます。まずはカジュアル面談からでもご応募いただければより詳しい情報をお伝えできますので、是非ご応募いただければと思います。

hrmos.co hrmos.co hrmos.co hrmos.co