はじめに
BEAM/OTP対話#9でThe Erlang Runtime Systemを3.2節から3.6節まで読んで知ったのですが,process_flag
のmessage_queue_data
をon_heap
(デフォルト)にするかoff_heap
にするかで,メッセージを受け取るときの挙動と性能が変わるという話を聞いて,とても興味を持ったので実験をしてみました。
また,benchee
を使って入力データの種類ごとにベンチマークを集計する方法,ベンチマークごとに前処理する方法についてもこの記事で紹介しています。
この記事はNervesJP Advent Calendar 2021の9日目の記事です。8日目は @torifukukaiou さんのNerves meets SORACOM (Elixir)でした。
BEAM/OTP対話#9で読んだところ
- 3.2. Processes Are Just Memory
- 3.3. The PCB
- 3.4. The Garbage Collector (GC)
- 3.5. Mailboxes and Message Passing
- 3.6. Lock Free Message Passing
この部分は,Erlang VMの内部に迫る内容で,メッセージパッシングを性能向上させるために,いかに安全性を保ったままロック獲得によるオーバーヘッドをなくしていくか,という話で,とても勉強になりました。BEAM/OTP対話#9の動画を公開していますので,ぜひ見てみてください。Erlang VMは,大袈裟ですが,人類が誇るべき財産だと思いました。
ちなみにThe Erlang Runtime Systemの著者Erik Stenmanさんに連絡して,日本語訳をすることの許諾を得ました。近々取り組もうと思っています。翻訳チームに入りたい方,ご連絡ください。
on_heapとoff_heapで何が変わるか?
on_heap
はOTP 19以前からあるようなメッセージ受信の方法です。初期状態ではon_heap
となります。条件が整えば,ヒープに直接メッセージを書き込みますが,他のプロセスと競合しているなど,条件が整わない場合には m-buf というメモリ領域を別途確保してまとめてメッセージを書き込み,「あとで読んでね」と受信する側のプロセスに教えます。条件が整ったときにはコピーが発生しないので,高速になります。そのため1つのプロセスからしか受信をしない場合には有効です。反面,たくさんのプロセスから同時に受信するような場合には性能が落ちるとのことです。
これに対し,off_heap
はOTP 19以降の新しいメッセージ受信の方法です。この戦略だと,状況に関係なく,m-bufというメモリ領域を別途確保してまとめてメッセージを書き込むことにします。多数のプロセスから受信をする場合に有効だとされています。
Elixirでon_heapとoff_heapを切り替えるには?
Erlang VM全体で off_heap
に切り替えるには次のようにします。
elixir --erl "+hmqd off_heap" *.{ex,exs}
なお,*.{ex,exs}
には実行させたいElixirのファイル名が入ります。
Mixを使って Erlang VM 全体を off_heap
に切り替える場合には次のようにします。
elixir --erl "+hmqd off_heap" -S mix
このようにしてErlang VM全体をoff_heap
にしてみて,パフォーマンスが向上するかどうかを確認します。向上しないならば,パフォーマンスボトルネックは他に原因があります。向上するならば,次にボトルネックになっているプロセスを特定してそのプロセスだけを off_heap
にします。ボトルネックを見るには,プロセスごとのメッセージの滞留度合いを見れば良いかと思います。BEAM/OTP対話#8にて解説しました。BEAM/OTP対話#8の動画を公開していますので,是非参照ください。
特定のプロセスをoff_heap
にするには,そのプロセスの中で次の式を実行します。
Process.flag(:message_queue_data, :off_heap)
GenServer
を使っているときには,init
の中でこの式を実行すると良いでしょう。
実験
次のようなコードで実験をしてみました。プログラミングElixir第2版にも書かれている Pmap
(Parallel map)の例です。
defmodule Pmap do
@moduledoc """
Documentation for `Pmap`.
"""
def pmap(collection, func) do
collection
|> Enum.map(&Task.async(fn -> func.(&1) end))
|> Enum.map(&Task.await/1)
end
end
bencheeを使って次のようなベンチマークコードを書いてみます。
map_fun = fn i -> [i, i * i] end
Benchee.run(
%{
"pmap (on_heap)" => {
fn input -> Pmap.pmap(input, map_fun) end,
before_scenario: fn input ->
Process.flag(:message_queue_data, :on_heap)
input
end
},
"pmap (off_heap)" => {
fn input -> Pmap.pmap(input, map_fun) end,
before_scenario: fn input ->
Process.flag(:message_queue_data, :off_heap)
input
end
}
},
inputs: %{
"Small" => Enum.to_list(1..1_000),
"Medium" => Enum.to_list(1..10_000),
"Bigger" => Enum.to_list(1..100_000)
},
memory_time: 2
)
- 入力データ
input
は次の3種類です。- Small(1,000個の要素からなるリスト)
- Medium(10,000個の要素からなるリスト)
- Bigger(100,000個の要素からなるリスト)
- それぞれの入力データに対し,
on_heap
とoff_heap
のそれぞれの場合で性能がどのように変わるのかを検証します。- この切り替えは
before_sceario: fn -> ... end
を使って,ベンチマーク実行の直前で実行するようにしてみました。
- この切り替えは
実験環境
次のような環境で実験しました。
Operating System: macOS
CPU Information: Apple M1
Number of Available Cores: 8
Available memory: 16 GB
Elixir 1.13.0
Erlang 24.1.7
実験結果
得られたベンチマーク結果は次のとおりでした。
##### With input Bigger #####
Name ips average deviation median 99th %
pmap (off_heap) 2.37 421.29 ms ±1.84% 418.69 ms 437.96 ms
pmap (on_heap) 2.31 432.39 ms ±2.12% 431.32 ms 454.44 ms
Comparison:
pmap (off_heap) 2.37
pmap (on_heap) 2.31 - 1.03x slower +11.10 ms
Memory usage statistics:
Name average deviation median 99th %
pmap (off_heap) 66.38 MB ±1.58% 66.92 MB 67.05 MB
pmap (on_heap) 66.93 MB ±0.38% 66.99 MB 67.16 MB
Comparison:
pmap (off_heap) 66.92 MB
pmap (on_heap) 66.93 MB - 1.01x memory usage +0.54 MB
##### With input Medium #####
Name ips average deviation median 99th %
pmap (off_heap) 23.98 41.70 ms ±3.02% 41.56 ms 44.82 ms
pmap (on_heap) 23.85 41.93 ms ±3.77% 42.02 ms 46.16 ms
Comparison:
pmap (off_heap) 23.98
pmap (on_heap) 23.85 - 1.01x slower +0.23 ms
Memory usage statistics:
Name average deviation median 99th %
pmap (off_heap) 6.61 MB ±1.32% 6.63 MB 6.70 MB
pmap (on_heap) 6.57 MB ±1.82% 6.59 MB 6.69 MB
Comparison:
pmap (off_heap) 6.63 MB
pmap (on_heap) 6.57 MB - 0.99x memory usage -0.03901 MB
##### With input Small #####
Name ips average deviation median 99th %
pmap (off_heap) 247.87 4.03 ms ±7.31% 4.04 ms 4.76 ms
pmap (on_heap) 235.61 4.24 ms ±7.01% 4.26 ms 5.08 ms
Comparison:
pmap (off_heap) 247.87
pmap (on_heap) 235.61 - 1.05x slower +0.21 ms
Memory usage statistics:
Name average deviation median 99th %
pmap (off_heap) 650.49 KB ±2.00% 653.55 KB 670.22 KB
pmap (on_heap) 652.15 KB ±1.88% 654.64 KB 672.29 KB
Comparison:
pmap (off_heap) 653.55 KB
pmap (on_heap) 652.15 KB - 1.00x memory usage +1.66 KB
off_heap
の方がいずれも速くなっていますが,若干ぐらいかな,というところです。
考察
今回の実験で用いたプログラムだと,送信側のプロセスの数がとても多いものの,各プロセスの処理時間が短く,かつ送信が1回限りですので,プロセス間の競合が起きにくい状況だと思います。したがって,off_heap
による性能向上の効果があまり無かったものと思います。
逆に言えば,もし,各プロセスが長い間にわたって生存し,送信をたびたび行うようなものであれば,off_heap
の効果は大きくなるんじゃないかと思います。今後,実験していきたいと思います。
おわりに
そういうわけで,ひとまず Elixir において,Erlang VM全体と特定のプロセスそれぞれについて,OTP 19以降でサポートされた off_heap
に設定する方法がわかったので,満足とします。また,ついでにbenchee
を使って入力データの種類ごとにベンチマークを集計する方法,ベンチマークごとに前処理する方法がわかったこともよかったです。
明日は @the_haigo さんのNerves で GPS Loggerを作ってみたです。お楽しみに。