Scala からシームレスに Python を使う Scalapy (と JVM の ffi 事情)
Scalapy
scalapy は Python を Scala から実行するためのライブラリです. Scala および Scala native で使えます.
numpy, pandas, matplotlib, tensorflow など、データ分析で使うライブラリを Scala からシュッと使うことができます. おそらくほとんどの Python ライブラリを使うことができます. モジュールの呼び出しもサポートしています.コードは gists に置いています. お役に立てたらスターしてね❤
gh gist clone https://gist.github.com/i10416/1f6e269a07ef5ae98b01e137c5c8aec5
Linux か MacOS なら以下のコードを適当な名前の *.worksheet.sc
ファイルにコピペしてScala の拡張機能 Metals がインストールされた VS Code で開けば動作を確認できるはずです.
import $ivy.`me.shadaj::scalapy-core:0.5.1`
import $ivy.`ai.kien::python-native-libs:0.2.1`
import me.shadaj.scalapy.py
import me.shadaj.scalapy.py.SeqConverters
import me.shadaj.scalapy.interpreter.PyValue
import ai.kien.python.Python
import me.shadaj.scalapy.interpreter.CPythonInterpreter
val pyt = Python()
val props = pyt.scalapyProperties.get
props.foreach{case (key,value) =>
System.setProperty(key,value)
}
val listLengthPython = py.Dynamic.global.len(List(1,2,3).toPythonProxy).as[Int]
val list = py.Dynamic.global.range(1,3+1)
val np = py.module("numpy")
val nparr = np.array(Seq(
Seq(1,0),
Seq(0,12)).toPythonProxy
)
np.linalg.svd(nparr)
val plot = py.module("matplotlib.pyplot")
val graph = plot.plot(Seq(1,2,3).toPythonProxy,Seq(3,2,1).toPythonProxy)
plot.savefig("sample.png")
このように numpy や matplotlib を使って行列計算をしたり、グラフをプロットして保存したりできます(^ω^)
Scala のライブラリだと Python と比べて少ないせいか、ところどころかゆいところに手が届かないもどかしさがありましたが、そんなときは Python のエコシステムを活用することができます. うれしいですね(^ω^)
※ Scala では numpy に相当する breeze や matplotlib に相当する breeze-viz,vegas,evilplot などがありますがイマイチ開発が活発ではありません😢
設定をすれば Scala の jupyter カーネルの almond からも利用できるようです. Scalaでデータサイエンス...! 夢がありますね...(^ω^)?
py.Dynamic
のままだと補完や定義ジャンプなどのメリットを享受できませんが、 Scalapy の開発者の shadaj さんが tensorflow や numpy のファサードを作って公開してくれています.
Scala.js で TypeScript の型から Scala.js 向けの型定義を作るように、Python の型を定義するためのマクロが用意されています.
たとえば、次のように IntList を定義することで Python から返ってきた値をScalaで扱いやすい型に変換することができます.
import py.PyBracketAccess
@py.native trait IntList extends py.Any {
@PyBracketAccess
def apply(index: Int): Int = py.native
@PyBracketAccess
def update(index: Int, newValue: Int): Unit = py.native
}
val myList = py"[1, 2, 3]".as[IntList]
// myList: IntList = [4, 2, 3]
// index アクセスするオブジェクト用のマクロ`@PyBracketAccess` を使えばより自然なコードを書くことができます.
myList(0) //=> 1
myList(0) = 100
(レポジトリを除いてみると一応 mypy の型定義を使った autogen のコードがありますが、 )残念ながら Scala.js の ScalablyTyped のTypeScript の型定義から自動でファサードを作ることはまだ難しいようです.
もし普段 Python と Scala を使っているならいい感じのファサードを作って OSS として公開してみてはいかがでしょうか(^ω^)
Scalapy の中身と JVM のネイティブアクセス事情
Scalapy の仕組みをざっくり説明すると、 Python が ABI(c言語向けインターフェース) で公開しているインターフェースを JNA(JVM から ネイティブのコードを呼び出すための機能) 経由で叩いて Scala <-> Python 間のデータのやり取りをしています.
(ほとんど)すべてのプログラミング言語は c 言語と会話ができるはずなので c 言語のインターフェースを介して言語間でデータをやり取りすることができます.
例えば、 Scala 側で以下のようなアノテーションをつけたメソッドを定義して呼び出すと、事前にロードしておいた libpython3.x.so
から Py_Initialize
関数を探し出して呼び出し、その結果を JVM 側に返してくれます.
@scala.native def Py_Initialize(): Unit
このようにほとんどボイラープレートを書くことなくネイティブのコードを JVM から使うことができます.
ネイティブ側では Python の Interpreter に処理を実行させて、その結果を返しています. 例えば PyRun_SimpleString("print('hello world!')")
のAPI を呼び出すと Python は hello world
を出力します. JVM側の型とネイティブ側の型の変換はJVM がよしなにやってくれます.
Python はすべてがObject なので大半のデータ型はポインタとして返ってくるため少し扱いにくいです. そこで間に PyValue というクラスを挟んでユーザーが使いやすいようにしています.
final class PyValue private[PyValue](var underlying: Platform.Pointer, safeGlobal: Boolean = false) {
// ...
def getLong: Long = CPythonInterpreter.withGil {
val ret = CPythonAPI.PyLong_AsLongLong(underlying)
CPythonInterpreter.throwErrorIfOccured()
ret
}
この薄いレイヤーのお陰でユーザーはネイティブ側のことをあまり考えずに Python を Scala から使うことができます.
ただし、JNAにはパフォーマンスのオーバーヘッドが大きいというデメリットがあります. ネイティブのコードを呼び出す場面ではしばしばパフォーマンスが重要なのにオーバーヘッドで無駄になっては元も子もありませんね.
パフォーマンスが第一ならば JNI というJNA より古い機能を使う必要があります. JNI では C と JVM のデータのやり取りを開発者自らやらなければなりません.
JVM 側で以下のようなアノテーションをつけたメソッドを定義します.(Java の場合は native
キーワードです. 以下は Scala で sbt-jni を使った場合の書き方.)
package example
class Hoge {
@native def init():Unit
}
この定義から次のようなヘッダファイルを生成します.
#include <jni.h>
JNIEXPORT void JNICALL Java_example_Hoge_init
(JNIEnv *, jobject);
開発者はこのヘッダファイルをもとに次のようなネイティブのコードと JVM のコードのバインディングを書く必要があります.
#include "example_Hoge.h"
#include "Python.h"
JNIEXPORT void JNICALL Java_example_Hoge_init(JNIEnv *, jobject){
Py_Initialize();
}
そして、バインディングのために書いたコードをビルドしてネイティブ側から呼び出してやる必要があります. このメソッドは引数なしで Unit
を返すのでまだ楽ですが、引数をとってなんらかの値を返す場合はそれらの型について変換処理を書かなければなりません.
CPythonAPIの数を見るとウッとなります...
多くのところで使われている重要なソフトウェアはいまだに c/c++ で書かれていたりするので、ネイティブのインターフェースをアクセスするためだけに毎度こんな二度手間三度手間をしないといけないのは辛いですね😖
Java 17 以降で使える jdk.incubator.foreign
(通称プロジェクト Panama) を使えばもう少し自然にネイティブ側にアクセスできるようになってこの問題が一部解決できるはずなので JVM には頑張ってもらいたいですね.
ところで Scala 3 のマクロと Java 17 の jdk.incubator.foreign
API をフルに活用して、ボイラープレートなし・パフォーマンスを保ったままネイティブへのバインディングを可能にする SLinC というプロジェクトがあるそうですよ? オオッ!?
ということで、少し試してみました.
まず Java 17 が必要なのでインスコします.
cs java-home --jvm openjdk:1.17.0
また、incubator.foreign api はまだ exprerimental なので jvm にオプションを渡す必要があるので build.sbt をゴニョゴニョします.
javaOptions ++= Seq(
"--add-modules",
"jdk.incubator.foreign",
"--enable-native-access",
"ALL-UNNAMED"
)
class Python3API extends Library(Location.Absolute("/path/to/lib/libpython3.9.so")) {
def init()(using SegmentAllocator):Int = {
Py_SetProgramName("example")
Py_Initialize()
}
def Py_SetProgramName(str:String)(using SegmentAllocator):Unit = bind
def Py_Initialize()(using SegmentAllocator):Unit = bind
}
こんな風に書くだけでバインディング用のコードをコンパイルタイムに自動生成してくれます. これはアツアツですね.
他にも、構造体の扱いを楽にするためのマクロもあるようです. 以下のように derives Struct
節を使うことでネイティブ側の構造体に対応するクラスを Scala 側から定義することができます.
case class foo(n: Int, m: Int) derives Struct
Rust の #[repr(C)]
みたいですね.
JNA のパフォーマンスや JNI のボイラープレートにうんざりしている JVM ユーザーのみなさん、さきっちょだけでも Scala を使ってみませんか...?
実際のところ、Python のライブラリは c/c++ のライブラリのラッパーだったりするので、 Scala 3 を使って (Pythonを介さずに) c++ や Rustのハイパフォーマンスなライブラリのバインディングを書くのにちょうどいいのではないでしょうか?
MAKE SCALA GREAT AGAIN!
(特にデータ分析のフィールドで...!)
プロジェクト valhalla や プロジェクト loom など JVM のパフォーマンス周りのプロジェクトがだいぶ整ってきているのでJVMさん頑張ってほしいですね(´・ω・`)
Discussion