
GPUなしでもここまでできる - AWS LambdaによるYOLOv8推論の最適化手法
1 はじめに
製造ビジネステクノロジー部の平内(SIN)です。
機械学習モデルをAWS環境で運用する際、GPU搭載のEC2インスタンスやSageMaker Endpointを使用するのが一般的ですが、常時起動のコストが課題となることがあります。特に利用頻度が低い場合や、リアルタイム性がそれほど求められない用途では、従量課金のAWS Lambdaが魅力的な選択肢となります。
しかし、LambdaではGPUが利用できないため、CPU環境でどこまで実用的な速度を実現できるかが鍵となります。
本記事では、AWS LambdaでYOLOv8セグメンテーションモデルを構築し、最大のパフォーマンスが出せるように最適化プロセスを重ねた記録を紹介します。
なお、先日公開されたLambda Managed Instancesを利用すると、GPUインスタンスを立てることも可能となりそうですが、こちらはインスタンスの常時起動が必要となり、Lambdaのコストメリットが無くなってしまうため、今回対象外とさせていただいております。
2 Lambdaによる実装
(1) サンプル実装
本記事で使用したサンプル実装は、GitHubで公開しています。
リポジトリ: lambda-yolo-sample
- YOLOv8セグメンテーション: カスタムモデル(best.pt, 6.4MB)を使用
- Lambda Container Images: 10GBまでのイメージサイズに対応
- AWS CDK: TypeScriptによるインフラのコード化
lambda-yolo-sample/
├── cdk/ # CDKプロジェクト(TypeScript)
│ ├── bin/cdk.ts # エントリーポイント
│ ├── lib/cdk-stack.ts # スタック定義
│ └── lambda/ # Lambda関数(Python)
│ ├── best.pt # カスタムモデル
│ ├── Dockerfile
│ ├── requirements.txt
│ └── lambda_function.py
└── scripts/ # テストスクリプト
├── invoke_lambda.py # 単一試験用
├── measurement.py # 多数試験用(平均値算出)
└── requirements.txt
デプロイ方法は、以下です。
# CDK依存関係のインストール
cd lambda-yolo-sample/cdk
pnpm install
# CDKブートストラップ(初回のみ)
pnpm exec cdk bootstrap
# Lambdaをデプロイ
pnpm exec cdk deploy
デプロイ後、テストスクリプトで動作確認ができます。
cd ../scripts
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Lambda関数を呼び出し
python invoke_lambda.py --image your-image.jpg --save-result result.jpg
API仕様は以下の通りです。
リクエスト:
{
"image": "base64エンコードされた画像文字列"
}
レスポンス(成功時):
{
"statusCode": 200,
"body": {
"annotatedImage": "<base64 image data omitted>",
"detections": [
{
"class_id": 0,
"class_name": "DUCK",
"confidence": 0.9832468628883362,
"bbox": [
143.3034210205078, 340.84954833984375, 384.9390563964844,
576.7772827148438
]
}
],
"summary": { "total_detections": 1, "classes_detected": ["DUCK"] },
"inference_time_ms": 153.74064445495605,
"timing_breakdown": {
"decode_ms": 4.119157791137695,
"yolo_total_ms": 202.5775909423828,
"inference_ms": 153.74064445495605,
"plot_ms": 48.674583435058594,
"detection_list_ms": 0.15401840209960938,
"encode_ms": 196.68984413146973,
"summary_ms": 0.009298324584960938
}
}
}

(2) Container Images 採用の理由
サンプルのデプロイ方式は、Container Imagesを採用しております。
Lambdaのデプロイ方式としては、Container Images と Lambda Layerがありますが、両者を比較すると以下のようになります。
| 項目 | Lambda Layer | Container Images |
|---|---|---|
| サイズ制限 | 250MB | 10GB (40倍) |
| 柔軟性 | Pythonパッケージのみ | システムパッケージも含む |
| ローカルテスト | 困難 | Dockerで完全再現 |
| ビルド時間 | 短い | やや長い(10-15分) |
機械学習モデルのデプロイを考えたとき、上記の中でも、「サイズ制限」が最もネックになると思います。一般的な機械学習のデプロイは、以下のようなサイズ感となり、250MBという制限は、非常に厳しいものになる可能性があります。
| ライブラリ | サイズ(概算) |
|---|---|
| PyTorch (CPU版) | ~130MB |
| OpenCV | ~30MB |
| Ultralytics (YOLOv8) | ~10MB |
| numpy | ~20MB |
| 合計 | ~220MB |
以上の考察から、サイズ制限を回避し、自由なライブラリ等の配置、そして将来的な拡張性を考えて、Container Images方式を採用しています。
(3) Dockerfile実装上の留意事項
機械学習モデルのデプロイでは、buildに比較的時間を要するため、開発中の手戻りも含め、できるだけ時間を短縮できるよう、変更頻度の低いものを上に配置するように意識しています。
- ベースイメージ (変更頻度: 極低) - FROM
- システムパッケージ (変更頻度: 低) - yum install
- NumPy (変更頻度: 低) - バージョン固定
- PyTorch (変更頻度: 低) - サイズが大きいため先にインストール
- Python依存関係 (変更頻度: 中) - requirements.txt
- アプリケーションコード (変更頻度: 高) - lambda_function.py
また、イメージサイズを抑えるため、CPU版のみのPyTorch、キャッシュの削除、軽量のopencv-pythonなどを採用しています。
# GPU版PyTorchは約2GB - 不要なサイズ増加
# RUN pip install torch torchvision
# CPU版PyTorchは約500MB - 1.5GB削減
RUN pip install torch torchvision \
--extra-index-url https://download.pytorch.org/whl/cpu
# キャッシュディレクトリを削除してサイズ削減
RUN pip install --no-cache-dir torch torchvision
# opencv-python-headlessでGUI依存を削除(約100MB削減)
RUN pip install opencv-python-headless
3 最適化プロセス
(1) ベースライン測定
最適化を始める前に、基準として、Lambda(3GB)の計測を行いました。
なお、どの処理にどれだけの時間を要しているかを確認できるように、結果を出力できるようにしています。
計測は31回のリクエストを行い、コールドスタートを排除するため2回目から31回目までの30回分の平均値を算出しています。
============================================================
計測結果(平均値)
============================================================
総処理時間: 710.91 ms
Lambda内の計測: 406.13 ms
Base64デコード: 4.23 ms
YOLO処理合計: 205.16 ms
- 推論: 155.63 ms
- 結果描画: 49.36 ms
- 検出リスト作成: 0.16 ms
Base64エンコード: 196.73 ms
サマリー作成: 0.01 ms
その他(オーバーヘッド等): 304.78 ms
============================================================
(2) メモリ10GBへ拡張
最適化として、Lambdaのメモリサイズを10G(最大)まで上げました。メモリを増やすことで、vCPUが上がるため、推論の処理などが短縮される想定です。
変更内容
cdk/lib/cdk-stack.ts
const yoloFunction = new DockerImageFunction(this, "YoloSampleFunction", {
functionName: "yolo-sample",
code: DockerImageCode.fromImageAsset(path.join(__dirname, "../lambda"), {
// 【Lambdaメモリ設定 前】
//memorySize: 3008, // 3GB
// 【Lambdaメモリ設定 後】 vCPU 6個相当でYOLO推論を高速化(3倍)
memorySize: 10240, // 10GB (最大)
// ...
});
計測結果です。
全体で130.7ms短縮されています。YOLOの処理時間、特に推論が、156msから73msへと飛躍的に高速化していることが分かります。
============================================================
計測結果(平均値)
============================================================
総処理時間: 580.21 ms
Lambda内の計測: 296.59 ms
Base64デコード: 4.41 ms
YOLO処理合計: 92.30 ms
- 推論: 72.91 ms 【-136.42】
- 結果描画: 19.21 ms 【-30.15】
- 検出リスト作成: 0.17 ms
Base64エンコード: 199.87 ms
サマリー作成: 0.01 ms
その他(オーバーヘッド等): 283.62 ms
============================================================
メモリを大きくすると、当然Lambdaのコストは上がりますが、実行時間も短縮されることと、元々の単価が低いこともあり、それほど気にするコストアップとはならないと思います。
例: 1日に1,000回リクエストするとした場合の試算
| メモリ | 1ミリ秒あたりの価格(USD) | 1回あたり実行時間(ms) | 1日の合計実行時間(ms) | 月間合計実行時間(ms) | コスト(USD/mon) |
|---|---|---|---|---|---|
| 3G | 0.0000000500 | 406.13 | 406,130 | 12,183,900 | 0.61(約95円) |
| 10G | 0.0000001667 | 295.59 | 206,560 | 8,897,700 | 1.48(約224円) |
※ 注意: 課金対象となるLambdaの実行時間は、オーバーヘッドにより、上記の計測時間より大きくなると思います。上記は、3Gと10Gの比較という意味での参考値とさせてください。
(3) ARM64アーキテクチャへ変更(失敗)
公式ドキュメントには、「ARM64アーキテクチャ(AWS Graviton2プロセッサ)を採用したLambda関数は、x86_64アーキテクチャで実行される同等の関数と比較して、大幅に優れた価格とパフォーマンスを実現できます」との記載があります。
ということで、ARM64への移行を試みました。
変更内容
cdk/lib/cdk-stack.ts
const yoloFunction = new DockerImageFunction(this,"YoloSampleFunction", {
functionName: "yolo-sample",
code: DockerImageCode.fromImageAsset(path.join(__dirname, "../lambda"), {
// ...
// 【ARM64への変更 前】
//platform: cdk.aws_ecr_assets.Platform.LINUX_AMD64,
// 【ARM64への変更 後】コスト削減を狙うがYOLO推論が遅くなるため非推奨
platform: cdk.aws_ecr_assets.Platform.LINUX_ARM64,
}),
// 【ARM64への変更 前】
//architecture: lambda.Architecture.X86_64,
// 【ARM64への変更 後】コスト削減を狙うがYOLO推論が遅くなるため非推奨
architecture: lambda.Architecture.ARM_64,
// ...
});
lambda/Dockerfile
# 【ARM64への変更 前】
# FROM public.ecr.aws/lambda/python:3.11
# 【ARM64への変更 後】コスト削減を狙うがYOLO推論が遅くなるため非推奨
FROM public.ecr.aws/lambda/python:3.11-arm64
計測結果です。
一部の処理は、確かに高速化されているのですが、全体で、96.54ms、特に推論が、大幅に処理低下(146.98ms)してしまいました。
============================================================
計測結果(平均値)
============================================================
総処理時間: 676.66 ms 【+96.54】
Lambda内の計測: 394.01 ms
Base64デコード: 2.95 ms
YOLO処理合計: 228.99 ms
- 推論: 219.89 ms【+146.98】
- 結果描画: 8.91 ms
- 検出リスト作成: 0.17 ms
Base64エンコード: 162.06 ms
サマリー作成: 0.01 ms
その他(オーバーヘッド等): 282.65 ms
============================================================
なぜARM64で遅くなってしまうのか?を調査したところ以下のような情報がありました。
x86_64の強み:
- **Intel MKL (Math Kernel Library)** という高度に最適化された数学演算ライブラリを使用
- MKLは以下を提供:
- 高速な行列演算(BLAS/LAPACK)
- SIMD命令(AVX2/AVX-512)の最適化
- マルチスレッド並列化
- キャッシュ効率の最適化
ARM64の弱み:
- ARM64用のBLAS実装(OpenBLAS、BLIS等)は存在するが、MKLほど最適化されていない
- PyTorchのARM64ビルドは、x86_64ほど成熟していない
- 深層学習の畳み込み演算がARM64のNEON SIMD命令を十分に活用できていない
ということで、ARMへの移行は、今回の場合、メリットがなかったので実装を戻しました。
(4) Base64エンコード最適化 (PNG → JPEG変換)
「メモリ10GBへ拡張」時点で、「Base64エンコード」が、199.87 msと比較的ボトルネックとなっていることが分かります。
これは、単純にデータサイズを小さくすることで改善できるはずなので、PNG形式からJPEG形式への変更を試してみました。
変更内容
lambda/lambda_function.py
# lambda/lambda_function.py
# 【画像エンコード形式 後】 JPEG形式で圧縮率を上げエンコード時間を98%削減
def encode_image_to_base64(image: np.ndarray, format: str = "JPEG", quality: int = 85) -> str:
# BGR -> RGB変換
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
# PIL Imageに変換
image_pil = Image.fromarray(image_rgb)
# バイトストリームに書き込み
buffer = BytesIO()
if format == "JPEG":
image_pil.save(buffer, format=format, quality=quality)
else:
image_pil.save(buffer, format=format)
buffer.seek(0)
# Base64エンコード
image_base64 = base64.b64encode(buffer.read()).decode("utf-8")
return image_base64
計測結果です。
全体の処理速度は、276.67msの削減です。特に、Base64エンコードが劇的に下がっています。また、その他(オーバーヘッド等)もデータ転送量が減ることなどの影響で下がっていることが分かります。
============================================================
計測結果(平均値)
============================================================
総処理時間: 303.54 ms【-276.67】
Lambda内の計測: 88.09 ms
Base64デコード: 4.36 ms
YOLO処理合計: 80.02 ms
- 推論: 64.98 ms
- 結果描画: 14.87 ms
- 検出リスト作成: 0.16 ms
Base64エンコード: 3.70 ms 【-196.17】
サマリー作成: 0.01 ms
その他(オーバーヘッド等): 215.45 ms【-68.17】
============================================================
ファイルサイズ比較
| フォーマット | ファイルサイズ | Base64サイズ | エンコード時間 |
|---|---|---|---|
| PNG | ~1.5 MB (推定) | ~2.0 MB | 199.87 ms |
| JPEG (quality=85) | ~300 KB (推定) | ~400 KB | 3.70 ms |
ここで、画像ファイルのサイズが、全体の処理速度に(ひいてはコストにも)大きく影響することが分かりました。
推論に影響が出ない範囲での画像サイズの縮小や画像形式の選定は、非常に重要だと思いました。
(5) PyTorch最適化 model.fuse() + inference_mode()
PyTorchの推論最適化テクニックを適用してみます。
変更内容
lambda/lambda_function.py
import torch # 追加
def initialize_model() -> YOLO:
"""YOLOモデルを初期化"""
global model
if model is None:
model_name = os.environ.get("MODEL_NAME", "/opt/ml/model/best.pt")
print(f"Initializing YOLO model: {model_name}")
model = YOLO(model_name)
model.fuse() # ★ レイヤーをfuse(10-20%高速化)
print("YOLO model loaded and fused successfully")
return model
def process_yolo_detection(
image: np.ndarray,
conf_threshold: float = 0.25,
iou_threshold: float = 0.45
) -> tuple[np.ndarray, List[Dict[str, Any]], Dict[str, float]]:
"""YOLO物体検出を実行"""
timing = {}
yolo_model = initialize_model()
# YOLO推論を実行
inference_start = time.time()
with torch.inference_mode(): # ★ 推論モードで高速化
results = yolo_model(
image,
conf=conf_threshold,
iou=iou_threshold,
verbose=False
)
inference_end = time.time()
timing['inference_ms'] = (inference_end - inference_start) * 1000
# ... (以下省略)
計測結果です。
僅かですが、推論の高速化が図られたことが分かります。
============================================================
計測結果(平均値)
============================================================
総処理時間: 286.30 ms【-17.21】
Lambda内の計測: 78.26 ms
Base64デコード: 4.25 ms
YOLO処理合計: 70.36 ms
- 推論: 55.96 ms【-9】
- 結果描画: 14.24 ms
- 検出リスト作成: 0.16 ms
Base64エンコード: 3.64 ms
サマリー作成: 0.01 ms
その他(オーバーヘッド等): 208.04 ms
============================================================
(6) 画素数の縮小
ここまで最適化を進めて来た結果、「オーバーヘッド等」が、全体処理時間の殆どを占めるようになってきました。
ここで、「オーバーヘッド等」とは、総処理時間(クライアント側計測) から Lambda内の計測時間 を引いた差です。
その他 = 総処理時間 - Lambda内の計測
その他 = 286.30ms - 78.26ms = 208.04ms
具体的にここに含まれるのは、Lambdaのオーバーヘッドや、ネットワークによる遅延によるものと考えられます。
要件による話ですが、ここでは、サンプルとして画素数を半分にして試してみました。
結果は、以下です。オーバヘッド分の縮小は、46.13ms程度でしたが、推論以外の全体に渡って時間短縮となりました。
============================================================
計測結果(平均値)
============================================================
総処理時間: 219.89 ms【-66.41】
Lambda内の計測: 57.99 ms
Base64デコード: 1.79 ms【-2.45】
YOLO処理合計: 55.08 ms
- 推論: 50.75 ms【-5.21】
- 結果描画: 4.17 ms【-10.07】
- 検出リスト作成: 0.16 ms
Base64エンコード: 1.12 ms【-2.52】
サマリー作成: 0.01 ms
その他(オーバーヘッド等): 161.91 ms【-46.13】
============================================================
画素数が半分になっても、見た感じは、殆ど変わりないですが、アノテーションされた表示が、大きくなっているのが分かると思います。

| 状態 | 画素数 | 送信画像サイズ(KB) | 受信画像サイズ(KB) |
|---|---|---|---|
| 変更前 | 540 * 960 | 86 | 74 |
| 変更後 | 270 * 480 | 40 | 29 |
データ量は、ネットワーク遅延に大きく影響するため、レスポンスを必要最小限の情報に絞るなどの実装が重要です。
4 最後に
最適化による改善結果です。
| 状態 | 全体実行時間(ms) | 改善時間(ms) |
|---|---|---|
| ベースライン (3GB, PNG 540*960) | 710.91 | |
| メモリ10G (10GB, PNG 540*960) | 580.21 | 130.7 |
| エンコード改善 (10GB, JPEG 540*960) | 303.54 | 276.67 |
| PyTorch最適化 (10GB, JPEG 540*960) | 286.30 | 17.24 |
| 画素数 (10GB, JPEG 270*480) | 219.89 | 66.49 |
色々な最適化を試してみて、どの部分にどのような負荷がかかるのかが、少し見えたような気がしました。
もし、200ms程度という速度が、要件にあうのであれば、CPUによるサーバレスの実装は、非常に魅力的な選択に入ると思います。今回の作業で得た「改善方法とその効果」をよく考え、今後の設計に応用したいと思います。







