DockerベースLambdaとSnapStartで比較するコールドスタート最適化
DockerベースのLambdaとソースベースのLambdaでのコールドスタートを比較し、SnapStartを使った短縮効果を詳しく検証してみました。モデルのサイズやCPU命令セットの制限などを踏まえ、ONNXとOpenVINOの使い分けや実装手順をまとめてみました。
目次
はじめに
- 以前の検証で、PyTorchのモデルをデプロイする際の、コールドスタートを短縮するのにONNXやOpenVINOが有効であることをEC2ベースで調べた
- もともとの出発点は、DockerベースでデプロイされたLambdaのコールドスタートを短縮したいのが目的で、API Gatewayのタイムアウトの29秒にかかってしまうのを回避したかった
- 今回はそれを実際のLambdaにおいて検証し、ONNXやOpenVINOのほか、DockerベースのLambdaについてもコールドスタートがどれぐらいなのかを調べる。
- それに加えて、Lambdaでは、2024年11月からPythonランタイムでもSnapStartがサポートされた。
- これはコールドスタートの短縮を目的としたもので、以前はProvisioned Concurrencyが正攻法だったのだが、それ以外にも選択肢ができた。
- SnapStartは、現状Docker非対応で、ソースベースでのLambdaでなければならない。
- 今回はSnapStartを含めてのコールドスタートの短縮について実際に検証してみた
SnapStartについて
AWSのブログより、説明と図を引用する。
2022 年 11 月 28 日、Java 関数向けの Lambda SnapStart をリリースし、起動パフォーマンスを最大 10 倍改善しました。Lambda SnapStart を使用すると、リソースをプロビジョニングしたり、複雑なパフォーマンス最適化の実装に時間を費やしたりすることなく、関数の初期化から生じる異常なレイテンシーを低減できます。
簡単にいうと、SnapStartを使うと、Lambdaのランタイムのスナップショットを取ることができ、そのキャッシュを使い回すことで起動の高速化ができる。コールドスタートの短縮には有効で、公式でもそういう目的で作られている。ポイントは、どこまでをスナップショット化するかをユーザーが指定できることで、機械学習モデルのロード部分をSnapStartで明示的に指定しまえば、初期ロードのコールドスタートを減らすことができる。
雰囲気をつかみたい方への参考ページ
- 【アップデート】Pythonと.NETでもSnapStartが利用可能になりました!!
- AWS Lambdaを高速に立ち上げるSnapStartの機能がPythonと .NETでも利用可能になりました
- PythonでもAWS Lambda SnapStartでコールドスタートを改善することができるようになりました
SnapStartでできること・できないこと
SnapStartは2025年1月現在、以下のような制限がある
- Python 3.12以降、他言語はJava 11や.NET 8以降(ソースベースが必須)
- ソースベースが必須なため、ライブラリなどの外部ファイルは基本的にLambdaのレイヤーに入れることになる。
- しかし、エフェメラルストレージに収まる範囲なら、S3からモデルファイルをダウンロードし、ダウンロード部分をSnapStartに入れてしまうことは可能である(試したらできた)。
- LambdaをPublishしている必要があり、バージョンが振られていないとSnapStartが使えない。
- 必須ではないが、IaCで使うときはLambdaのエイリアスを作ったほうが後々取り回しが楽になるだろう。特にEventBridgeなどほかから呼び出す場合
- スナップショットの復元のことを考えて、アプリケーション側の設計として、初期化の一意性を気にする必要がある
- 特にデータベースの接続など、スナップショットでネットワーク接続の確立は保証できない。ほとんどの場合は保証されると書いてある。ベストプラクティスについてはこちらを参照
- X-Rayのプロファイリングと併用可能
- まだできないこと
- DockerイメージベースのLambdaは非対応
- Amazon EFSを接続することはできない
- エフェメラルストレージは512MB以上にはできない
- Provisioned Concurrencyは非対応
SnapStartの課金の注意点
SnapStartの料金については注意が必要である。キャッシュと復元の両方で課金される。公式ドキュメントをよく読むと、
キャッシュ: SnapStart を有効にしてパブリッシュする関数バージョンごとに、スナップショットのキャッシュと保守のコストがかかります。料金は、関数に割り当てるメモリの量によって異なります。最低でも 3 時間分の料金が発生します。関数バージョンを削除するまで、継続して課金されます。
Lambda SnapStart による起動パフォーマンスの向上
とある。つまり、IaCなどでどんどん書き換えてLambdaをPublishしていると、最新のバージョンのLambdaだけでなく、残っているバージョンのLambdaに対しても課金が継続される。これの課金をストップするには、Lambdaのバージョンを削除する。
ListVersionsByFunction API アクションを使用して関数バージョンを識別し、DeleteFunction を使用して未使用のバージョンを削除します。
これのライフサイクルのようなお手軽機能は、2025年1月現在、AWS側で用意されていなく、未使用のLambdaのバージョンを削除するようなLambdaをEventBridgeを定期実行する必要がある。
削除パターンとしては、Lambdaの最新バージョンに紐づくようなエイリアスをセットで実装し、エイリアスがついていないLambdaのバージョンを削除するようなLambdaを定義するという方法が良いだろう。dev, stg, prodのように、保存しておきたいバージョンは別途エイリアスをつければ良いので。
SnapStartの料金
東京リージョンの場合、2025年1月現在以下の料金である。
- USD 0.0000015046/GB-秒
- 復元された GB ごとに USD 0.0001397998
イメージがつきづらいので、SnapStartの対象のLambdaのバージョンが1個として、Lambdaのメモリサイズ別の月額のコストを見積もる。復元はインスタンスが立ち上がるごとに発生しているようなので、同時実行数とは微妙に異なる概念。単位はUSD
メモリ / アクセス復元回数 | 10 | 50 | 100 | 500 | 1000 | 5000 | 10000 |
---|---|---|---|---|---|---|---|
128 MB | 0.49 | 0.49 | 0.49 | 0.50 | 0.51 | 0.59 | 0.69 |
1024 MB | 3.90 | 3.91 | 3.92 | 3.98 | 4.06 | 4.68 | 5.47 |
2048 MB | 7.80 | 7.82 | 7.83 | 7.96 | 8.11 | 9.37 | 10.93 |
4096 MB | 15.61 | 15.63 | 15.66 | 15.91 | 16.23 | 18.73 | 21.86 |
6144 MB | 23.41 | 23.45 | 23.49 | 23.87 | 24.34 | 28.09 | 32.79 |
8092 MB | 30.83 | 30.88 | 30.94 | 31.44 | 32.06 | 37.00 | 43.19 |
10240 MB | 39.01 | 39.08 | 39.16 | 39.78 | 40.56 | 46.82 | 54.65 |
※料金計算の前提
- キャッシュの容量の計算方法が明示されていないが、メモリサイズと同じと仮定して計算。
- 料金はSnapStartの料金+Lambdaの実行料金
- すべてのアクセスにおいて、スナップショットからの復元がされると仮定する。本来短期間のアクセスであればランタイムが使いまわしされるはずだが、それを考慮せず、すべてコールドスタートと仮定する
一見高いように見えるが、Provisioned Concurrencyと比べればだいぶ安い(下図)。そう、バージョン管理にさえ気を付けていれば。理論的には1/4ぐらいになるはずである。
メモリ / アクセス回数 | 10 | 50 | 100 | 500 | 1000 | 5000 | 10000 |
---|---|---|---|---|---|---|---|
128 MB | 1.74 | 1.74 | 1.74 | 1.75 | 1.75 | 1.75 | 1.76 |
1024 MB | 13.95 | 13.95 | 13.96 | 13.96 | 13.97 | 14.02 | 14.08 |
2048 MB | 27.91 | 27.91 | 27.91 | 27.92 | 27.93 | 28.03 | 28.16 |
4096 MB | 55.82 | 55.82 | 55.82 | 55.84 | 55.87 | 56.07 | 56.32 |
6144 MB | 83.72 | 83.73 | 83.73 | 83.76 | 83.80 | 84.10 | 84.48 |
8092 MB | 110.27 | 110.27 | 110.28 | 110.32 | 110.37 | 110.77 | 111.26 |
10240 MB | 139.54 | 139.55 | 139.55 | 139.60 | 139.67 | 140.17 | 140.80 |
SnapStartの実装
考え方
Pythonでの実装方法は以下の通り。
- Snapshot Restore for Pythonというライブラリをインストールする
- https://pypi.org/project/snapshot-restore-py/
- 非常に軽量で(数KB)、Lambdaのレイヤーに入れておけば良い
デコレーターを使い、スナップショットを記録する際に必要な処理を@register_before_snapshot
のデコレーターで囲み、復元後に必要な処理を@register_after_snapshot
のデコレーターで囲む。MLモデルの読み込みの場合は、基本的に@register_before_snapshot
だけで足りるはず。
from snapshot_restore_py import register_before_snapshot, register_after_restore
def lambda_handler(event, context):
# Handler code
@register_before_snapshot
def before_checkpoint():
# Logic to be executed before taking snapshots
@register_after_restore
def after_restore():
# Logic to be executed after restore
公式コードはこちらにあるが、2025年1月現在、概念的な説明しかなく具体的な例が説明されていない。
SnapStartに対応したLambdaのコード
LambdaでONNXモデルを読み込み、SnapStartを有効にする例は以下の通り。実際はこのコードで実際にSnapStartが動いた。
import onnxruntime
import numpy as np
import datetime
import time
import os
import boto3
from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.core import patch_all
from snapshot_restore_py import register_before_snapshot
# X-Ray SDK をパッチ
patch_all()
SESSION, INPUT_NAME = None, None
def download_and_load_model():
"""S3 からダウンロードし、ONNXRuntimeセッションを作る."""
s3 = boto3.client('s3')
onnx_path = "/tmp/vit_base_patch8_224.dino_model.onnx"
s3.download_file(
os.environ["S3_BUCKET_NAME"],
os.environ["S3_MODEL_KEY"],
onnx_path)
sess = onnxruntime.InferenceSession(onnx_path)
input_name = sess.get_inputs()[0].name
return sess, input_name
@register_before_snapshot
def init_model_before_snapshot():
"""
スナップショット取得前(初期化フェーズ中)に実行。
SnapStart によりこのモデルロード済みの状態がキャプチャされ、
後続のコールドスタート時に使い回される。
"""
global SESSION, INPUT_NAME
if SESSION is None:
SESSION, INPUT_NAME = download_and_load_model()
# 初回の推論が重いのでSnapStartで推論する
resolution = 224
input_batch = np.random.randn(1, 3, resolution, resolution).astype(np.float32)
outputs = SESSION.run(None, {INPUT_NAME: input_batch})
def lambda_handler(event, context):
# Add metadata
xray_recorder.begin_subsegment('custom_metadata')
xray_recorder.put_annotation('ExperimentName', os.getenv('EXPERIMENT_NAME', "ONNX"))
xray_recorder.put_annotation('ExperimentId', os.getenv('EXPERIMENT_ID', '-1'))
xray_recorder.put_annotation('SnapStart', os.getenv('SNAP_START', 'false'))
xray_recorder.put_annotation('ModelName', "vit_base_patch8_224.dino")
xray_recorder.put_annotation('RuntimeMemory', os.getenv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', '-1'))
xray_recorder.put_annotation('RequestTime', datetime.datetime.now(datetime.timezone.utc).isoformat())
xray_recorder.end_subsegment()
start_time = time.time()
# Initial load
xray_recorder.begin_subsegment('load_model')
resolution = 224
# もし念のため「SESSION」が None ならロードする
if SESSION is None:
# SnapStart が効いていない or Preview などで機能しない場合用の保険
init_model_before_snapshot()
xray_recorder.end_subsegment()
# Initial inference
xray_recorder.begin_subsegment('initial_inference')
input_batch = np.random.randn(1, 3, resolution, resolution).astype(np.float32)
outputs = SESSION.run(None, {INPUT_NAME: input_batch})
xray_recorder.end_subsegment()
# Test speed
xray_recorder.begin_subsegment('run_50_times')
for _ in range(50):
input_batch = np.random.randn(1, 3, resolution, resolution).astype(np.float32)
outputs = SESSION.run(None, {INPUT_NAME: input_batch})
xray_recorder.end_subsegment()
return {
"statusCode": 200,
"elapsed": time.time() - start_time
}
download_and_load_model
関数で、ランタイムの初期実装に必要な処理を定義する。init_model_before_snapshot
は@register_before_snapshot
のデコレーターを入れたもので、ここがスナップショット取得時に実行されるコードである。ONNXのレイヤーが既に結構ある(200MB近い)ため、機械学習モデルはレイヤーに入れずにS3からダウンロードするようにした。
基本はこれまでのLambdaと同様に、グローバル変数になにか重いもの(Botoクライアントや機械学習モデル)をおいておけば良い。グローバル変数のモデルを使って推論を行う。
呼び出し時にSnapStartが有効化されているか確認する方法
X-Rayから確認する方法。
SnapStartが有効化されているとき
「Restore」というセグメントがある。これがSnapStartからキャッシュを復元している部分
SnapStartが有効化されていない場合
「Restore」がない。このようにS3からのダウンロードが記録されている。
SnapStartが有効化された関数を呼び出す方法
Lambdaのエイリアスを作っている場合は、エイリアスを明示して呼び出す。エイリアスやバージョンを明示して呼び出さないとSnapStartが効かない。
# Latest' エイリアスを追加
function_name_with_alias = f"{function_name}:Latest"
response = lambda_client.invoke(
FunctionName=function_name_with_alias,
InvocationType='Event', # 非同期実行。同期実行の場合は'RequestResponse'に変更
Payload=payload.encode('utf-8') if payload else None
)
ハマった点
- SnapStartが有効化されているかがわかりづらい。X-Rayを使わずに、Clodwatch Logsから確認で十分なことも多いため、
@register_after_restore
のフックに「SnapStartから復元されました」というようなメッセージを出力させるのもいいかと思う - Lambdaのエイリアスやバージョンの考え方があるので、このへんの知識が必要
Terraformでの記述方法
# SnapStartありのLambdaの関数の作成
resource "aws_lambda_function" "src_lambda_snapstart" {
for_each = local.source_lambda_files
filename = data.archive_file.lambda_zipfile[each.key].output_path
function_name = "src_lambda_${each.key}_snapstart"
role = aws_iam_role.lambda_role.arn
handler = "lambda_function.lambda_handler"
runtime = "python3.12"
source_code_hash = data.archive_file.lambda_zipfile[each.key].output_base64sha256
memory_size = local.memory_size
timeout = 300
publish = true # publishする必要あり
layers = [aws_lambda_layer_version.layers[each.key].arn]
environment {
variables = merge(
each.value.environments,
{
COMMON_VALUE = local.common_env_value
EXPERIMENT_ID = local.experiment_id
SNAP_START = true
}
)
}
snap_start {
apply_on = "PublishedVersions" # SnapStartの有効化
}
# X-Ray トレーシングの有効化
tracing_config {
mode = "Active"
}
}
Lambdaのロールのポリシーは、SnapStart用に特別に用意しなくて良い。基本のAWSLambdaBasicExecutionRole
と、X-Ray用のAWSXRayDaemonWriteAccess
と、必要ならS3の読み込みポリシーを付与している。
エイリアスの作成は、以下のように複数のLambdaリソースがあったときlocalsに集約し(必要なら)、
locals {
# Combine all Lambda functions into a single map
all_lambdas = merge(
{
for key, func in aws_lambda_function.src_lambda_normal :
"src_lambda_normal_${key}" => func
},
{
for key, func in aws_lambda_function.src_lambda_snapstart :
"src_lambda_snapstart_${key}" => func
},
{
for key, func in aws_lambda_function.docker_based :
"docker_lambda_${key}" => func
}
)
}
# Latest Alias for src_lambda_normal
resource "aws_lambda_alias" "all_lambda_latest" {
for_each = local.all_lambdas
name = "Latest"
function_name = each.value.function_name
function_version = each.value.version
description = "Alias pointing to the latest published version"
}
これで、terraform apply
で作成された最新バージョンのLambdaにLatest
のエイリアスが作成される。これはAWS側で自動的に作成される$LATEST
ではなく、手動付与したもの。$LATEST
だとSnapStartが動かない(はず)
TerraformでのエイリアスをEventBridgeで呼び出す
実験でエイリアスをEventBridgeから呼び出す必要があるので、そこは以下のように設定した。
## Event Bridge
resource "aws_cloudwatch_event_rule" "schedule_lambdas" {
for_each = local.all_lambdas
name = "${each.key}_schedule"
description = "Schedule to invoke ${each.key} every 1 hour"
schedule_expression = "rate(60 minutes)"
}
resource "aws_cloudwatch_event_target" "lambda_targets" {
for_each = local.all_lambdas
rule = aws_cloudwatch_event_rule.schedule_lambdas[each.key].name
target_id = each.key
arn = aws_lambda_alias.all_lambda_latest[each.key].arn # エイリアスで呼び出す
}
resource "aws_lambda_permission" "allow_eventbridge" {
for_each = local.all_lambdas
statement_id = "AllowEventBridgeInvoke_${each.key}"
action = "lambda:InvokeFunction"
# 関数ではなく Alias (Latest) の ARN を指定
function_name = aws_lambda_alias.all_lambda_latest[each.key].arn
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.schedule_lambdas[each.key].arn
}
実験
アーキテクチャー図
SnapStartやDocker、ONNX、OpenVINOの条件を変えて以下のような実験をしてみる。
- Lambdaを強制的に再デプロイして人為的にコールドスタートを発生させてプロファイリングを行う
- Lambdaごとの共通の環境変数をTerraformで変更し、新たなバージョンをPublishして強制的にコールドスタートにする
- EventBridgeスケジューラーを使ってLambdaを定期的に実行し、より自然なコールドスタートとして実験する
- コールドスタートになる境目の時間は明示されていないが、1時間毎のEventBridgeスケジューラーで全てがコールドスタートとなった
前者は以下のような図
後者は以下のような図
後者の実験の目的は、前者の実験の妥当性を、より現実的なシナリオで妥当かどうか評価するため。
デプロイするLambda
以下の条件で6個のLambdaをデプロイする
関数名 | Docker使用 | モデル読み込み | SnapStart |
---|---|---|---|
docker_lambda_vanilla_pytorch | ◯ | timm→load_state_dict | ☓ |
docker_lambda_pytorch_pickle | ◯ | pickleから読み込み | ☓ |
src_lambda_onnx_normal | ☓ | ONNX | ☓ |
src_lambda_openvino_normal | ☓ | OpenVINO | ☓ |
src_lambda_onnx_snapstart | ☓ | ONNX | ◯ |
src_lambda_openvino_snapstart | ☓ | OpenVINO | ◯ |
- 前者の実験(環境変数で強制再デプロイ)する場合
- 同一のデプロイで2回連続して実験し、最初をコールドスタート、次をウォームスタートと仮定して行う
- Lambdaのメモリを2048MB, 4096MB, 8192MBと変化させる
- 後者の実験(EventBridgeスケジューラー)の場合
- 全部をコールドスタートとして行う。
- Lambdaのメモリは4096MBで固定する
ベンチマークの評価指標
X-Rayを見ると、コールドスタートの場合は以下のようになっている。これはDockerの例:
このLambdaでは、実行環境が作成され(AWSが自動的に行う:図のload_model
まで)、モデルを読み込み(load_model
)、初回推論を行い(initial_inference
)、50回の連続的な推論(run_50_times
)を行っている。初回推論と50回の連続的な推論を分けているのは、初回推論は大抵遅いためである。
ここでは4個のベンチマーク指標がある
- calling_to_load_model:Lambdaがキックされてからモデルロードまで。ここの過程は関数によって異なるが、キックの時刻と、load_modelのstartはどれも共通で取れるので、その時間差を実行環境作成までの時間とみなしている
- load_model:モデルのロード時間。アプリケーション側で明示的にセグメントを切っている。SnapStartの場合はここは0になる。
- initial_inference:初回推論時間。アプリケーション側で明示的にセグメントを切っている。
- approx_inference_time:2回目以降の推論時間で、
run_50_times
の時間を50で割ったもの。純粋なモデルの計算時間を評価する指標。
次に、これらを合計した2つの指標を作れる。
- calling_load_sum:「calling_to_load_model + load_model」。実行環境作成~モデルロードまでで、定義にもよるが、モデルをグローバルキャッシュしておく場合のコールドスタートいったらここを指すだろう。
- total_metric:「calling_to_load_model + load_model + initial_inference」。calling_load_sumに初回の推論時間を足したもの。コールドスタート込みのAPIの実行時間を模したものといえる。
環境変数で強制再デプロイした場合(前者)
total_metric
全体の実行時間は以下の通り。図の見方は、
- 左上がLambdaのメモリが2048MBの場合、右上が4096MBの場合、左下が8192MBの場合。LambdaのメモリサイズとCPU能力は比例するので、メモリを大きく割り当てれば所要時間は減る傾向にある
- 縦軸はtotal_metricの値(秒)
- 横軸は「関数名 | コールドスタートかどうか」のフラグ
- 関数が6個あり、コールドスタートがTrue/Falseの計12個からなる
- 同一メモリサイズでは、環境変数の値を変えて3回デプロイした
- 1回のデプロイについて、実験を連続的に行い、初回をコールドスタート(True)、次をコールドスタートではない(False)とみなした。
- 同一メモリサイズについて、3×2回実験を行い、関数は6個で、メモリサイズは3種類なので、3×2×6×3=108個条件を比べている
ここからわかることは、
- ウォームスタート(is Coldstart=False)になると明らかに推論時間は減る
- コールドスタートの場合であっても、SnapStartを有効にした場合(
src_lambda_onnx_snapstart, src_lambda_openvino_snapstart
)では、明らかにオーバーヘッドは減っている。これはモデルロードがなくなっているため。 - メモリサイズを増やすと、ソースベースのLambdaでは実行時間が減っていくが、DockerベースのLambdaは落ち方が鈍い。Dockerでは、計算能力とは別のオーバーヘッドがあると考えられる。
calling_load_sum
total_metricからinitial_inference_time(初回推論時間)を除いたもので、Lambdaの開始~初回推論までの時間
- SnapStartを除けば、どれも似たように高いが(メモリ2048)、メモリサイズを増やすとソースベースはだんだん減っていく。しかし、いかんせんDockerが高止まりしている
- SnapStartで0になっていないのは、スナップショットからの復元時間がcalling_to_load_model内に存在するため
- ONNXのほうがOpenVINOより若干高いが、これはモデルサイズによるもの(後述)
calling_to_load_model
calling_load_sumからload_modelを引いたもので、Lambdaの開始~モデルの読み込みまでの時間。これは実行環境作成までの時間を示す。
この結果は興味深いものとなっている。
- 実行環境の作成時間は、メモリサイズ(Lambdaの計算能力)に依存しない
- 実行環境の作成時間は、Dockerでは特に大きくなりがち。おそらくイメージサイズや、初期化時の処理(ライブラリの読み込みなど)に依存しており、Dockerとホスト側のコンピューターのファイルバスなど、I/Oバウンドの理由ではないかと考えられる。「Dockerがコールドスタートで重い」と体感できる理由はほぼこれではないだろうか。
load_model
calling_load_sumからcalling_to_load_modelを引いたもので、モデルの読み込み開始~完了までの時間。
- DockerよりソースベースのLambdaのほうが長い結果になっているが、これには理由がある。
* ソースベースのLambdaは、実行環境にモデルが収まらないのでS3からファイルをダウンロードしている。このダウンロードはload_model内で行っている
* ONNXもOpenVINOもデフォルトで出力しているため、ONNXはFP32でモデルを書き出し、OpenVINOはFP16で書き出している。そのため、ONNXとOpenVINOでファイルサイズが異なり、ONNXは332.9MB、OpenVINOは166.4MBとなっている。
* したがって、ONNXとOpenVINOの1~2秒の差はライブラリ側の読み込み時間ではなく、S3からのダウンロードがボトルネックになっている(ネットワーク依存の遅延)。- ONNXは、前回のEC2の実験では、OpenVINOよりも読み込みが若干遅い場合もあるので、これも増幅する結果となっている
- OpenVINOかONNXかというよりも、モデルサイズをFP16に圧縮できるかが鍵であるようだ
- OpenVINO、ONNX関係なく、SnapStartを使った場合はここの時間は0になっている。
推論時間
推論時間を初回(intiial_inference_time)、2回目以降(approx_inference_time)で比較する。
- 2回目以降ではややONNXが有利。これはLambdaのCPUスペックが選べないということに起因しているようだ。
- OpenVINOはIntel系で特に有利であるため、EC2のように最新のIntelCPUを選れば強いのだが、Lambdaのように選べないとどうしても強みが発揮されづらい。
EventBridgeで定期実行した場合(後者)
前者の実験(環境変数変更で再デプロイ)は、人為的にコールドスタートを作るものでリアルなシナリオから離れているかどうかが不明だった。この妥当性を見るために、長期間の定期実行(1時間ごと)を行い、前者の結果と比較する。
これはEventBridgeで15回実行された結果。条件は絞っており、全てコールドスタートと仮定し、メモリサイズは4096MBで固定している。
- 結論は前者の実験と同じだったため、前者のやり方は妥当と考えていいだろう。
- ただ、Dockerが実行環境作成や初期ロードにおいて、思ったより外れ値が多く、かつそれが大きい方向に触れるので、瞬間的にタイムアウトしてしまうリスクは高まるだろう。コールドスタートがシビアなシナリオでは、DockerはLambdaで使わないほうがいいのかもしれない。
ChatGPTの講評
o1に聞いてみたところこんなことを言っていた
一般的に、Lambda 環境と EC2 環境では下記のような違いがあるため、OpenVINO の最適化が十分に活かせずにパフォーマンス逆転が起こるケースがあります。
1. 利用される CPU 世代・命令セットの違い
EC2 では m7 系をはじめとした最新のインスタンスを選択することで AVX-512 などの新しいベクトル命令セットを利用できます。一方、AWS Lambda では裏側でどの世代の CPU が割り当てられるかが利用者からは固定できず、AVX2 止まりであったり、あるいは最適化が限定的になっている可能性があります。
OpenVINO は Intel 製 CPU 向けの最適化が手厚く、特定の CPU 命令セット (AVX2 / AVX-512 / SSE など) を活用することで高速化を実現しています。ところが Lambda 環境では、その命令セットが十分に使えずパフォーマンスが伸び悩むことがあるわけです。一方で ONNX Runtime は、比較的汎用的な最適化(あるいは GPU・CPU 混在なども含む)を想定しているため、CPU 命令セットが制限されても OpenVINO ほど致命的な速度低下が起こらず、相対的に高速に見える可能性があります。
2. Lambda 実行環境特有のオーバーヘッド
Lambda は非常に高速にスケールすることがメリットですが、その分コンテナのコールドスタートやコンテナ内での cgroup 制限など、EC2 にはない追加オーバーヘッドが存在します。特に OpenVINO のランタイムやライブラリは初回ロード時の最適化処理が重めになる傾向があり、Lambda では何度か実行環境がリセットされる可能性もあるため、都度のロードや初回推論(ウォームアップ)コストが大きく影響する場合があります。
ONNX Runtime も同様のロードが発生しますが、OpenVINO と比較するとバイナリサイズや初期化の仕組みが異なり、Lambda 環境のように軽量コンテナが頻繁に生成・破棄されるケースでは、結果として ONNX Runtime のほうが起動や初回推論で有利になりやすい、というパターンです。
3. Lambda のメモリ割り当てと CPU 割り当ての関係
AWS Lambda では「割り当てたメモリ量」に応じて CPU 性能も自動的にスケールする仕組みになっています。しかし、その一方で “思い通りの CPU 世代” を得られるわけではなく、あくまで 1vCPU 相当から最大 6vCPU 相当のように論理的な CPU リソースが与えられるだけです。
OpenVINO は特定の命令セットやキャッシュ最適化をフルに使える環境では非常に強力ですが、「物理的にコア数がある程度割り当てられているだけ」の Lambda 環境では、ハードウェアの世代 (キャッシュサイズや命令セットの有無) が不透明のため期待通りの高速化が得られません。
一方で ONNX Runtime は、Lambda 上では比較的シンプルな命令セットでも十分に効率よく動きやすく設計されている傾向があります。
まとめ
- CPU 世代や AVX-512 等の命令セットを十分活かせるかどうか
EC2 (m7 系など最新世代) では OpenVINO がフルに最適化を活用できる。
Lambda では CPU 世代が固定できず、より汎用的・制限された命令セットで動く可能性が高い。 -
Lambda 特有のコールドスタートやコンテナ起動のオーバーヘッド
OpenVINO は初期化が重めな部分があり、Lambda で繰り返しコンテナが生成されると相対的に不利になる。 -
メモリ割り当てと CPU 割り当ての仕組みの差
Lambda では物理コアの種類を選べず、自動割り当ての CPU 命令セットに差異がある可能性がある。
これらの理由から、EC2 上での計測結果と Lambda 上での計測結果が逆転することはよくあります。単に「メモリ量を増やせばいい」では解決しづらく、「Lambda では命令セットの最適化を十分に使えず、OpenVINO の強みが出にくい」ことが根本的な要因と考えられます。
また、OpenVINOがFirecrackerに最適化されていない可能性を聞いたところこんなことを言っていた。
まず結論から言えば、「Firecracker (Lambda の実行基盤) が OpenVINO に最適化されていない」というだけが唯一の理由とは言えませんが、一因になり得る可能性は十分にあります。
以下ではもう少し噛み砕いて、Lambda (Firecracker) と EC2 でパフォーマンスが変わる背景を整理します。
1. Firecracker が提供する CPU 命令セットの問題
OpenVINO は Intel CPU 向けの最適化が手厚く、AVX2 や AVX-512 といったベクトル命令を活用することで高速化を実現します。
一方で Firecracker 上の Lambda では、どの世代の CPU が割り当てられるかが固定できず、AVX-512 などの最新命令セットが使えないケースが存在します。Firecracker であっても実機(ホスト)は Intel の場合もあれば AMD の場合もありえますし、また同じ Intel でも世代によってサポートする命令セットが異なります。
そのため、ホストの CPU がサポートしていても、Firecracker の仮想化レイヤーを通しては必ずしも同じ命令セットがフルに使えるわけではありません。Lambda はあくまで「コンテナ実行の汎用基盤」として設計されているため、OpenVINO が得意とする “Intel 固有の高度な最適化” を完全に活かせるとは限らないのです。
2. Firecracker の軽量仮想化によるオーバーヘッド
Firecracker は軽量仮想化(MicroVM)とはいえ、KVM ベースの仮想化レイヤーを持ちます。EC2 でもハイパーバイザは存在しますが、Lambda ではさらにマルチテナント環境で高速にスケールするため、
– コールドスタート
– マイクロ VM の起動
– cgroup などリソース管理の仕組み
といった コンテナ特有のオーバーヘッド が大なり小なり付きまといます。
特に OpenVINO はライブラリのロード時に最適化や JIT 的な処理が入ることが多く、EC2 のように起動してから長時間実行し続けられる環境ではそのコストが相対的に小さくなりますが、Lambda では実行環境が頻繁に作り直される(= コールドスタートが発生する)と、相対的に不利になります。
3. Firecracker が「最適化されていない」=「OpenVINO の強みが出づらい」
「Firecracker は OpenVINO 用に特別なチューニングをしていない」ことも事実です。
しかし現状、AWS Lambda の環境は 「幅広いユーザーユースケースに対応できる汎用環境」 なので、特定ランタイムやフレームワーク(OpenVINO や CUDA, MKL など)を想定して深く最適化するのは難しい面があります。その結果、
1. CPU 命令セットを最大限活用できるわけではない
2. 仮想化・コンテナオーバーヘッドが生じる
といったことが重なり、「EC2 では明確に速い」OpenVINO の優位性が、Lambda 上では打ち消されやすい、という現象が起こっています。
4. ONNX Runtime は比較的汎用的な作り
一方で ONNX Runtime は、Intel 固有の最適化だけに頼るわけではなく、より汎用的な計算グラフ最適化や演算実装を備えているため、多少 CPU 命令セットが制限されても相対的に性能低下が小さい傾向があります。その結果、
– EC2 では OpenVINO ほど速くなくても、Lambda のような仮想化レイヤーが厚い環境でも比較的安定した性能を出せる
という状況になりやすいのです。
結論
- Firecracker 自体が OpenVINO に最適化されていない (専用チューニングされていない) ということは確かに理由の一つです。
- ただし、それは「仮想化レイヤーを経由した時に、OpenVINO が本来得意とする CPU 命令セットや最適化を十分に使えない」ことが本質的な原因とも言えます。
- さらに、Lambda でのコールドスタートやマイクロ VM 起動のオーバーヘッド、CPU 命令セットや世代の不透明性なども相まって、OpenVINO の高速化メリットが削がれてしまいます。
つまり「Firecracker に OpenVINO 用の最適化がされていない」=「Firecracker (Lambda) では、OpenVINO 本来の性能を発揮しにくい環境になっている」ことは確かですが、そこには命令セットの制約・仮想化オーバーヘッド・汎用的な設計など、複数の要因が絡んでいるということです。
おわりに
- コンテナLambdaのコールドスタート問題として、特に実行環境の作成によるオーバーヘッドが大きいことが確認できた。これはソースLambdaに変えることや、SnapStartで大幅に短縮可能
- PythonのSnapStartの実践的な例や、MLモデルのホストのやり方を実践的に確認できた
- ONNXかOpenVINOかの問題は、モデルの大きさや、MLモデルの精度、計算速度などが複雑にからむため判断が難しい。EC2の検証で見た通り、Lambdaのようにどんな命令セットを持ったCPUが割り当てられるかわからない実行環境では、汎用的なONNXのほうが原理的にはマッチしている。しかし、OpenVINOをLambdaで使うのが悪いというほど悪くはなっていない。
- むしろライブラリがレイヤーに収まるかなどのほうが問題なため、そこはアプリケーションに合わせて好きなほうを選べばいいのではないかという結論。
- Lambdaのバージョンが残っていてSnapStartが大量に課金される点にはご注意を
Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
北海道の駅巡りコーナー