AWS Lambdaはリクエストを受け付けてから実行環境を立ち上げて処理する為、初回のリクエストは2回目以降と比べて遅くなります。これをコールドスタートと言います。
※ コールドスタートの詳細についてはこちらのドキュメントを読んでいただくのが良いと思います。
コールドスタート時に動く処理の多くはAWS側が管理しており、開発者が工夫出来る箇所は多くありません。ただし、Initフェーズは例外で、ここではLambda Extensionsの初期化やhandlerの外側のロジックが実行される為、一度だけ実行すれば良い処理や、ライブラリのインポートやクライアントの生成といった重い処理をこのフェーズで済ませておくことは、ベストプラクティスとしてよく知られています。
また、Lambdaではメモリサイズを指定することでリソースを割り当てます。メモリサイズに比例して使えるCPUの量も増える為、処理を高速化したい場合はメモリサイズを大きく設定するのが基本的な考え方となります。
ここからが本題なのですが、ライブラリのインポートに時間がかかるケースなどで、Initフェーズの処理を高速化する為にメモリサイズを増やしても、ほとんど効果が感じられなかったことはないでしょうか。これはこちらの記事で紹介されているように、Initフェーズの最初の10秒間はメモリサイズに比例する量のCPUではなく、ホストCPU容量のバーストが割り当てられている為です。
この挙動は、日本語圏のコミュニティでは boost host CPU と呼ばれています。AWSのサービスドキュメントには記載のない呼び名ですが、元をたどるとre:Invent 2019のセッション資料にある Boosted host CPU access という表記に行き着きます。
この boost host CPU の効き方は、メモリサイズの決定やInitフェーズへの処理の寄せ方に直結します。本記事ではその挙動を測定し、チューニングにおいてどこまで当てにして良いか、どのような条件で個別の考慮が必要かを整理します。
検証の前提条件
Lambda設定
今回は、以下の設定で検証を行います。
| 項目 | 内容 |
|---|---|
| Runtime | Python 3.14 |
| Lambdaの形式 | zip形式 |
| VPC内/VPC外 | VPC外 |
| CPUアーキテクチャ | arm64 |
測定環境
| 項目 | 内容 |
|---|---|
| リージョン | ap-northeast-1(東京) |
| メモリサイズ | 256MB〜4096MB(256MB刻みで16段階) |
| 測定回数 | 各条件毎に30回 |
| 測定値 | Graviton3で実行されたデータの平均値 ※ |
※ Lambda上で実行されるCPUの世代については、こちらの記事を参考に、/proc/cpuinfo の CPU part の値が0xd40となっているものをGraviton3として扱っています。世代は選択出来ない為、30回の測定のうちGraviton3で実行された分だけを集計対象としています。メモリサイズ毎の件数は9〜24件です。
検証対象外の観点について
以下の観点は、本記事では検証対象外とします。
- リージョン差異や測定する時間帯による性能影響
- ホストCPU容量のバーストの持続時間そのもの
検証
検証1 マルチプロセスで処理する場合の性能影響
最初の検証では、Initフェーズで複数のプロセスを立ち上げた場合について、メモリサイズが256MB〜4096MBの間の処理時間を測定します。各プロセスでは1000万回のループ処理を行っています。
import os
import time
# 同時に立ち上げるプロセス数
PROCESS_COUNT = 1
def execute_loop():
x = 0
for i in range(10000000):
x += i
return x
def run_multiprocess(process_count):
pid_list = []
for _ in range(process_count):
pid = os.fork()
if pid == 0:
execute_loop()
# 子プロセスを、後始末を走らせずに即座に終了させる
os._exit(0)
pid_list.append(pid)
# 指定した子プロセスが終わるまでブロックし、その終了ステータスを回収する
for pid in pid_list:
os.waitpid(pid, 0)
def get_cpu_part():
with open('/proc/cpuinfo') as f:
line = next(l for l in f if l.startswith('CPU part'))
return line.split(':')[1].strip()
# マルチプロセスの前後で測定する
start_time = time.time()
run_multiprocess(PROCESS_COUNT)
elapsed_time = time.time() - start_time
def lambda_handler(event, context):
return {
'process_count': PROCESS_COUNT,
'elapsed_time': elapsed_time,
'cpu_part': get_cpu_part(),
}
検証結果
まず、PROCESS_COUNTを1に設定し、1プロセスにおけるループの処理時間(elapsed_time)を測定しました。結果は、メモリサイズに依らず440ms付近でほぼ一定となりました。メモリサイズを減らしても処理時間が長くならないということは、boost host CPU の挙動が実在する証拠と言えるでしょう。
次に、PROCESS_COUNTを2に設定し、2プロセスにおけるループの処理時間(elapsed_time)を測定しました。結果は、2048MBまではメモリサイズに依らず880ms付近でほぼ一定となり、2304MB以上では顕著に処理時間が短くなるという結果になりました。
880msは1プロセスの場合(440ms)のちょうど2倍にあたります。つまり2プロセスを同時に走らせても、Initフェーズで使えるCPUは1プロセス分しかなく、2048MBまではメモリサイズを増やしてもその量は変わらないということになります。
検証2 Lambda Extensionsを利用する場合の性能影響
次の検証では、自作のExtensionを設定した場合のメモリサイズが256MB〜4096MBの間の処理時間を測定します。Lambda関数の本体とExtensionのそれぞれでは1000万回のループ処理を行っています。公式ドキュメントには、Lambda Extensionsは独立プロセスとして実行されるが、CPUリソースは共有されると記載されており、検証1の2プロセスと同じ傾向になることが予想されます。
※ 以降の検証では処理時間(elapsed_time)とは別にrun_delay_timeの測定を行います。これはそのスレッドにおけるCPUの割り当てを待った時間を意味します。
import time
def execute_loop():
x = 0
for i in range(10000000):
x += i
return x
def get_run_delay_time():
# /proc/self/schedstat の run_delay(累積)を秒で返す。
with open('/proc/self/schedstat') as f:
return int(f.read().split()[1]) / 1e9
def get_cpu_part():
# /proc/cpuinfo の CPU part を返す。全コアが同じ値なので先頭だけ見る。
with open('/proc/cpuinfo') as f:
line = next(l for l in f if l.startswith('CPU part'))
return line.split(':')[1].strip()
run_delay_time_before = get_run_delay_time()
start_time = time.time()
execute_loop()
elapsed_time = time.time() - start_time
run_delay_time = get_run_delay_time() - run_delay_time_before
def lambda_handler(event, context):
return {
'elapsed_time': elapsed_time,
'run_delay_time': run_delay_time,
'cpu_part': get_cpu_part(),
}
#!/var/lang/bin/python3
# Extensionの実装
import json
import os
import urllib.request
# このファイルの名前と一致していないと登録が弾かれる。
EXTENSION_NAME = 'extension'
RUNTIME_API = os.environ['AWS_LAMBDA_RUNTIME_API']
def register():
request = urllib.request.Request(
f'http://{RUNTIME_API}/2020-01-01/extension/register',
data=json.dumps({'events': ['SHUTDOWN']}).encode(),
headers={
'Lambda-Extension-Name': EXTENSION_NAME,
'Content-Type': 'application/json',
},
method='POST',
)
with urllib.request.urlopen(request) as response:
return response.headers['Lambda-Extension-Identifier']
def wait_next_event(extension_id):
request = urllib.request.Request(
f'http://{RUNTIME_API}/2020-01-01/extension/event/next',
headers={'Lambda-Extension-Identifier': extension_id},
)
with urllib.request.urlopen(request) as response:
return json.loads(response.read())
def execute_loop():
x = 0
for i in range(10000000):
x += i
return x
extension_id = register()
print('[EXT] register終了', flush=True)
# ここでループ処理をすることで、Initフェーズと被せることが出来る
execute_loop()
while True:
event = wait_next_event(extension_id)
if event.get('eventType') == 'SHUTDOWN':
break
検証結果
まず、ExtensionをLambda関数と紐付け、Extension側のexecute_loop関数の実行部分をコメントアウトした状態で測定を行いました。結果は検証1の1プロセスの時と同様に、メモリサイズに依らず440ms付近でほぼ一定となり(図1枚目)、CPUの割り当て待ち時間も発生しませんでした(図2枚目)。
次に、Extension側のexecute_loop関数の実行部分のコメントアウトを元に戻した状態で測定を行いました。結果は検証1の2プロセスの時と同じ傾向で、2048MBまではメモリサイズに依らず780ms付近でほぼ一定となり、2304MB以上では顕著に処理時間が短くなりました(図1枚目)。CPUの割り当て待ち時間も2048MBまでは330ms付近でほぼ一定となり、2304MB以上では待ち時間が短くなって、最終的に30ms付近まで収束しました(図2枚目)。
Extensionを動かしたことによる処理時間の増分(780ms - 440ms = 340ms)は、CPUの割り当て待ち時間(330ms)とほぼ一致します。これはどのメモリサイズでも成り立っており、Extensionを足したことによる遅延はCPUの割り当て待ちで説明が付くということになります。
検証3 pandasをインポートする場合の性能影響
次の検証では、pandasをインポートした場合のメモリサイズが256MB〜4096MBの間の処理時間を測定します。この検証で利用するpandasのバージョンは3.0.5です。
import time
def get_run_delay_time():
# /proc/self/schedstat の run_delay(累積)を秒で返す。
with open('/proc/self/schedstat') as f:
return int(f.read().split()[1]) / 1e9
def get_cpu_part():
# /proc/cpuinfo の CPU part を返す。全コアが同じ値なので先頭だけ見る。
with open('/proc/cpuinfo') as f:
line = next(l for l in f if l.startswith('CPU part'))
return line.split(':')[1].strip()
run_delay_time_before = get_run_delay_time()
start_time = time.time()
import pandas as pd
elapsed_time = time.time() - start_time
run_delay_time = get_run_delay_time() - run_delay_time_before
def lambda_handler(event, context):
return {
'elapsed_time': elapsed_time,
'run_delay_time': run_delay_time,
'cpu_part': get_cpu_part(),
}
検証結果
こちらの検証結果は個人的にとても興味深いと思っていて、pandasのインポートのような一見シングルプロセスの処理でも、CPUの割り当て待ち時間が発生していることが確認出来ました。この待ち時間は2304MB以上で減少し、それに合わせてインポートにかかる時間も短くなっています。
メモリサイズを大きくすることでInitフェーズでもインポート時間を短くすることが出来ますが、効果が現れるのは2304MB以上で、短縮出来る幅も1割程度です。コスト面を考えると割に合わないという結論になることが多いと思われます。
考察
今回実施した全ての検証で、2048MBまでは処理時間が変わらず、次の測定点である2304MBから処理時間が短くなり始めることが確認出来ました。個人的には1vCPU相当の1769MBが閾値だと予想していたのですが、閾値は予想よりも少し高いメモリサイズにあるようです。
また、マルチプロセスの処理を実行する際などで、Initフェーズでのパフォーマンスが減少する事象を確認することが出来ました。boost host CPU はLambdaの実行環境で動作している全てのプロセスでCPUリソースを共有しているようです。
※ これはLambda Extensionsを利用する際などで、意識しておいた方が良さそうです。
最後に、閾値を超える2304MB以上のメモリサイズを設定した場合は、設定した値に応じてCPUの割り当て量が増加していくことも確認出来、boost host CPU はあくまでも下限値であることが分かりました。
結論
以上、boost host CPU によって、Initフェーズでは2048MBまでメモリサイズに依らず一定のCPUが使えることが分かりました。小さいメモリサイズを設定している場合ほど恩恵が大きく、現在はInitフェーズも課金対象となっていることも合わせて考えると、ベストプラクティスの通り、重い処理は可能な限りInitフェーズに寄せるのが好ましいと考えます。
ただし、そこで使えるCPUは実行環境全体で共有される有限のリソースです。Initフェーズに寄せた処理を更にマルチプロセス化しても、2048MB以下のメモリサイズでは処理時間は短くなりません。この点は意識しておいた方が良さそうです。







