Встраиваем интерпретатор Python в java-приложение с помощью проекта Panama +13




Пару дней назад увидел твит Брайана Гетца, но только сегодня дошли руки поиграться с примерами.

image

Про это и хочу кратко рассказать.

О проекте Panama на Хабре уже писали. Чтобы понять, что это и зачем, стоит прочитать интервью по ссылке. Я же просто покажу пару простых примеров того, как можно применить native binder.

Прежде всего, вам понадобится компилятор C. Если вы используете Linux или MacOS, то он у вас уже есть. Если Windows, то придётся сначала установить Build Tools for Visual Studio 2017. И, конечно же, вам потребуется OpenJDK с поддержкой «Панамы». Получить его можно либо сборкой ветки «foreign» соответствующего репозитория, либо загрузкой Early-Access билда.

Начнём с минимального примера — простой функции, складывающей два числа:

adder.h

#ifndef _ADDER_H
#define _ADDER_H

__declspec(dllexport) int add(int, int);

#endif

adder.c
#include "adder.h"

__declspec(dllexport)
int add(int a, int b) {
    return a + b;
}

Компилируем в DLL

cl /LD adder.c

И используем в java-коде

import java.foreign.Library;
import java.foreign.Libraries;
import java.foreign.annotations.NativeHeader;
import java.foreign.annotations.NativeFunction;
import java.lang.invoke.MethodHandles;

public class App {
    @NativeHeader
    interface Adder {
        @NativeFunction("(i32 i32)i32")
        int add(int a, int b);
    }

    public static void main(String[] args) {
    	Library lib = Libraries.loadLibrary(MethodHandles.lookup(), "adder");
    	Adder adder = Libraries.bind(Adder.class, lib);

        System.out.println(adder.add(3, 5));
    }
}

В исходнике должно быть много знакомого для тех, кто использовал JNR: объявляется интерфейс нативной библиотеки, библиотека загружается, связывается с интерфейсом и происходит вызов нативной функции. Основное отличие — использование Layout Definition Language в аннотациях для описания схемы отображения нативных данных на типы Java.

Несложно догадаться, что выражение "(i32 i32)i32" обозначает функцию принимающую два целых 32-битных числа и возвращающую целое 32-битное число. Метка i обозначает один из трёх основных типов — целое число с порядком байт little-endian. Кроме него часто встречаются u и f — беззнаковое целое и число с плавающей точкой, соответственно. Для обозначения порядка big-endian используются те же метки, но в верхнем регистре — I, U, F. Ещё одна часто встречающаяся метка — это v, используемая для void. Число идущее следом за меткой обозначают количество используемых бит.

Если число стоит перед меткой, то метка обозначает массив: [42f32] — массив из 42 элементов типа float. Квадратные скобки группируют метки. Кроме массивов это может использоваться для обозначения структур ([i32 i32] — структура с двумя полями типа int) и объединений ([u64|u64:f32]long или указатель на float).

Для обозначения указателей используется двоеточие. Например, u64:i32 — указатель на int, u64:v — указатель типа void, а u64:[i32 i32] — указатель на структуру.

Вооружившись этой информацией, немного усложним пример.

totalizer.c
__declspec(dllexport)
long sum(int vals[], int size) {
    long r = 0;
    for (int i = 0; i < size; i++) {
        r += vals[i];
    }
    return r;
}

App.java
import java.foreign.Library;
import java.foreign.Libraries;
import java.foreign.NativeTypes;
import java.foreign.Scope;
import java.foreign.annotations.NativeHeader;
import java.foreign.annotations.NativeFunction;
import java.foreign.memory.Array;
import java.foreign.memory.Pointer;
import java.lang.invoke.MethodHandles;

public class App {
    @NativeHeader
    interface Totalizer {
        @NativeFunction("(u64:i32 i32)u64")
        long sum(Pointer<Integer> vals, int size);
    }

    public static void main(String[] args) {
        Library lib = Libraries.loadLibrary(MethodHandles.lookup(),
          "totalizer");
        Totalizer totalizer = Libraries.bind(Totalizer.class, lib);

        try (Scope scope = Scope.newNativeScope()) {
            Array<Integer> array = scope.allocateArray(NativeTypes.INT,
              new int[] { 23, 15, 4, 16, 42, 8 });
            System.out.println(totalizer.sum(array.elementPointer(), 3));
        }
    }
}

В java-коде появилось сразу несколько новых элементов — Scope, Array и Pointer. Работая с нативным кодом, вам придётся оперировать off-heap данными, а значит придётся самостоятельно выделять память, самостоятельно освобождать и следить за актуальностью указателей. К счастью, есть Scope, берущий на себя все эти заботы. Его методы позволяют легко и удобно выделять неспецифицированную память, память под массивы, структуры и C-строки, получать указатели на эту память, а так же автоматически освобождать её после завершения блока try-with-resources и менять состояние созданных указателей так, чтобы обращение к ним приводило к исключению, а не падению виртуальной машины.

Чтобы посмотреть в работе структуры и указатели, усложним пример ещё немного.

mover.h
#ifndef _ADDER_H
#define _ADDER_H

typedef struct {
    int x;
    int y;
} Point;

__declspec(dllexport) void move(Point*);

#endif

mover.c
#include "mover.h"

__declspec(dllexport)
void move(Point *point) {
    point->x = 4;
    point->y = 2;
}

App.java
import java.foreign.Library;
import java.foreign.Libraries;
import java.foreign.Scope;
import java.foreign.annotations.NativeHeader;
import java.foreign.annotations.NativeFunction;
import java.foreign.annotations.NativeStruct;
import java.foreign.annotations.NativeGetter;
import java.foreign.memory.LayoutType;
import java.foreign.memory.Pointer;
import java.foreign.memory.Struct;
import java.lang.invoke.MethodHandles;

public class App {
    @NativeStruct("[i32(x) i32(y)](Point)")
    interface Point extends Struct<Point> {
        @NativeGetter("x")
        int x();
        @NativeGetter("y")
        int y();
    }

    @NativeHeader
    interface Mover {
        @NativeFunction("(u64:[i32 i32])v")
        void move(Pointer<Point> point);
    }

    public static void main(String[] args) {
        Library lib = Libraries.loadLibrary(MethodHandles.lookup(), "mover");
        Mover mover = Libraries.bind(Mover.class, lib);

        try (Scope scope = Scope.newNativeScope()) {
            Pointer<Point> point = scope.allocate(
              LayoutType.ofStruct(Point.class));
            mover.move(point);
            System.out.printf("X: %d Y: %d%n", point.get().x(),
              point.get().y());
        }
    }
}

Интерес здесь представляет то, как объявляется интерфейс структуры и как под неё выделяется память. Обратите внимание, что в ldl-объявлении появился новый элемент — значения в круглых скобках после меток. Это аннотация метки в сокращённой форме. Полная форма выглядела бы так: i32(name=x). Аннотация метки позволяет соотнести её с методом интерфейса.

Прежде, чем переходить к обещанному в заголовке, осталось осветить ещё один способ взаимодействия с нативным кодом. Все предыдущие примеры вызывали нативные функции, но иногда нативному коду требуется вызывать java-код. Например, если мы хотим отсортировать массив с помощью qsort, нам понадобиться callback.

import java.foreign.Library;
import java.foreign.Libraries;
import java.foreign.NativeTypes;
import java.foreign.Scope;
import java.foreign.annotations.NativeHeader;
import java.foreign.annotations.NativeFunction;
import java.foreign.annotations.NativeCallback;
import java.foreign.memory.Array;
import java.foreign.memory.Callback;
import java.foreign.memory.Pointer;
import java.lang.invoke.MethodHandles;

public class App {
    @NativeHeader
    interface StdLib {
        @NativeFunction("(u64:[0i32] i32 i32 u64:(u64:i32 u64:i32) i32)v")
        void qsort(Pointer<Integer> base, int nitems, int size,
          Callback<QComparator> comparator);

        @NativeCallback("(u64:i32 u64:i32)i32")
        interface QComparator {
            int compare(Pointer<Integer> p1, Pointer<Integer> p2);
        }
    }

    public static void main(String[] args) {
        Library lib = Libraries.loadLibrary(MethodHandles.lookup(), "msvcr120");
        StdLib stdLib = Libraries.bind(StdLib.class, lib);

        try (Scope scope = Scope.newNativeScope()) {
            Array<Integer> array = scope.allocateArray(NativeTypes.INT,
              new int[] { 23, 15, 4, 16, 42, 8 });
            Callback<StdLib.QComparator> cb = scope.allocateCallback(
              (p1, p2) -> p1.get() - p2.get());

            stdLib.qsort(array.elementPointer(), (int) array.length(),
              Integer.BYTES, cb);

            for (int i = 0; i < array.length(); i++) {
                System.out.printf("%d ", array.get(i));
            }
            System.out.println();
        }
    }
}

Легко заметить, что ldl-объявления, и так не особо простые для восприятия, быстро превращаются в зубодробительные конструкции. А ведь qsort — не самая сложная функция. Кроме того, в реальных библиотеках могут быть десятки структур и десятки функций, писать для них интерфейсы — дело неблагодарное. К счастью, обе проблемы легко решаются использованием утилиты jextract, которая сгенерирует всех необходимые интерфейсы на основе заголовочных файлов. Вернёмся к первому примеру и обработаем его этой утилитой.

jextract -L . -l adder -o adder.jar -t "com.example" adder.h

// импорт jextract'нутых "заголовочных" классов
import static com.example.adder_h.*;

public class Example {
    public static void main(String[] args) {
        System.out.println(add(3, 5));
    }
}

И используем полученный jar-файл для сборки и запуска java-кода:

javac -cp adder.jar Example.java
java -cp .;adder.jar Example

Пока не особенно впечатляет, но позволяет понять принцип. А теперь проделаем то же самое с python37.dll (наконец-то!)

import java.foreign.Scope;
import java.foreign.memory.Pointer;

import static org.python.Python_h.*;
import static org.python.pylifecycle_h.*;
import static org.python.pythonrun_h.*;

public class App {
    public static void main(String[] args) {
        Py_Initialize();
        try (Scope s = Scope.newNativeScope()) {
            PyRun_SimpleStringFlags(
              s.allocateCString("print(sum([23, 15, 4, 16, 42, 8]))\n"),
              Pointer.nullPointer());
        }
        Py_Finalize();
    }
}

Генерируем интерфейсы:

jextract -L "C:\Python37" -l python37 -o python.jar -t "org.python" --record-library-path C:\Python37\include\Python.h

Компилируем и запускаем:

javac -cp python.jar App.java
java -cp .;python.jar App

Поздравляю, ваше java-приложение только что загрузило в себя интерпретатор Python и выполнило в нём скрипт!

image

Больше примеров можно посмотреть в инструкции для первопроходцев.

Maven-проекты с примерами из статьи можно найти на GitHub.

P.S. API сейчас находится в стадии бурных изменений. В презентациях вышедших пару месяцев назад докладов легко увидеть код, который не будет компилироваться. Не застрахованы от этого и примеры из этой статьи. Если вы столкнётесь с этим, отправьте мне сообщение, постараюсь исправить.

Теги:




Комментарии (9):

  1. DollaR84
    /#19762196

    Извиняюсь спросить, но зачем нужно такое скрещивание? Если только для того, чтоб на java нарисовать интерфейс программы, то у python есть куча вариантов интерфейсов, например WX widgets, или другие. Если чтоб запускать без установки на других компах pythonа, то всегда можно собрать exe-шник pyinstaller или еще чем. А вот для чего запускать python приложение из java приложения — не могу придумать вообще.

    • sergey-gornostaev
      /#19762258 / +1

      Работа с Python — это просто пример. Проект Panama позволяет работать с любым нативным кодом, включая пакеты линейной алгебры, написанные на Fortran, графические библиотеки, типа OpenGL, библиотеки машинного обучения, типа Tensorflow, криптографические библиотеки, системные и т.п. Не всё можно реализовать на Java, и не всегда реализации на Java получаются в достаточной степени производительными, поэтому невозможно обойтись без интероперабельности с нативным кодом. Раньше для этого применялся JNI, но он слишком сложный и недостаточно быстрый. Проект Panama исправляет оба этих недостатка.

      • DollaR84
        /#19762464

        А, понял, спасибо за разъяснение. После статьи как-то не уловил данных возможностей.

      • Suvitruf
        /#19763206

        Насколько я помню, при работе с JNI много ресурсов тратится именно на бридж между Java и нейтивом. А как тут эту проблему решили?

        • sergey-gornostaev
          /#19763790

          Честно говоря, с деталями не знаком. Документации пока очень мало, а в код я ещё не погружался. Но судя по прослушанным докладам, к этому вопросу комплексный подход, JVM изменяют под «Панаму» сразу во многих местах — не такая тесная интеграция нативного кода с JVM, передача в нативный код только off-heap данных, большее количество информации о нативных вызовах у JIT-компилятора и т.д. и т.п.

    • oldd
      /#19763830

      Приведу пример из своей работы. Нужно строить дерево какого-то железа (эта железка может иметь этих детей в таком-то количестве, а вот эта- совсем других детей и.т.д.) Железо на момент изготовления программы неизвестно, что куда подключается- неизвестно. Сначала пытались выехать на описалове и настройках, но уж больно всё сложно всё получалось, и потому решили внедрить скрипты в описалово железа. И всё прям замечательно получилось — считываешь json-ку, находишь по id нужную железку, запускаешь скрипт и получаешь, что к этой железке можно подключить, а что-нет. Для скриптов используем groovy

  2. Moxa
    /#19763050

    А есть какие-нибудь бенчмарки, насколько оно быстрее чем jni?

    • stuf4ik
      /#19763220 / +1

      Есть хорошее видео от JVM разработчика, который принимает участие в этом проекте. И хорошо проходит по теме «native» вызовов/памяти. На 29 минуте есть небольшое сравнение между JNI и «улучшенным вариантов».
      https://www.youtube.com/watch?v=sFxrjGTnvBs

    • sergey-gornostaev
      /#19763774

      Пока рано измерять, так как Panama на ранних стадиях разработки. В Early-Access билдах Panama не совсем настоящая, foreign function calls делаются поверх JNI, так же как это делает JNA. Но одна из основных целей, поставленных перед проектом с самого начала — быть быстрее JNI и sun.misc.Unsafe. Маурицио Чимадаморе в докладе "Project Panama’s Foreign API" рассказывает, что в те редкие моменты, когда боевой бэкенд не падает объятый пламенем, он показывает результаты в несколько раз лучшие, чем JNI, и есть потенциал для ускорения в десятки раз.