JavaからRustをJNA(Java Native Access)で実行する
はじめに
動機
システムプログラミング言語(?)であるRustの話を聞いていると、FFI(Foreign function interface)の話が少なからず出てきます。
ということで普段書いているJavaから呼び出します。
対象読者
JavaとRustが多少読め、MavenというJava用のプロジェクト管理用ツールが存在していることを知っている方。
難しいことはしておらず、プロジェクトの雛形を作成したレベルの話です。
環境
開発環境はVSCodeを使用します。
RustとJavaの実行環境はDockerで構築します。
DockerFiile
ベースはMicrosoft提供のJavaの開発環境のサンプル(Debean 10)を、フォークして自分用に弄ったものを使用しています。変更箇所としてはJavaのversionを14→11に変更した程度です。
弄ったもの
そこにRustのコンパイラをインストールします。以下を追記。
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:$PATH
RUN set -eux; \
\
url="https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init"; \
wget "$url"; \
chmod +x rustup-init; \
./rustup-init -y --no-modify-path --default-toolchain nightly; \
rm rustup-init; \
chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \
rustup --version; \
cargo --version; \
rustc --version;
RUN apt-get update && apt-get install -y lldb python3-minimal libpython3.7 python3-dev gcc \
&& apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
内容としては
- 環境変数の追加
- 必要なrustのコンポーネントをインストール
- Rustで必要になるデバッガ、python、gccのインストール
を行っています
インストールするRustをここでnightlyとしている理由は後述します。
Rust
今回Rust側で作成したファイルは以下です
workspace
│ Cargo.toml
│
├─sample-jna
│ │ Cargo.toml
│ │
│ └─src
│ lib.rs
│
└─scripts
cargo-build.sh
workspaceのトップレベルでcargoのコマンドが使いたかったため、このような構成となりました。
以下解説
Cargo.toml
[workspace]
members = ["sample-jna"]
[profile.release]
lto = true
上2行でworkspace内のsample-jnaディレクトリをプロジェクトとして認識しています。
lto = trueはbuild時のファイルサイズ削減用のオプションです。
sample-jna/Cargo.toml
[package]
name = "sample-jna"
version = "0.1.0"
authors = ["uesugi6111 <59960488+aburaya6111@users.noreply.github.com>"]
edition = "2018"
[lib]
crate-type = ["cdylib"]
[package]はcargo new で作成されるもので問題ありません。
[lib]のcrate-typeがコンパイル後のタイプとなります。別言語から呼び出す想定のダイナミックライブラリは、cdylibを指定するようリファレンスに書かれているので、それに従います。
lib.rs
ここがライブラリ本体となります。今回は以前書いて残していたエラトステネスの篩に似たアルゴリズムで引数までの素数を列挙し、その個数を返すだけのプログラムを用意しました。
#[no_mangle]
pub extern fn sieve_liner(n: i32) -> i32{
let mut primes = vec![];
let mut d = vec![0i32; n as usize + 1];
for i in 2..n + 1 {
if d[i as usize] == 0 {
primes.push(i);
d[i as usize] = i;
}
for p in &primes {
if p * i > n {
break;
}
d[(*p * i) as usize] = *p;
}
}
primes.len() as i32
}
通常のコンパイルでは関数名は他の名称に変換されてしまい、ほかプログラムなどから呼び出す際に、名前がわからなくなってしまいます。それを防ぐために**#[no_mangle]**(直訳:切り刻み無し)を関数に付与します。
cargo-build.sh
#!/bin/bash
cargo build --release -Z unstable-options --out-dir ./src/main/resources
ライブラリのbuildスクリプトになります。
--release
releaseオプションでのbuildを指定します。
-Z unstable-options --out-dir ./src/main/resources
build後に出力するディレクトリを指定するオプションとなっています。しかしこのオプションが使えるのはnightlyのみとなっています。
そのためDockerで構築する環境へのインストールは、nightly指定にしています。
ディレクトリの指定先はJava側でコンパイルされたときにjarファイル内に配置される場所に設定しました。
Java
Java側で作成したファイルは以下になります。
workspace
│ pom.xml
└─src
└─main
├─java
│ └─com
│ └─mycompany
│ └─app
│ App.java
│
└─resources
やけにディレクトリが深いですが、特に意味はありません。
pom.xml
以下を**<dependencies>**へ追記します
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.6.0</version>
</dependency>
App.java
package com.mycompany.app;
import java.util.ArrayList;
import java.util.List;
import com.sun.jna.Library;
import com.sun.jna.Native;
public class App {
private static final int N = 100000000;
public interface SampleJna extends Library {
SampleJna INSTANCE = Native.load("/libsample_jna.so", SampleJna.class);
int sieve_liner(int value);
};
public static void main(String[] args) {
System.out.println("N = " + N);
System.out.println("FFI :" + executeFFI(N) + "ms");
System.out.println("Java :" + executeJava(N) + "ms");
}
public static long executeFFI(int n) {
long startTime = System.currentTimeMillis();
SampleJna.INSTANCE.sieve_liner(n);
return System.currentTimeMillis() - startTime;
}
public static long executeJava(int n) {
long startTime = System.currentTimeMillis();
sieveLiner(n);
return System.currentTimeMillis() - startTime;
}
public static int sieveLiner(int n) {
List<Integer> primes = new ArrayList<>();
int d[] = new int[n + 1];
for (int i = 2; i < n + 1; ++i) {
if (d[i] == 0) {
primes.add(i);
d[i] = i;
}
for (int p : primes) {
if (p * i > n) {
break;
}
d[p * i] = p;
}
}
return primes.size();
}
}
Rust側に実装したロジックと同様のものを実装し、実行時間を比較します。
ライブラリの呼び出しは
SampleJna INSTANCE = Native.load("/libsample_jna.so", SampleJna.class);
で行っています。
Native.load(ライブラリのPath,ライブラリを定義したのinterface)という形で記述するようです。
今回ライブラリはmain/resources直下に配置する予定なので絶対パス(?)で表記しています。
Maven
ここまでで本来動作確認はできるのですが、jarにすることを考えた際の設定もしてみました。
jarを作成するまでの流れ
- Rust をコンパイルしてJava側のresourcesディレクトリに配置
- Java側のコンパイル
これをMavenの機能を利用し、ワンアクションで行います。
maven-assembly-plugin
jarに依存ライブラリを含める
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>/</classpathPrefix>
<mainClass>com.mycompany.app.App</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
exec-maven-plugin
maven の処理中でシェルスクリプトを実行するために必要となります。
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.3.2</version>
<executions>
<execution>
<id>dependencies</id>
<phase>generate-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}</workingDirectory>
<executable>${project.basedir}/scripts/cargo-build.sh </executable>
</configuration>
</execution>
</executions>
</plugin>
少し解説
phase
シェルスクリプトを実行するタイミングを設定します。Mavenではライフサイクルという概念が存在するので、実行したいタイミングに合ったものを指定します。リファレンス
executable
ここで実行したい対象を指定します。
pom.xml
ここまで適応し終えたファイル
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.mycompany.app</groupId>
<artifactId>my-app</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<name>my-app</name>
<url>http://maven.apache.org</url>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna</artifactId>
<version>5.6.0</version>
</dependency>
</dependencies>
<properties>
<jdk.version>11</jdk.version>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>/</classpathPrefix>
<mainClass>com.mycompany.app.App</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M3</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.3.2</version>
<executions>
<execution>
<id>dependencies</id>
<phase>generate-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}</workingDirectory>
<executable>${project.basedir}/scripts/cargo-build.sh </executable>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
実行
workspaceのルートで以下を実行
mvn package
すると
[INFO] --- maven-assembly-plugin:3.3.0:single (make-assembly) @ my-app ---
[INFO] Building jar: /workspace/target/my-app-1.0-SNAPSHOT-jar-with-dependencies.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
のようなログが出力されてコンパイルが完了します。
あとは表示されたパスに出力されているjarを実行してください。
java -jar ./target/my-app-1.0-SNAPSHOT-jar-with-dependencies.jar
出力
N = 100000000
FFI :1668ms
Java :3663ms
10^8までの素数の数のカウントでかかった、JavaとFFI(Rust)の時間(ms)が出力されました。
Nを小さくするとJavaの方が早くなるため、大きなオーバーヘッドがあるのでしょうかわかりません。
さいご
とりあえずは動いたということで一旦完了とします。
使ったソースになります
普段触らない部分の話が多くまだわからないことが多いですがぼちぼち調べていきます。
謎
- JNA以外のJavaからの呼び出し方法
- Rustの関数での単純な数値以外の返し方
Discussion