日本語でHiPEについて触れている文書が少ない気がしたので、自分が知っている範囲の情報を取り留めもなく書いてみた。
※ 経験ベースで書いているため情報の網羅性や信頼性にはあまり期待しないでください
HiPEって何?
「High Performance Erlang」の略。
HiPEコンパイラはOTPが提供する標準アプリケーションの中に含まれている(OTP17.3現在)。
HiPEコンパイラを使うと、ErlangのソースコードをVM(beam)のバイトコードとしてではなく、ネイティブコードとしてコンパイルしてくれる。
(JavaのJITコンパイラのように、実行時にネイティブコードへの変換を行なうわけではない)
そのため、速度を重視したい(けどCで書く程ではない)モジュールを実装する場合に、HiPEが手軽な高速化の手段として重宝することがある。
HiPEを有効にするには
ソースコードから自前でErlang/OTPをビルドする場合にはconfigureオプションに--enable-hipe
を指定する。
# ソースコードの取得
$ wget http://www.erlang.org/download/otp_src_17.3.tar.gz
$ tar zxf otp_src_17.3.tar.gz
$ cd otp_src_17.3
# '--enable-hipe'を指定してビルド&インストール
$ ./configure --enable-hipe ...他のオプションは省略...
$ make
$ sudo make install
# 起動: 起動メッセージに'[hipe]'という文言が含まれていたらHiPEが有効(利用可能)になっている
$ erl
Erlang/OTP 17 [erts-6.2] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false]
Eshell V6.2 (abort with ^G)
1> q().
Erlang SolutionやOS標準のパッケージマネージャが提供するビルド済みパッケージを使用する場合は、HiPEが有効になっているパッケージを選択すること。
HiPEコンパイルの方法とコンパイルオプション
基本的なHiPEコンパイル
native
オプションをコンパイル時に指定すれば良い。
%% サンプルモジュール
-module(hoge).
-export([hoge/0]).
hoge() -> hoge.
実行例:
%% HiPEなし
> c(hoge).
{ok, hoge}.
> code:is_module_native(hoge). % code:is_module_native/1で判定可能
false
> hoge:hoge().
hoge
%% HiPEあり
> c(hoge, [native]).
{ok, hoge}.
> code:is_module_native(hoge).
true
> hoge:hoge().
hoge
最適化オプション等
コンパイル時にhipe
オプションを指定することで最適化の度合いを制御することも可能。
%% 'o0'から'o3'まで最適化レベルを指定可能 (それぞれの詳細は後述)
> c(hoge, [native, {hipe, [o0]}]). % 最適化なし
> c(hoge, [native, {hipe, [o1]}]).
> c(hoge, [native, {hipe, [o2]}]). % デフォルト
> c(hoge, [native, {hipe, [o3]}]). % 最高レベル
またOTP17.0からはLLVM(ErLLVM?)がHiPEのバックエンドとしてサポートされたのでLLVMがマシンにインストールされていれば、そちらもオプションで利用可能。
> c(hoge, [native, {hipe, [to_llvm]}]). % 'to_llvm'オプションを指定する
ErLLVMの参考資料:
ついでにHiPE自体の話(若干古い?):
hipeモジュール
通常のコンパイル関数経由ではなくhipe
モジュールを直接扱うことも可能。
※ ただし直接扱うことはほとんどないので、この節は読み飛ばし推奨
OTP17.3でhipeモジュールは、以下の関数群を提供している:
> hipe:module_info(exports).
[{load,1},
{c,1},
{c,2},
{f,1},
{f,2},
{compile,1},
{compile,2},
{compile_core,4},
{file,1},
{file,2},
{version,0},
{help,0},
{help_hiper,0},
{help_options,0},
{help_option,1},
{help_debug_options,0},
{llvm_support_available,0},
{module_info,0},
{module_info,1},
{compile,4}]
使用例1) ヘルプ表示
hipe:help/0
やhipe:help_options/0
を呼び出すことで、各関数やオプションの詳細を見ることができる。
%%
%% 全体ヘルプ
%%
> hipe:help().
The HiPE Compiler (Version 3.11)
The normal way to native-compile Erlang code using HiPE is to
include `native' in the Erlang compiler options, as in:
1> c(my_module, [native]).
Options to the HiPE compiler must then be passed as follows:
1> c(my_module, [native,{hipe,Options}]).
Use `help_options()' for details.
Utility functions:
help()
Prints this message.
help_options()
Prints a description of options recognized by the
HiPE compiler.
help_option(Option)
Prints a description of that option.
help_debug_options()
Prints a description of debug options.
version() ->
Returns the HiPE version as a string'.
For HiPE developers only:
Use `help_hiper()' for information about HiPE's low-level interface
ok
%%
%% HiPE用のコンパイルオプションの説明
%%
> hipe:help_options().
HiPE Compiler Options
Boolean-valued options generally have corresponding aliases `no_...',
and can also be specified as `{Option, true}' or `{Option, false}.
General boolean options:
[debug,load,pp_asm,pp_beam,pp_icode,pp_native,pp_rtl,time,timeout,verbose].
Non-boolean options:
o#, where 0 =< # =< 3:
Select optimization level (the default is 2).
Further options can be found below; use `hipe:help_option(Name)' for details.
Aliases:
pp_all = [pp_beam,pp_icode,pp_rtl,pp_native],
pp_sparc = pp_native,
pp_x86 = pp_native,
pp_amd64 = pp_native,
pp_ppc = pp_native,
o0, % o0〜o3までの最適化オプションの実態もここで分かる
o1 = [inline_fp,pmatch,peephole],
o2 = [icode_range,icode_ssa_const_prop,icode_ssa_copy_prop,icode_type,
icode_inline_bifs,rtl_lcm,rtl_ssa,rtl_ssa_const_prop,spillmin_color,
use_indexing,remove_comments,concurrent_comp,binary_opt] ++ o1,
o3 = [{regalloc,coalescing},icode_range] ++ o2.
ok
%%
%% 個別オプションの詳細表示
%%
> hipe:help_option(icode_range).
icode_range - Performs integer range analysis on the Icode level
ok
> hipe:help_option(to_llvm).
This is an alias for: [to_llvm,{llvm_opt,o3},{llvm_llc,o3}].
ok
使用例2) ソースファイルがなくてもコンパイル可能
上に記載の資料が詳しいが、HiPEコンパイラはソースファイル(.erl)からではなく、バイトコード(.beam)を解析してネイティブコードを生成している模様。
そのため、hipeモジュールを直接使えば、ソースファイルがなくてもHiPEコンパイルが行える。
(実際に使いたいケースはなさそうだけど...)
%% まずバイトコードコンパイル
> c(hoge).
{ok, hoge}.
%% 下記コマンドを別のシェルで実行
%% $ mv hoge.erl h.erl
> ls("hoge.beam").
hoge.beam
ok
> ls("hoge.erl").
no such file or directory
ok
%% hipeモジュールを直接使ってHiPEコンパイル
> hipe:c(hoge).
{ok, hoge}. % beamファイルだけでコンパイルが可能
%% 通常のコンパイル関数経由でHiPEコンパイル
> c(hoge, [native]).
hoge.erl: no such file or directory
error % ソースファイルがないと怒られる
速度はどう変わるか
HiPEコンパイルすると、処理速度がどの程度変わるのかを、いくつかのケースで簡単に計測してみた。
実行環境や計測条件によって、結果はだいぶ変わると思うので、あくまでも参考程度に。
実行環境
- OS: Ubuntu-14.04 (on VMware Player)
- CPU: Intel(R) Core(TM) i7-4600U CPU @ 2.10GHz (x 4)
- memory: 4GB
- Erlang: OTP-17.3
フィボナッチ数
まずはHiPEの効果が出やすそうな題材として、(数値計算が主 and ループ回数が多い)フィボナッチ数を求めるコードを取り上げてみる。
-module(fib).
-export([fib/1]).
-spec fib(non_neg_integer()) -> non_neg_integer().
fib(N) when N < 2 ->
1;
fib(N) ->
fib(N - 2) + fib(N - 1).
実行結果:
※ なお、以下に記載の処理時間は、計測関数を三回実行して、その中央値を採用している (以降も同様)
%% 処理時間計測用の補助関数を準備
> Time = fun (Fun) -> {MicroSeconds, _} = timer:tc(Fun), MicroSeconds / (1000 * 1000) end.
#Fun<erl_eval.6.90072148>
%% N=40の場合のフィボナッチ数を求めるのに掛かる時間を計測する
> fib:fib(40).
165580141
%% HiPEなし
> c(fib).
> Time(fun () -> fib:fib(40) end).
5.325893 % 5.32秒
%% HiPEあり: デフォルトオプション
> c(fib, [native]).
> Time(fun () -> fib:fib(40) end).
1.167685 % 1.16秒
%% HiPEあり: 最適化レベル最高
> c(fib, [native, {hipe, [o3]}]).
> Time(fun () -> fib:fib(40) end).
1.166181 % 1.16秒 (今回のケースでは、デフォルト(o2)とほとんど差異なし)
%% HiPEあり: LLVMバックエンド
> c(fib, [native, {hipe, [to_llvm]}]).
> Time(fun () -> fib:fib(40) end).
1.076048 % 1.07秒
フィボナッチ数の場合は、HiPEのon/offで大幅に処理速度に差が出ていた(五倍程度)。
HiPEのコンパイルオプションを調整しても、今回のケースではあまり大きな差は見られなかった。
ただし、LLVMをバックエンドにした場合の方が、若干ではあるがコンスタントに性能が良かった。
ちなみに、C言語で実装した場合の処理時間も参考までに載せておく。
#include <stdio.h>
#include <stdlib.h>
int fib(int n) {
if (n < 2) {
return 1;
}
return fib(n - 2) + fib(n - 1);
}
int main(int argc, char ** argv) {
int n = atoi(argv[1]);
printf("%d: %d\n", n, fib(n));
return 0;
}
実行結果:
# 最適化なし
$ gcc -o fib fib.c
$ time ./fib 40
40: 165580141
real 0m0.678s
user 0m0.676s
sys 0m0.000s
# 最適化あり
$ gcc -O3 -o fib fib.c
$ time ./fib 40
40: 165580141
real 0m0.287s
user 0m0.286s
sys 0m0.000s
やっぱり単純な数値計算だと、C言語に比べると(HiPE付きでも)だいぶ遅い。
echoサーバ
次はHiPEがあまり効果的ではないと思われる例。
プロセス間のメッセージパッシングが主となるechoサーバの処理時間を計測してみる。
%% かなり適当な実装のechoサーバプロセス
-module(echo).
-export([start/0, call/2, call_n/3]). % external functions
-export([loop/0]). % internal functions
%% @doc echoサーバを起動する
-spec start() -> {ok, pid()}.
start() ->
{ok, spawn(?MODULE, loop, [])}.
%% @doc echoサーバにリクエストを投げる
-spec call(Server::pid(), Request::term()) -> Response::term().
call(Server, Request) ->
Server ! {self(), Request},
receive
Response -> Response
end.
%% @doc echoサーバに`Count'回だけリクエストを投げる
-spec call_n(Server::pid(), Request::term(), Count::non_neg_integer()) -> ok.
call_n(_Server, _Request, 0) ->
ok;
call_n(Server, Request, Count) ->
Request = call(Server, Request),
call_n(Server, Request, Count - 1).
-spec loop() -> no_return().
loop() ->
receive
{From, Request} ->
From ! Request, % 送られてきたリクエストをそのまま返す
?MODULE:loop()
end.
実行結果:
%% HiPEなし
> c(echo).
> {ok, Pid} = echo:start(). % 起動
> echo:call(Pid, hello). % 動作確認
hello
> Time(fun () -> echo:call_n(Pid, hello, 1000000) end). % 100万回のリクエストを処理するまでに掛かる時間
0.746382 % 0.74秒
> exit(Pid, kill), f(Pid). % 後始末
%% HiPEあり
> c(echo, [native]).
> {ok, Pid} = echo:start(). % 起動
> Time(fun () -> echo:call_n(Pid, hello, 1000000) end). % 100万回のリクエストを処理するまでに掛かる時間
0.739132 % 0.73秒
> exit(Pid, kill), f(Pid). % 後始末
予想通りechoサーバの場合はHiPEの有無で、ほとんど結果に差がでなかった。
より現実的な例での効果測定
ここまでHiPEの効果を見るために、作為的な例を二つ取り上げたが、最後にもう少し現実的な例を載せておくことにする。
具体的には、Key-Valueマップ的な用途向けに標準で提供されているgb_treesモジュールの処理速度が、HiPEの有無によってどのように変わるのかを簡単に見てみる。
(なお、gb_treesやそれに類似したデータ構造の性能についてより詳しく知りたい場合は、こっちの記事にもう少し掘り下げた測定結果が掲載されている)
まずはベンチマーク用の補助モジュールを定義:
%% 一応、gb_trees以外にも使えるようになっている
-module(bench).
-export([do_bench/5]).
%% @doc マップ(的なデータ構造)の構築時間と検索時間を測定する
do_bench(MapInit, StoreFun, FindFun, Input, Keys) ->
erlang:garbage_collect(),
{StoreTime, Map} =
timer:tc(fun () -> store_loop(MapInit, StoreFun, Input) end),
erlang:garbage_collect(),
{FindTime, _} =
timer:tc(fun () -> find_loop(Map, FindFun, Keys) end),
[
{store_time, StoreTime / (1000 * 1000)},
{find_time, FindTime / (1000 * 1000)}
].
store_loop(Map, _, []) ->
Map;
store_loop(Map, StoreFun, [{K, V} | List]) ->
store_loop(StoreFun(K, V, Map), StoreFun, List).
find_loop(_, _, []) ->
ok;
find_loop(Map, FindFun, [K | List]) ->
FindFun(K, Map),
find_loop(Map, FindFun, List).
実行結果:
%% 下準備:
%% $ cp ${OTP_17_3_SOURCE}/lib/stdlib/src/gb_trees.erl .
> code:unstick_mod(gb_trees). % 標準モジュールを上書き可能にする
true
> c(bench, [native]).
{ok, bench}.
> Input = [{random:uniform(1000000), N} || N <- lists:seq(1, 200000)]. % 入力データを20万個用意する
> Keys = [K || {K, _} <- Input]. % 検索に使用するキーセット (成功探索のみ)
%% HiPEなし
> c(gb_trees).
> bench:do_bench(gb_trees:empty(), fun gb_trees:enter/3, fun gb_trees:lookup/2, Input, Keys).
[{store_time,0.489443}, % 構築時間: 0.48秒
{find_time, 0.169788}] % 検索時間: 0.16秒
%% HiPEあり
> c(gb_trees, [native]).
> bench:do_bench(gb_trees:empty(), fun gb_trees:enter/3, fun gb_trees:lookup/2, Input, Keys).
[{store_time,0.29301}, % 構築時間: 0.29秒
{find_time,0.107849}] % 検索時間: 0.10秒
フィボナッチ数の例ほどは劇的ではないが、gb_trees
のようなデータ構造実装モジュールでも、HiPEを有効にすることで数割程度は処理時間が短縮されていた。
ちなみにErlangをソースからビルドする際に--enable-native-libs
をconfigureオプションに渡しておけば、gb_trees
を含む大半の(?)標準モジュールがビルド時にHiPEコンパイルされるようになる。
(ただし、後述するようにHiPEにはデメリットも結構あるので、不必要なモジュールまでHiPEコンパイルしてしまうのは、あまり好ましくはないかもしれない)
感想
計算処理が重そうなモジュールならHiPEの効果はそれなりに期待できそう
HiPEを使った場合の可搬性
HiPEコンパイルを行なうとネイティブコードが生成されるので、VMのバイトコードの場合に比べると可搬性は損なわれる。
ただしHiPEコンパイルを行った場合でも、beamファイルの中にはオリジナル(?)のバイトコードも保持されているようで、最低限実行するだけなら、環境が変わっても問題はない。
以下は、Ubuntu-14.10+OTP17.3
環境でHiPEコンパイルしたモジュールを、CentOS-6.4+R16B03
環境で動かした場合の挙動:
$ erl
Erlang R16B03 (erts-5.10.4) [source] [64-bit] [smp:2:2] [async-threads:10] [hipe] [kernel-poll:false]
Eshell V5.10.4 (abort with ^G)
%% 上でHiPEコンパイルされていたfibモジュールをロードする
1> l(fib).
{module,fib}
%% HiPEの互換性が無い、というエラーメッセージが表示される
=INFO REPORT==== 2-Dec-2014::02:44:44 ===
<HiPE (v 3.10.2.2)> Warning: not loading native code for module fib: it was compiled for an incompatible runtime system; please regenerate native code for this runtime system
2> code:is_module_native(fib).
false % HiPEは有効になっていない
3> fib:fib(10).
89 % 実行することは可能
当然、単に動作するだけで、HiPEによって期待していた性能向上は見込めない。
HiPEのデメリット
これまでで、HiPEによって性能が向上する例の紹介と、HiPEを有効にしても可搬性が致命的に損なわれることがない、という話をしてきた。
それなら、全てのモジュールを無条件にHiPEでコンパイルしておけば良いのかといえば、そういうものでもなく、HiPEには結構いろいろなデメリットもある。
ここでは、自分が遭遇したことのあるHiPEの問題点について、書いておくことにする。
コンパイルスピードが遅い
対象となるモジュールにもよると思うが、HiPEなしのコンパイルに比べて、だいたい数倍程度の時間がかかる印象。
# OTP17-3のgb_treesモジュールの例
# 行数
$ wc -l gb_trees.erl
553 gb_trees.erl
# erlcコマンド自体の実行時間
$ time erlc
real 0m0.190s
user 0m0.023s
sys 0m0.239s
# HiPEなしのコンパイル時間
$ time erlc gb_trees.erl
real 0m0.419s % 0.419 - 0.190 = 0.228s
user 0m0.108s
sys 0m0.483s
# HiPEありのコンパイル時間
real 0m0.907s % 0.907 - 0.190 = 0.717s
user 0m0.495s
sys 0m1.065s
上のgb_trees
の例では、HiPEありの場合、三倍程度コンパイル時間が長くなっている。
beamファイルのサイズが大きい
同じくgb_trees
を例に取り上げる:
# HiPEなし
$ erlc gb_trees.erl
$ du -h gb_trees.beam
8.0K gb_trees.beam
# HiPEあり
$ erlc +native gb_trees.erl
$ du -h gb_trees.beam
28K gb_trees.beam
この例では両者のサイズに3.5倍の差がある。
NIFとの併用が不可
HiPEとNIF(Native Implemented Function)の併用は不可能らしい (これはそもそもその需要がなさそう)。
以下、NIFを使っているcrypto
モジュールでHiPEコンパイルを試した例:
%% dictなら成功
> code:unstick_mod(dict).
> l(dict).
> hipe:c(dict).
{ok, dict}.
%% cryptoなら失敗
> l(crypto).
> hipe:c(crypto).
<HiPE (v 3.11)> EXITED with reason {'trans_fun/2',on_load} @hipe_beam_to_icode:1174
=ERROR REPORT==== 2-Dec-2014::04:02:45 ===
Error in process <0.120.0> with exit value: {{badmatch,{'EXIT',{{hipe_beam_to_icode,1174,{'trans_fun/2',on_load}},[{hipe_beam_to_icode,trans_fun,2,[]},{hipe_beam_to_icode,trans_fun,2,[]},{hipe_beam_to_icode,trans_mfa_code,5,[]},{hipe_beam_to_icode,trans_beam_function_chunk...
** exception exit: {{badmatch,{'EXIT',{{hipe_beam_to_icode,1174,
{'trans_fun/2',on_load}},
[{hipe_beam_to_icode,trans_fun,2,[]},
{hipe_beam_to_icode,trans_fun,2,[]},
{hipe_beam_to_icode,trans_mfa_code,5,[]},
{hipe_beam_to_icode,trans_beam_function_chunk,2,[]},
{hipe_beam_to_icode,'-module/2-lc$^1/1-1-',2,[]},
{hipe_beam_to_icode,'-module/2-lc$^1/1-1-',2,[]},
{hipe,get_beam_icode,4,[]},
{hipe,'-run_compiler_1/3-fun-0-',4,[]}]}}},
[{hipe,get_beam_icode,4,[]},
{hipe,'-run_compiler_1/3-fun-0-',4,[]},
{erl_pp,attribute,2,[]}]}
in function hipe:run_compiler_1/3
in call from hipe:run_compiler/4
in call from hipe:c/3
...以下略...
一部のエラー情報が欠落する
HiPEでコンパイルされたモジュールの実行中に例外が発生した場合に、行番号等の情報がスタックトレースから欠落してしまう。
まず動作確認用モジュールの定義:
-module(fib).
-export([fib/1]).
fib(N) when not is_integer(N) ->
error(badarg, [N]); % 引数の型が不正ならbadarg例外を投げるように変更
fib(N) when N < 2 ->
1;
fib(N) ->
fib(N - 2) + fib(N - 1).
-module(fib2).
-export([fib2/1]).
fib2(N) -> fib:fib(N) + fib:fib(N).
実行例:
> c(fib).
> c(fib2).
> fib:fib(10).
89
> fib2:fib2(10).
178
%% 例外発生: HiPEなし
> c(fib).
> try fib2:fib2(xxx) catch error:badarg -> erlang:get_stacktrace() end.
[{fib,fib,[xxx], % 例外送出元関数の引数の情報が載る
[{file,"fib.erl"},{line,8}]}, % 行番号もつく
{fib2,fib2,1,[{file,"fib2.erl"},{line,6}]},
{erl_eval,do_apply,6,[{file,"erl_eval.erl"},{line,661}]},
{erl_eval,try_clauses,8,[{file,"erl_eval.erl"},{line,891}]}]
%% 例外発生: HiPEあり
> c(fib, [native]).
[{fib,fib,1,[]}, % 引数の数(arity)だけしか情報がない
{erl_pp,attribute,2,[]}, % fib2:fib2/1を経由しているという情報も消えている
{shell,exprs,7,[]},
{shell,eval_exprs,7,[]},
{shell,eval_loop,3,[]},
{erl_pp,attribute,2,[]}]
HiPEコンパイルすると例外発生時に結構いろいろな情報が欠落するので、デバッグに苦労した覚えがある。
(最初は、そもそも何故、情報が消えているのかが分からなかった)
個人的には、これは割合大きなデメリット。
トレース機能が使えない
Erlang/OTPが組み込みで提供している関数のトレース機能は、HiPEコンパイルされたモジュールは対象外の模様。(おそらく。検証不十分)
以下、Erlangのトレース機能を利用しているreconというライブラリを用いた例:
%% fibモジュールをHiPEなしでコンパイル
> c(fib).
{ok,fib}
%% recon_traceを使ってトレース
> recon_trace:calls({fib, fib, fun (_) -> return_trace() end}, 10).
1 % トレース対象の関数の数
> fib:fib(10).
89
4:46:10.951606 <0.33.0> fib:fib(10) % トレース結果が表示される
4:46:10.957504 <0.33.0> fib:fib/1 --> 89
%% fibモジュールをHiPEありでコンパイル
> c(fib, [native]).
{ok,fib}.
%% recon_traceを使ってトレース
> recon_trace:calls({fib, fib, fun (_) -> return_trace() end}, 10).
0 % トレース対象が存在しない
> fib:fib(10).
89 % 実行しても特に何も表示されない
トレース機能は、問題発生時の状況追跡の際に有用なので、これが無効になるのは地味に痛い。
2015/06/15追記: コードリロード時のメモリリーク
Erlang in Angerの7.1.3節によるとHiPEコンパイルされたコードの領域は、新しいバージョンのコードがリロードされても破棄(GC)されないとのこと。
(通常は、二世代以上前のコード領域はGCによって回収される)
まとめ
とりあえず、現時点で把握してる情報はここまで。
HiPEにはいろいろと気を付けなければいけない点もあるので、アプリケーションやライブラリ全体をHiPEでコンパイルするのではなく、最適化が必要な一部のモジュールだけを対象とするのが個人的にはお勧め。
(取り上げた問題の中でもエラー情報の欠落は、デバッグが困難になり結構キツイものがあるので、HiPEの対象となるモジュールは例外が送出されないように注意して実装するのが良さそう。例えばjsoneはHiPEのために、その辺りのハンドリングを無駄に頑張っている)
HiPEを使っているという話はあまり耳にしない気もするけど、適用対象を間違えなければ意外と効果があるので、データ構造の実装とか最適化とかが好きな人はぜひ試してみると良いのではないかと思う。