JavaRush /Java блог /Random /Java Foreign Linker API Работаем с памятью без перерывов ...
SemperAnte
4 уровень
Донецк

Java Foreign Linker API Работаем с памятью без перерывов и выходных

Статья из группы Random
Сразу хотелось бы оговорить, что данная статья не предназначена для новичков. Вот совсем. Если Вы решили полезть в вызов C-кода из Java, значит Вы хорошо знаете оба эти языка на высоком (а вернее, с программной точки зрения, низком) уровне: знания структуры памяти, адресации и т.д., если же я только что сказал что-то для Вас непонятное - добро пожаловать отсюда (с уважением, без агрессии, увидимся когда изучите матчасть). Данное "предупреждение" я решил внести, чтобы в дальнейшем говорить с читателем на одном языке и не расписывать каждую строку кода, здесь будет о чем поговорить и без этого. Также, для удобства буду сокращать Foreign Linker API в FLA Немного предыстории Из-за чего, собственно, я полез в это дело? Я писал магистерскую диссертацию на тему параллелизма с учетом времени доступа памяти (грубо говоря, расчет оценок параллелизма с учетом характеристик конкретной вычислительной системы). В этой работе я кроме проверки самого метода, анализировал разные подходы к реализации параллельных алгоритмов:
  • Java ExecutorService (fixed thread pool)
  • Java ForkJoinPool
  • JNI OMP
Грубо говоря сравнил методы в Java между собой и напоследок добавил вызов "параллельного" кода С через JNI. И тут выходит JDK 17 LTS в которой в инкубатор секции находился FLA (в общем-то он был ещё в версии 16, но 17 всё же LTS). И я недолго думая, решил, что добавить в магистерскую целый новый подраздел с новой API - это же ого-го! И актуальность и разнообразие и ваще... Сложность данного решения я осознал, когда любимое "ща загуглю" не работало, потому что API новое, информации что-то около 0, кроме, собственно, примеров из самой JEP и презентации Oracle, более ничего. Ну, а раз уже пообещал научруку, что будет - надо делать, потому пришлось разбираться и, собственно, результатами в данной статье я бы и хотел поделиться, тем более, что в релизе JDK 19 данная API перешла из инкубатора в Preview, а значит в целом уже более-менее актуально начинать её разбирать. Собственно, в примерах именно JDK 19 и использовался (уточнение, на случай изменений, т.к. с JDK17 было несколько изменений в структуре API из-за которых пришлось адаптировать код) Чуточку теории Сама Foreign Linker API создавалась как аналогия JNI, целью которой, по заверению разработчиков, является упростить вызов С (и перспективе С++) кода из динамически подключаемых библиотек (.dll, .so, .dylib). При помощи данной API мы создаем сегмент памяти в который кладем данные, будто бы созданные самим C и потом вызываем методы С указывая на данный сегмент как рабочий - грубо, возможно в чем-то не совсем корректно, но в целом примерно так и есть. Для понимания идеи рассмотрим сначала простой пример кода С:

int add(int a, int b)
{
   return a+b;
}
Для вызова этого кода с точки зрения машины, нам нужен адрес функции add в памяти. Обратившись по этому адресу, в качестве параметров вызова мы передаем 2 числа a и b, на каком-то, достаточно низком уровне, мы передаем адреса памяти в которых эти параметры лежат. В ответ получаем адрес где лежит результат (сумма). Т.е. для того чтобы вызвать этот код из Java, нам нужен инструмент с помощью которого мы:
  1. Загрузим адреса функций из библиотеки в память
  2. Найдем нужный адрес функции
  3. Положим в память 2 числа (a,b) и получим их адреса
  4. Вызовем функцию указав эти 2 адреса
  5. Считаем ответ из полученного адреса
Вот именно эти задачи и решает FLA. Точнее, первый пункт выполняется старым добрым System.loadLibrary(), а вот дальше уже FLA. Теперь к делу Для простоты возьмем алгоритм умножения двух матриц. Почему его? - Потому что передать в С двумерный массив - очень интересная задача, разобравшись в которой, можно сказать: "Я прошел Foreign Linker API" (почти...), потому что всё остальное передается по аналогии +- так же. Всё что нам нужно - понимание того, как тот или иной объект представляется в памяти С. Для начала напишем наш С-код. Прошу обратить внимание, именно С-код! На момент написания статьи данное API не умеет работать с библиотеками написанными на С++, хотя разработчики обещают добавить и такой функционал. Как культурные разрабы (и потому что IDE так сгенерировала) имеем 2 файла: library.h и library.cpp library.h

#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. Наполняем память Итак у нас матрица. Что такое матрица? Правильно, двумерный массив. А что такое двумерный массив? Массив массивов! И что это означает? Что у нас есть массив ссылок на другие массивы. Т.е. для начала занесем в память сами массивы со значениями (как бы строки) Java Foreign Linker API Работаем с памятью без перерывов и выходных - 1 Для каждой "строки" получаем адрес её начала и заносим в массив этих самых адресов, после чего получаем адрес начала массива адресов (уф блин). Т.к. система у меня 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, так что проблем не будет.
4. Вызываем функцию Для вызова функции нужно выполнить вызов handle.invoke(...) по аналогии с рефлексией в Java. Я сделал 2 реализации кода, потому у меня 2 "хендла", но остальная логика одинакова, потому сделаем 3 функции, чтобы было просто и красиво:

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 - чтобы убрать предупреждения о нативных вызовах.
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ