Сразу хотелось бы оговорить, что данная статья не предназначена для новичков. Вот совсем. Если Вы решили полезть в вызов C-кода из Java, значит Вы хорошо знаете оба эти языка на высоком (а вернее, с программной точки зрения, низком) уровне: знания структуры памяти, адресации и т.д., если же я только что сказал что-то для Вас непонятное - добро пожаловать отсюда (с уважением, без агрессии, увидимся когда изучите матчасть). Данное "предупреждение" я решил внести, чтобы в дальнейшем говорить с читателем на одном языке и не расписывать каждую строку кода, здесь будет о чем поговорить и без этого.
Также, для удобства буду сокращать Foreign Linker API в FLA
Немного предыстории
Из-за чего, собственно, я полез в это дело?
Я писал магистерскую диссертацию на тему параллелизма с учетом времени доступа памяти (грубо говоря, расчет оценок параллелизма с учетом характеристик конкретной вычислительной системы). В этой работе я кроме проверки самого метода, анализировал разные подходы к реализации параллельных алгоритмов:
- Java ExecutorService (fixed thread pool)
- Java ForkJoinPool
- JNI OMP
int add(int a, int b)
{
return a+b;
}
Для вызова этого кода с точки зрения машины, нам нужен адрес функции add в памяти. Обратившись по этому адресу, в качестве параметров вызова мы передаем 2 числа a и b, на каком-то, достаточно низком уровне, мы передаем адреса памяти в которых эти параметры лежат. В ответ получаем адрес где лежит результат (сумма).
Т.е. для того чтобы вызвать этот код из Java, нам нужен инструмент с помощью которого мы:
- Загрузим адреса функций из библиотеки в память
- Найдем нужный адрес функции
- Положим в память 2 числа (a,b) и получим их адреса
- Вызовем функцию указав эти 2 адреса
- Считаем ответ из полученного адреса
#ifndef TEST_MATRIX_LIBRARY_H
#define TEST_MATRIX_LIBRARY_H
#include <cstdlib>
#include "omp.h"
extern "C"
{
double **multiplyMatrix(double **, double **, size_t, size_t, size_t, size_t);
double **multiplyMatrixParallel(double **, double **, size_t, size_t, size_t, size_t);
};
#endif //TEST_MATRIX_LIBRARY_H
library.cpp
#include "library.h"
extern "C"
{
double **multiplyMatrix(double **A, double **B,
size_t width1, size_t height1, size_t width2, size_t height2) {
if (width1 != height2)
return nullptr;
double **res = (double **) malloc(sizeof(double *) * height1);
for (size_t i = 0; i < height1; ++i) {
res[i] = (double *) malloc(sizeof(double) * width2);
}
for (size_t i = 0; i < height1; i++) {
for (size_t j = 0; j < width2; j++) {
res[i][j] = 0;
for (size_t k = 0; k < height2; k++) {
res[i][j] += A[i][k] * B[k][j];
}
}
}
return res;
}
double **multiplyMatrixParallel(double **A, double **B,
size_t width1, size_t height1, size_t width2, size_t height2) {
if (width1 != height2)
return nullptr;
double **res = (double **) malloc(sizeof(double *) * height1);
for (size_t i = 0; i < height1; ++i) {
res[i] = (double *) malloc(sizeof(double) * width2);
}
size_t i, j, k;
#pragma omp parallel for private(j, k)
for (i = 0; i < height1; i++) {
for (j = 0; j < width2; j++) {
res[i][j] = 0;
for (k = 0; k < height2; k++) {
res[i][j] += A[i][k] * B[k][j];
}
}
}
return res;
}
};
Я сделал 2 реализации - последовательную и параллельную (сильно не бейте, написал банальщину), в целом код не комментировал, опять же, если вам нужно рассказывать как умножать матрицы (по определению) - встречный вопрос: "что вы тут забыли?".
Теперь пойдем по пунктам:
1. Загружаем библиотеку
System.loadLibrary("test_matrix");
2. Ищем адрес функции
Для этого FLA предоставляет функцию "downcallHandle" в которой мы описываем что мы ищем:
private static final MethodHandle HANDLE;
private static final MethodHandle HANDLE_PARALLEL;
static {
System.loadLibrary("test_matrix");
FunctionDescriptor fd = FunctionDescriptor.of(
//Возвращаемый тип функции в C
ValueLayout.ADDRESS, //Адрес в памяти (size_t)
//Формальные параметры функции в C
ValueLayout.ADDRESS, //Адрес в памяти (size_t)
ValueLayout.ADDRESS, //Адрес в памяти (size_t)
ValueLayout.JAVA_INT, //int
ValueLayout.JAVA_INT, //int
ValueLayout.JAVA_INT, //int
ValueLayout.JAVA_INT //int
);
HANDLE = Linker.nativeLinker().downcallHandle(
SymbolLookup.loaderLookup().lookup("multiplyMatrix").orElseThrow(),
fd
);
HANDLE_PARALLEL = Linker.nativeLinker().downcallHandle(
SymbolLookup.loaderLookup().lookup("multiplyMatrixParallel").orElseThrow(),
fd
);
}
Здесь стоит обратить внимание, что нам не нужно вручную для простых типов брать адреса - API сделает это за нас, нам лишь нужно указать какой тип мы хотим положить - в нашем случае используем int. Для остальных же мы указываем, что параметром мы передаем адрес в памяти, т.е., грубо говоря, не простой "примитив".
Далее для получения функции нам наобходимо вызвать Linker.nativeLinker().downcallHandle(). Первый параметр - адрес, который находим при помощи SymbolLookup.loaderLookup().lookup() указывая имя функции, второй параметр - описание данной функции: первый аргумент - возвращаемый тип, последующие - формальные параметры. Если у нас возвращаемый тип void - можем так и указать void.class, либо использовать вместо FunctionDescriptor.of метод FunctionDescriptor.ofVoid - тогда передаем только список формальных параметров.
3. Наполняем память
Итак у нас матрица. Что такое матрица? Правильно, двумерный массив. А что такое двумерный массив? Массив массивов! И что это означает? Что у нас есть массив ссылок на другие массивы.
Т.е. для начала занесем в память сами массивы со значениями (как бы строки)
Для каждой "строки" получаем адрес её начала и заносим в массив этих самых адресов, после чего получаем адрес начала массива адресов (уф блин). Т.к. система у меня 64-битная, адрес в памяти будет представлять из себя 8-байтовое число, а значит тип хранения будет long.
Напишем 2 метода: один заносит в память массивы, другой всю матрицу.
/**
* Кладем массив в память
* @param array массив на Java
* @param session Сессия в памяти
* @return Сегмент (адрес) начала массива
*/
public static MemorySegment allocateArray(double[] array, MemorySession session) {
//Создаем сегмент для нашего массива
MemorySegment ms = MemorySegment.allocateNative(8L * array.length, session);
//Заполняем сегмент значениями из массива
ms.copyFrom(MemorySegment.ofArray(array));
return ms; //Возвращаем сегмент
}
/**
* Кладем в память матрицу
* @param matrix матрица в виде java массивов
* @param session Сессия в памяти
* @return Сегмент в котором наша матрица лежит (адрес начала)
*/
public static MemorySegment allocateMatrix(double[][] matrix, MemorySession session) {
long[] memories = new long[matrix.length]; //Наш итоговый массив адресов
for (int i = 0; i < matrix.length; ++i) {
//Кладем строку в память
MemorySegment ms = allocateArray(matrix[i], session);
//Заносим адрес начала строки в итоговый массив
memories[i] = ms.address().toRawLongValue();
}
//Берем адрес для итогового массива
MemorySegment ms = MemorySegment.allocateNative(8L * memories.length, session);
//Кладем в этот адрес значения нашего массива
ms.copyFrom(MemorySegment.ofArray(memories));
return ms; //Возвращаем сегмент где лежит наш массив адресов на массивы.
}
Прошу обратить внимание на метод MemorySegment.allocateNative(long, MemorySession):
- В качестве первого параметра long мы передаем то, сколько памяти мы хотим выделить (в байтах) (аналог malloc на С) - в нашем случае мы будем хранить в массиве значения double - 8 байт, значит 8*(размер массива) нам понадобится, если бы хранили int - 4*(размер массива) и т.д. Не путайте! Я сейчас говорю о коде метода allocateArray. В allocateMatrix указывается 8 байт потому что это размер адреса т.е. long. Допустим была бы это ссылка на массив каких-то объектов с несколькими полями, то для хранения адреса начала массива берем 8 байт, а уже внутри выделяем сумму "весов" полей объекта.
- MemorySession - сессия в памяти. По сути тот "пузырь" который API использует для этого вызова. По завершению его нужно "закрывать" - очищать память, благо MemorySession является AutoCloseable, так что проблем не будет.
package ru.semperante.javarushforeign;
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
public class NativeMatrixMultiplier {
private static final MethodHandle HANDLE;
private static final MethodHandle HANDLE_PARALLEL;
static {
System.loadLibrary("test_matrix");
FunctionDescriptor fd = FunctionDescriptor.of(
//Возвращаемый тип функции в C
ValueLayout.ADDRESS, //Адрес в памяти (size_t)
//Формальные параметры функции в C
ValueLayout.ADDRESS, //Адрес в памяти (size_t)
ValueLayout.ADDRESS, //Адрес в памяти (size_t)
ValueLayout.JAVA_INT, //int
ValueLayout.JAVA_INT, //int
ValueLayout.JAVA_INT, //int
ValueLayout.JAVA_INT //int
);
HANDLE = Linker.nativeLinker().downcallHandle(
SymbolLookup.loaderLookup().lookup("multiplyMatrix").orElseThrow(),
fd
);
HANDLE_PARALLEL = Linker.nativeLinker().downcallHandle(
SymbolLookup.loaderLookup().lookup("multiplyMatrixParallel").orElseThrow(),
fd
);
}
public static double[][] multiplyMatrix(double[][] A, double[][] B) throws Throwable {
return mult(A, B, HANDLE);
}
public static double[][] multiplyMatrixParallel(double[][] A, double[][] B) throws Throwable {
return mult(A, B, HANDLE_PARALLEL);
}
private static double[][] mult(double[][] A, double[][] B, MethodHandle handle) throws Throwable {
int width1 = A[0].length; //Ширина первый матрицы
int height1 = A.length; //Высота первой матрицы
int width2 = B[0].length; //Ширина второй матрицы
int height2 = B.length; //Высота второй матрицы
//Создаем "пузырь" в памяти
try (MemorySession session = MemorySession.openConfined()) {
//Кладем в память первую матрицы
MemorySegment m1 = NativeUtils.allocateMatrix(A, session);
//Кладем в память вторую матрицу
MemorySegment m2 = NativeUtils.allocateMatrix(B, session);
//Получаем адрес начала результата
MemoryAddress result = (MemoryAddress) handle.invoke(m1.address(), m2.address(),
width1, height1, width2, height2);
//Считываем результат в матрицу
return NativeUtils.readMatrix(result, width2, height1);
}
}
}
Здесь мы создаем сессию в памяти, кладем в неё наши матрицы, вызываем функцию и считываем результат (да чуть забежал вперед в коде)
5. Считываем результат
Теперь нам необходимо прогнать всё обратно: Взять адрес начала массива адресов, считать из этого массива адреса начала массивов с данными и перенести эти данные в Java.
Приступим:
/**
* Считываем матрицу
* @param from Адрес начала матрицы
* @param width Ширина матрицы
* @param height Высота матрицы
* @return Матрица в Java
*/
public static double[][] readMatrix(MemoryAddress from, int width, int height) {
double[][] result = new double[height][width];
for (int i = 0; i < height; ++i) {
//Считываем "очередной" адрес
MemoryAddress ms = MemoryAddress.ofLong(from.getAtIndex(ValueLayout.JAVA_LONG, i));
for (int j = 0; j < width; ++j) {
//Считываем значения из массива по адресу выше
result[i][j] = ms.getAtIndex(ValueLayout.JAVA_DOUBLE, j);
}
}
return result;
}
Готово.
Давайте проверим работоспособность: напишем простенький метод main:
package ru.semperante.javarushforeign;
import java.util.Arrays;
import java.util.Random;
public class JavaRushForeignMain {
private static final int N = 3;
private static final Random RANDOM = new Random();
public static void main(String... args) throws Throwable {
double[][] m1 = new double[N][N];
double[][] m2 = new double[N][N];
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
m1[i][j] = round2((RANDOM.nextDouble() + 1D) * 10D);
m2[i][j] = round2((RANDOM.nextDouble() + 1D) * 10D);
}
}
double[][] res = NativeMatrixMultiplier.multiplyMatrix(m1, m2);
System.out.println("=== m1 ===");
printMatrix(m1);
System.out.println("==========");
System.out.println("=== m2 ===");
printMatrix(m2);
System.out.println("==========");
System.out.println("=== res ===");
printMatrix(res);
System.out.println("==========");
}
private static void printMatrix(double[][] matrix) {
for (double[] d : matrix) {
System.out.println(Arrays.toString(d));
}
}
private static double round2(double a)
{
return (int)(a*100)/100.0;
}
}
Результат выполнения:
=== m1 ===
[11.93, 12.44, 10.15]
[17.74, 14.63, 17.73]
[12.27, 14.79, 13.18]
==========
=== m2 ===
[11.56, 19.64, 12.82]
[18.05, 19.46, 17.69]
[16.05, 17.4, 19.11]
==========
=== res ===
[525.3603, 652.9976, 566.9727]
[753.7124, 941.6153999999999, 825.0518]
[620.3397, 758.1282, 670.8063]
==========
Вручную проверять лень, а если верить онлайн калькулятору - верно (с погрешностью на округление double).
Всё! Наш вызов работает корректно. Теперь давайте немного "повеселимся" и сравним время работы последовательного и параллельного алгоритма на C вызванные таким методом и аналогичный код написанный на Java (с использованием ForkJoinPool).
Код для Java:
package ru.semperante.javarushforeign;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;
public class JavaMatrixMultiplier {
public static double[][] multiplyMatrix(double[][] A, double[][] B) {
double[][] res = new double[A.length][B[0].length];
for (int i = 0; i < A.length; ++i) {
for (int j = 0; j < B[0].length; ++j) {
for (int k = 0; k < B.length; ++k) {
res[i][j] += A[i][k] * B[k][j];
}
}
}
return res;
}
public static double[][] parallelMultiplyMatrix(double[][] A, double[][] B) {
try (ForkJoinPool pool = new ForkJoinPool()) {
double[][] res = new double[A.length][B[0].length];
for (int i = 0; i < A.length; ++i) {
final int idx = i;
pool.submit(() -> {
for (int j = 0; j < B[0].length; ++j) {
for (int k = 0; k < B.length; ++k) {
res[idx][j] += A[idx][k] * B[k][j];
}
}
});
}
pool.shutdown();
pool.awaitTermination(10000, TimeUnit.DAYS); //(почему бы и да)
return res;
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Да, код достаточно ленивый, без заморочек.
Теперь чутка поправим наш main:
public static void main(String... args) throws Throwable {
double[][] m1 = new double[N][N];
double[][] m2 = new double[N][N];
for (int i = 0; i < N; ++i) {
for (int j = 0; j < N; ++j) {
m1[i][j] = round2((RANDOM.nextDouble() + 1D) * 10D);
m2[i][j] = round2((RANDOM.nextDouble() + 1D) * 10D);
}
}
//Просто чтобы вызвался статический блок
new NativeMatrixMultiplier();
long time = System.currentTimeMillis();
NativeMatrixMultiplier.multiplyMatrix(m1, m2); //Последовательный С
System.out.printf("Native seq %dms.\n", System.currentTimeMillis() - time);
time = System.currentTimeMillis();
NativeMatrixMultiplier.multiplyMatrixParallel(m1, m2); //Параллельный С
System.out.printf("Native parallel %dms.\n", System.currentTimeMillis() - time);
time = System.currentTimeMillis();
JavaMatrixMultiplier.multiplyMatrix(m1, m2); //Последовательная Java
System.out.printf("Java seq %dms.\n", System.currentTimeMillis() - time);
time = System.currentTimeMillis();
JavaMatrixMultiplier.parallelMultiplyMatrix(m1, m2); //Параллельная Java
System.out.printf("Java parallel %dms.\n", System.currentTimeMillis() - time);
}
Замечательно! Прошу заметить, что я даю вызваться статическому блоку вне учета времени, так как он вызывается один раз за время выполнения программы и потому не зависит от размера матрицы, а значит особо значения для нас не имеет, как и инициализация самих матриц.
Так как будем использовать параллелизм, не лишним будет уточнить: на моем ноутбуке установлен процессор Ryzen 7 5700U: 8 ядер 16 потоков 4.4Ггц в турбо.
Для начала проверим, допустим, для N=5.
Native seq 8ms.
Native parallel 5ms.
Java seq 1ms.
Java parallel 5ms.
Как видим C не успевает за Java. Всё же затраты на перегонку туда-сюда памяти неплохо так влияют на нашего нативного друга, параллелизм так же себя особо не оправдывает т.к. объем вычислений слишком мал, чтобы покрыть затраты на параллелизм.
А теперь запустим при N = 1000 (матрицы 1000х1000)
Native seq 1539ms.
Native parallel 202ms.
Java seq 5346ms.
Java parallel 609ms.
Вот. Совсем другой результат. Натив выполнился в 3 раза быстрее Java, очень даже неплохо! И параллелизм себя оправдал - ваще шик!
Понятное дело с ростом N "выигрыш" будет только расти. Вот уже одна из причин использования такого подхода к реализации каких-то алгоритмов.
Немного побаловались и хватит
В данной статье я не буду рассматривать upcall'ы, т.к. они отдельная вообще тема для разговора. Возможно как-нибудь потом напишу отдельную статейку по ним, когда придумаю более-менее понятную задачу по их применению. В целом у Oracle есть пример, там +- понятно что это и зачем.
Выводы
В качестве итогов хочу отметить удобство использование данного API в сравнении с JNI - не нужно писать специальный код, который будет "мостом" для вызова. Также можно вызывать стандартные методы (в своем примере Oracle как раз это и показывали). Затраты на занесение данных в память достаточно малы, даже при больших объёмах данных не превышают нескольких миллисекунд, да и под капотом JNI тоже приходится таким заниматься на каком-то этапе.
В целом удобная и очень даже перспективная штука, особенно если научится вызывать С++ код. Зачем её можно использовать, я думаю, читатель для себя придумает сам, всё же не просто так он эту статью открыл.
Если есть какие-то вопросы или есть идеи, что ещё можно рассмотреть как дополнение к статье - с радостью прочту комментарии.
P.S.
Полный исходный код примера доступен тут. Сразу отмечу, что для запуска необходимо указать --enable-preview (т.к. FLA ещё в preview стадии) и ещё желательно --enable-native-access=ALL-UNNAMED - чтобы убрать предупреждения о нативных вызовах.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ