JNIを使ってJavaからCとRustを呼び出してみた

JNIとは

JNIとはJavaからネイティブコードを呼び出すための機能です。例えばC言語であればgccコンパイルするときにsharedのオプションを有効にすることで共有ライブラリが作れるので、Java側ではSystem.loadLibrarySystem.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の連携にはこのあたりを参考にしました。

nitschinger.at

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の関数呼び出しのオーバーヘッドが気になってくるのでこのあたりを参考にしたらよいかもしれないです。

www.ibm.com