本日は
- Julia アドベンドカレンダー ネタとして投稿しています.
- Julia パッケージの一つである PackageCompiler.jl を使うと Julia のコードを予めコンパイルしておいてアプリを実行形式にすることができます.では?shared library として固めておくことは可能でしょうか?
- 2020/12月現在ではまだ experimental な段階です.実は https://github.com/simonbyrne/libcg のリポジトリが PoC として作っています.私もそれを真似てみましたので知見を共有したいと思います.
準備
- 今回は https://github.com/terasakisatoshi/CallJ.jl を用いて説明します.
- 環境は Julia, git, make が利用可能・インストールされていることを前提としています.ある程度 C のコードのコンパイル方法がわかっていると理解が進みやすいです.
- PackageCompiler の sysimage についての知識を仕入れる
- https://julialang.github.io/PackageCompiler.jl/dev/devdocs/sysimages_part_1/
使い方
- 説明を見るのがめんどくさい読者のためにたった3行で動作を確認できる方法を書いておきました.
$ git clone https://github.com/terasakisatoshi/CallJ.jl.git
$ cd CallJ.jl
$ make
下記のような出力結果が出れば無事一通りの動作ができたことになります.
./main_double
Hello World!
typeof(x) = Float64
f(2,3,4)=9.000000
show initial Array x
x[0]=0.000000
x[1]=1.000000
x[2]=2.000000
x[3]=3.000000
x[4]=4.000000
x[5]=5.000000
calc max of Array x
jlmax(x)=5.000000
apply jlminus to Array x
calc min of Array x
jlmin(jlminus(x))=-5.000000
x[0]=-0.000000
x[1]=-1.000000
x[2]=-2.000000
x[3]=-3.000000
x[4]=-4.000000
x[5]=-5.000000
apply jlreverse to Array x
x[0]=-5.000000
x[1]=-4.000000
x[2]=-3.000000
x[3]=-2.000000
x[4]=-1.000000
x[5]=-0.000000
何が起こっているかみていく
ディレクトリ・ファイル構造
リポジトリのファイル構造は大まかに下記のようになっています.
$ tree
.
├── Dockerfile
├── Makefile
├── Project.toml
├── README.md
├── builder
│ ├── Project.toml
│ ├── compile.jl
│ └── generate_code.jl
├── jlinit.c
├── rustrun.rs
├── src
│ └── CallJ.jl
└── test
└── runtests.jl
-
src/CallJ.jl
はCallJ
という名前で名付けられた Julia パッケージのコードが格納されています.test
ディレクトリは CallJ パッケージのテストコードが格納されています.パッケージのテストは次のようにして行うことができます.ここら辺の話は Julia のパッケージの一般論です.
$ pwd
CallJ.jl
$ julia --startup-file=no --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.test()'
builder/compile.jl について
- CallJ.jl リポジトリ直下に
builder
というディレクトリがありますね.これは PackageCompiler.jl の機能を用いた CallJ.jl パッケージが提供する関数たちを sysimage に焼く(追加する)ためのスクリプトになります.
using PackageCompiler, Libdl
PackageCompiler.create_sysimage(
Symbol[:CallJ];
project=pwd(),
precompile_execution_file=[joinpath("test", "runtests.jl")],
sysimage_path="libcallj.$(Libdl.dlext)",
#incremental=false, filter_stdlibs=true,
)
CallJ.jl パッケージが提供する関数
を sysimage に焼くためにそれらの関数を実行してどのような入力の型で JITコンパイル されたかを記録する必要があります.その記録の方法として CallJ.jl のテストコードを走らせてJITコンパイルをさせます.記録の方法は PackageCompiler がよしなにしてくれます.
# この部分
precompile_execution_file=[joinpath("test", "runtests.jl")]
コンパイルの仕方は次のようにします.
$ cd <CallJ.jl のリポジトリ>
$ julia --startup-file=no --project=builder builder/compile.jl
これによって libcallj.<OS 依存の拡張子>
というファイルが出来上がります.ビルドに時間がかかりますのでコーヒーでも飲んで待ちましょう.
C 側から呼び出すコードを書いていく.
このシステムイメージのなかに Julia の関数の実体を入れることができました.これをCのライブラリとみなして呼び出すコードを作っていきましょう.下記を実行します.
$ julia --project=builder builder/generate_code.jl
そうすると main_double.c
や callj_double.h
という C のコードとヘッダーファイルが出来上がります.
#include <stdio.h>
#include <stdlib.h>
#include <uv.h>
#include <julia.h>
#include "callj_double.h"
JULIA_DEFINE_FAST_TLS()
int main(int argc, char *argv[])
{
// initialization of libuv and julia
uv_setup_args(argc, argv);
libsupport_init();
jl_parse_opts(&argc, &argv);
// JULIAC_PROGRAM_LIBNAME defined on command-line for compilation
jl_options.image_file = JULIAC_PROGRAM_LIBNAME;
julia_init(JL_IMAGE_JULIA_HOME);
greet();
double myvalue = 1;
gettype(myvalue);
printf("f(2,3,4)=%lf\n", f(2,3,4));
size_t len = 6;
double *x = (double *)malloc(len * sizeof(double));
for (int i = 0; i < len; i++)
{
x[i] = (double)i;
}
printf("show initial Array x\n");
for (int i = 0; i < len; i++)
{
printf("x[%d]=%lf\n", i, x[i]);
}
printf("calc max of Array x\n");
printf("jlmax(x)=%lf\n", jlmax(x, len));
printf("apply jlminus to Array x\n");
jlminus(x, len);
printf("calc min of Array x\n");
printf("jlmin(jlminus(x))=%lf\n", jlmin(x, len));
for (int i = 0; i < len; i++)
{
printf("x[%d]=%lf\n", i, x[i]);
}
printf("apply jlreverse to Array x\n");
jlreverse(x, len);
for (int i = 0; i < len; i++)
{
printf("x[%d]=%lf\n", i, x[i]);
}
free(x);
int ret = 0;
jl_atexit_hook(ret);
return ret;
}
上のコードにある
// initialization of libuv and julia
uv_setup_args(argc, argv);
libsupport_init();
jl_parse_opts(&argc, &argv);
// JULIAC_PROGRAM_LIBNAME defined on command-line for compilation
jl_options.image_file = JULIAC_PROGRAM_LIBNAME;
julia_init(JL_IMAGE_JULIA_HOME);
は Julia の環境を起動するために使います.JULIAC_PROGRAM_LIBNAME
はあとでコンパイルオプションで先ほど作成した sys image libcallj
の名前を指定します.
callj_double.h
というヘッダーの関数たちは src/CallJ.jl
の中で定義されている関数と対応しています.
// Julia headers (for initialization and gc commands)
#include "uv.h"
#include "julia.h"
// prototype of the C entry points in our application
void greet(void);
void gettype(double x);
double f(double x, double y, double z);
double jlmax(double *cx, size_t len);
double jlmin(double *cx, size_t len);
int jlminus(double *cx, size_t len);
int jlreverse(double *cx, size_t len);
たとえば配列を逆に並べる reverse!
という Julia の関数をラップした jlreverse
は次のようになっています.
TYPES=[Cdouble]
# ...
# 中略
# ...
for T in TYPES
@eval Base.@ccallable function jlreverse(cx::Ptr{$T}, len::Csize_t)::Cint
x = unsafe_wrap(Array, cx, (len,))
reverse!(x)
return 0
end
end
ぱっと見複雑なことをしていますがこれは下記と同じです.
Base.@ccallable function jlreverse(cx::Ptr{Cdouble}, len::Csize_t)::Cint
x = unsafe_wrap(Array, cx, (len,))
reverse!(x)
end
TYPES
をいろいろ変えるとわかるのですが,今の時点では C から呼べるのはどうやら最後に評価された関数っぽいんですよね.multiple-dispatch は使えない? ?
ちょっとこの話は置いておいて用意したCのコードのオブジェクトファイルを作ります.
下記のは Makefile から引用した物です. ${...}
と書かれたものの定義は Makefile の中身を参照してください.たとえばこの文脈では ${MAIN}
は main
と読み替えてください.
${MAIN}_double.o: ${MAIN}_double.c
$(CC) $< -c -o $@ $(CFLAGS) -DJULIAC_PROGRAM_LIBNAME=\"lib${LIBNAME}.$(DLEXT)\"
このオブジェクトファイルと libcallj
のライブラリを用いてリンクする作業に入ります.
$(MAIN)_double: ${MAIN}_double.o lib${LIBNAME}.$(DLEXT)
$(CC) -o $@ $< $(LDFLAGS) -l${LIBNAME}
これで main_double.c の main 関数を起点として C から Julia のコードを実行する実行ファイルが作成されます.
やったね.たえちゃん.Julia を C から呼べたよ.
Appendix
ということは C を呼べる言語からこのテクニックを使って他言語から呼べるんでは? と遊んでみたらできました.Mac だとビルドできましたっていう程度です.
rustrun.rs
というファイルを作ります.
cat rustrun.rs
//extern crate libc;
//use libc::c_char;
//use libc::c_int;
//use std::ffi::CString;
#[link(name="callj", kind="dylib")]
extern{
fn greet();
}
#[link(name="jlinit", kind="dylib")]
extern{
//fn jlinit(argc: c_int, argv: *const *const c_char);
fn jlinit();
}
fn main() {
// create a vector of zero terminated strings
//let args = std::env::args().map(|arg| CString::new(arg).unwrap() ).collect::<Vec<CString>>();
// convert the strings to raw pointers
//let c_args = args.iter().map(|arg| arg.as_ptr()).collect::<Vec<*const c_char>>();
//unsafe {jlinit(c_args.len() as c_int, c_args.as_ptr()); greet();};
unsafe {jlinit(); greet();};
}
- Rust がインストールされていれば
make rustrun
によって Rust の rustc コマンドでコンパイルして Rust から利用できるようにしています.本当は Cargo とかでやりたいですよね・・・しゅいませんどなたかよろしくお願いします. - あと普通は cargo でプロジェクトを作ってビルドするべきなんですが
rustc -C ...
ように link-args を渡す方法がちょっとわからなかったのもあります.ちゃんとするんであれば libc クレートを使ってクレメンス.
また,関数を呼ぶ前に下記のような Julia のセットアップコードを呼び出さないとセグフォするので気をつけてください.
void jlinit(void){
int argc=1;
char **argv;
// initialization of libuv and julia
uv_setup_args(argc, argv);
libsupport_init();
jl_parse_opts(&argc, &argv);
// JULIAC_PROGRAM_LIBNAME defined on command-line for compilation
jl_options.image_file = JULIAC_PROGRAM_LIBNAME;
julia_init(JL_IMAGE_JULIA_HOME);
}
まとめ
- PackageCompiler を使って sysimage に自作の関数を追加して C から呼び出すロジックを作りました.まだまだ experimental なところがありますが, 研究段階では JIT コンパイル方式で動的言語の利点である生産性の高さを出しつつ,プロダクションで使うときは PackageCompiler による AOT コンパイルで配布するという未来が期待できそうですね.