JNIを使ってJavaからCとRustを呼び出してみた
JNIとは
JNIとはJavaからネイティブコードを呼び出すための機能です。例えばC言語であればgccでコンパイルするときにsharedのオプションを有効にすることで共有ライブラリが作れるので、Java側ではSystem.loadLibraryやSystem.loadで共有ライブラリを読み込むと呼び出せるようになれます。
計算量の多い部分をネイティブコードに置き換えることで高速化を狙えるらしいのですが、メモリ管理や排他制御などに気を付けないと不具合や低速化を招くので使うポイントはちゃんと考える必要があるようです。
今回はC言語とRustをコンパイルして共有ライブラリを出力してJavaから呼び出して、実行時間の比較を行いたいと思います。
JavaからC言語、Rusutのフィボナッチ数列の関数を呼び出して実行速度を図りたいと思います。
Java
まずJavaの実装は以下のようになりました。
□Bench.java
import java.nio.file.Path; import java.nio.file.Paths; public class Bench { static { System.loadLibrary("fib_c"); Path p = Paths.get("bench_r/target/debug/libhpa.so"); System.load(p.toAbsolutePath().toString()); } // nativeメソッドの宣言 public static native long fibc(int i); public static native long fibr(int i); long[] memo_j = new long[300]; public static void main(String[] args) { Bench bench = new Bench(); bench.run(); } public void run() { run_j(); run_c(); run_r(); } public void run_j() { System.out.println("java fib start"); long start = System.nanoTime(); System.out.println("fib(90) = " + fib_j(90)); long end = System.nanoTime(); System.out.println("java fib end"); System.out.println("java time:" + (end - start) + "nano"); } public void run_c() { System.out.println("clang fib start"); long start = System.nanoTime(); System.out.println("fib(90) = " + fibc(90)); long end = System.nanoTime(); System.out.println("clang fib end"); System.out.println("clang time:" + (end - start) + "nano"); } public void run_r() { System.out.println("rust fib start"); long start = System.nanoTime(); System.out.println("fib(90) = " + fibr(90)); long end = System.nanoTime(); System.out.println("rust fib end"); System.out.println("rust time:" + (end - start) + "nano"); } public long fib_j(int i) { if(i == 0) {return 1;} else if(i == 1) {return 1;} else { if(memo_j[i] != 0) { return memo_j[i]; } else { long result = fib_j(i-1) + fib_j(i-2); memo_j[i] = result; return memo_j[i]; } } } }
以下の部分でネイティブコードを読み込んでいます。
static { System.loadLibrary("fib_c"); Path p = Paths.get("bench_r/target/debug/libhpa.so"); System.load(p.toAbsolutePath().toString()); }
読み込んだネイティブコードのメソッドも定義しておきます。
// nativeメソッドの宣言 public static native long fibc(int i); public static native long fibr(int i);
Javaでのフィボナッチ数列の実装と実行時間の計測は以下の部分になります。
long[] memo_j = new long[300]; public void run_j() { System.out.println("java fib start"); long start = System.nanoTime(); System.out.println("fib(90) = " + fib_j(90)); long end = System.nanoTime(); System.out.println("java fib end"); System.out.println("java time:" + (end - start) + "nano"); } public long fib_j(int i) { if(i == 0) {return 1;} else if(i == 1) {return 1;} else { if(memo_j[i] != 0) { return memo_j[i]; } else { long result = fib_j(i-1) + fib_j(i-2); memo_j[i] = result; return memo_j[i]; } } }
フィボナッチ数列の関数の引数を0起算として90を渡しているので91番目の値を求めてその実行時間を計測しています。また、フィボナッチ数列の計算を高速に行うためにすでに求めた結果はcacheするようにしてそこから取得するようにしています。
C言語とRustの関数を呼び出して処理時間を計測するのは以下になります。
public void run_c() { System.out.println("clang fib start"); long start = System.nanoTime(); System.out.println("fib(90) = " + fibc(90)); long end = System.nanoTime(); System.out.println("clang fib end"); System.out.println("clang time:" + (end - start) + "nano"); } public void run_r() { System.out.println("rust fib start"); long start = System.nanoTime(); System.out.println("fib(90) = " + fibr(90)); long end = System.nanoTime(); System.out.println("rust fib end"); System.out.println("rust time:" + (end - start) + "nano"); }
C言語
C言語で実装する前にJavaをコンパイルしヘッダファイルを出力します。実際のコマンドは以下のようになります。
javac Bench.java javah Bench
これで以下のようなヘッダファイルが出力されているはずです。
/* DO NOT EDIT THIS FILE - it is machine generated */ #include <jni.h> /* Header for class Bench */ #ifndef _Included_Bench #define _Included_Bench #ifdef __cplusplus extern "C" { #endif /* * Class: Bench * Method: fib_c * Signature: (I)J */ JNIEXPORT jlong JNICALL Java_Bench_fibc (JNIEnv *, jclass, jint); /* * Class: Bench * Method: fibr * Signature: (I)J */ JNIEXPORT jlong JNICALL Java_Bench_fibr (JNIEnv *, jclass, jint); #ifdef __cplusplus } #endif #endif
C言語で実際にフィボナッチの数列を実装する場合は、このヘッダファイルを読み込んでJava_Bench_fibcを実装します。
実際の実装は以下のようになりました。
□fib.c
#include "Bench.h" long memo_c[300]; long fib(int i) { if(i == 0) {return 1;} else if(i == 1) {return 1;} else { if(memo_c[i] != 0) { return memo_c[i]; } else { long result = fib(i-1) + fib(i-2); memo_c[i] = result; return memo_c[i]; } } } JNIEXPORT jlong JNICALL Java_Bench_fibc (JNIEnv *env, jobject obj, jint i) { return fib(i); }
ヘッダファイルの関数を実際に実装しているのは以下の部分になります。jintとjlong はそれぞれJavaのintとlongに対応していて、javaから呼び出すとき引数がintだったらC言語側はjintにしたら良いようです。
JNIEXPORT jlong JNICALL Java_Bench_fibc (JNIEnv *env, jobject obj, jint i) { return fib(i); }
次にC言語のソースをビルドして共有ライブラリを出力するのですが、その時のコマンドは以下のようになります。
gcc -fPIC -shared fib.c -I /usr/lib/jvm/java-8-openjdk-amd64/include -I /usr/lib/jvm/java-8-openjdk-amd64/include/linux/ -o libfib_c.so
gccでJNI用の共有ライブラリを作成する場合、jni.hとjni_md.hをインクルードする必要があるようでして自分の検証したubuntuの環境では"/usr/lib/jvm/java-8-openjdk-amd64/include"と"/usr/lib/jvm/java-8-openjdk-amd64/include/linux/"に含まれているので-Iオプションで指定しています。ビルド後にlibfib_c.soが作成されまして、Javaで読み込むためにはSystem.loadLibrary("fib_c");を実行しています。
Rust
次にRustですが、プロジェクトの設定ファイルで共有ライブららりを出力する設定を行います。
具体的には、まず以下のようにプロジェクトを設定し
cargo new bench_r
それから、プロジェクト設定ファイルのcargo.tmlのlibセクションに以下を以下の設定にします。
□ cargo.toml
[package] name = "bench_r" version = "0.1.0" authors = ["xxxxxx"] edition = "2018" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] name = "hpa" crate-type = ["dylib"] [dependencies] jni-sys = "0.3.0" cached = "0.11.0" lazy_static = "1.4.0"
共有ライブラリを作成する場合、libセクションのcrate-typeで"dylib"にする必要があるようです。またdependenciesセクションには開発に必要なライブラリを指定していまして、jni-sys はRustとJNIを連携するのに使っていて、cachedとlazy_staticはフィボナッチ数列の計算の結果をメモ化するのに使っています。
cargo.tmlの設定が終わったら、Rustのプログラムを書きます。ファイル名はlib.rsで以下のように実装しました。
□bench_r/src/lib.rs
use jni_sys::{JNIEnv, jint, jlong, jclass}; #[macro_use] extern crate cached; #[macro_use] extern crate lazy_static; cached!{FIB; fn fib(n: jint) -> jlong = { if n == 0 || n == 1 { return 1 } else { return fib(n-1) + fib(n-2) } }} #[no_mangle] pub extern fn Java_Bench_fibr(jre: *mut JNIEnv, class: jclass, i: jint) -> jlong { fib(i) }
Rustの計算結果のメモ化に使えるクレートであるcachedの説明にフィボナッチ数列を使っていたので、そのままつかってみます。 docs.rs
RustとJNIの連携にはこのあたりを参考にしました。
hidekatsu-izuno.hatenablog.com
それから、プロジェクトのディレクトリ直下で以下のコマンドでビルドします。
cargo build
ビルドしたらtarget/debugにcargo.tomlのlibセクションのnameで指定した名前で共有ライブラリが作られているはずです。 共有ライブラリはJava側では以下のように読み込んでいます。
Path p = Paths.get("bench_r/target/debug/libhpa.so"); System.load(p.toAbsolutePath().toString());
実行
CとRustで共有ライブラリが作成したので実行してみます。
java -Djava.library.path=. Bench Java側でSystem.loadLibraryでライブラリを読み込む場合、java.library.pathに共有ライブラリのパスを指定する必要があるようです。 実行結果は以下のようになりました。
java fib start fib(90) = 4660046610375530309 java fib end java time:133799nano clang fib start fib(90) = 4660046610375530309 clang fib end clang time:78300nano rust fib start fib(90) = 4660046610375530309 rust fib end rust time:350799nano
Java、C、Rustの実行時間はそれぞれ以下のようなりました。
java time:133799nano clang time:78300nano rust time:350799nano
これだけ見るとC言語は想定通りJavaよりも早いことが確認できますがRustは逆にJavaよりも遅くなっています。以外な気がするのでRustのプログラムを以下のように書き換えRust内でどれだけ時間がかかっているのか確認できるようにします。
use jni_sys::{JNIEnv, jint, jlong, jclass}; use std::time::{Duration, Instant}; use std::thread::sleep; #[macro_use] extern crate cached; #[macro_use] extern crate lazy_static; cached!{FIB; fn fib(n: jint) -> jlong = { if n == 0 || n == 1 { return 1 } else { return fib(n-1) + fib(n-2) } }} #[no_mangle] pub extern fn Java_Bench_fibr(jre: *mut JNIEnv, class: jclass, i: jint) -> jlong { let start = Instant::now(); let ret = fib(i); let end = start.elapsed(); println!("{}nano in rust", end.subsec_nanos()); ret }
それから実行すると以下の結果になりました。
java fib start fib(90) = 4660046610375530309 java fib end java time:125800nano clang fib start fib(90) = 4660046610375530309 clang fib end clang time:98600nano rust fib start 222700nano in rust fib(90) = 4660046610375530309 rust fib end rust time:356100nano
Rust内では222700nano でJavaからの呼び出し全体では356100nanoなので関数呼び出しのオーバーヘッドがかかっていることは確認できますが、それでもRust内での処理時間はJavaよりもかかっているので単純に実装が良くなかったのかもしれませんが以外な結果になりました。
Rust追記
Rustの結果がJavaよりも遅くなったのが気になったので、今回はcachedクレートを使はずグローバル変数にメモ化して結果を比べてみたいと思います。修正したRustのプログラムは以下のようになりました。
extern crate libc; use std::time::{Duration, Instant}; use std::thread::sleep; use jni_sys::{JNIEnv, jint, jlong, jclass}; static mut memo: [jlong; 300] = [0; 300]; unsafe fn fib(n: jint) -> jlong { if n == 0 || n == 1 { return 1 } else { if memo[n as usize] != 0 { return memo[n as usize] } else { let ret = fib(n-1) + fib(n-2); memo[n as usize] = ret; return ret } } } #[no_mangle] pub unsafe extern fn Java_Bench_fibr(jre: *mut JNIEnv, class: jclass, i: jint) -> jlong { let start = Instant::now(); let ret = fib(i); let end = start.elapsed(); println!("{}nano in rust", end.subsec_nanos()); ret }
それから結果は以下のようになりました。
java fib start fib(90) = 4660046610375530309 java fib end java time:114400nano clang fib start fib(90) = 4660046610375530309 clang fib end clang time:64700nano rust fib start 1200nano in rust fib(90) = 4660046610375530309 rust fib end rust time:108400nano
これを見てみるとRust内での処理時間はJavaで書いたフィボナッチよりも大分早くなっているので想定した結果になっています、ただJNIの関数呼び出しのオーバーヘッドが気になってくるのでこのあたりを参考にしたらよいかもしれないです。