Pull to refresh

GraalVM: смешались в кучу C и Scala

Reading time 12 min
Views 7.9K

Не знаю, как на вас, а на меня в последнее время производят сильное впечатление статьи про новые Java-технологии — Graal, Truffle и все-все-все. Выглядит так, как будто раньше ты придумал язык, написал интерпретатор, порадовался какой язык хороший и погрустил, какой медленный, написал к нему нативный компилятор и/или JIT, а ведь нужен ещё отладчик… LLVM есть, и на том спасибо. После прочтения этой статьи сложилось (несколько гротескное) впечатление, что после написания интерпретатора специального вида работу можно, в принципе, и завершать. Ощущение, что теперь кнопка "Сделать зашибись" стала доступна и программистам-компиляторщикам. Нет, конечно, JIT-языки медленно стартуют, им нужно время на прогрев. Но, в конце концов, время и квалификация программиста тоже не бесплатные — в каком бы мире информационных технологий мы бы жили, если бы до сих пор писали всё на ассемблере? Нет, может, всё бы, конечно, и летало (это если программист грамотно инструкции разложил), но вот насчёт суммарной сложности активно используемых программ у меня есть некоторые сомнения...


В общем, я прекрасно понимаю, что в дилемме «затраченное программистом время vs идеальность полученного продукта ("ручная работа")» границу можно двигать до скончания веков, поэтому давайте сегодня просто попробуем воспользоваться традиционной библиотекой SQLite без подгрузки нативного кода в чистом виде. Будем использовать уже готовую truffle-реализацию языка для LLVM IR, зовущуюся Sulong.


Disclaimer: эту статью нужно рассматривать не как рассказ профи новичкам, а как своего рода лабораторную работу такого же новичка, который только пытается освоиться с технологией. И ещё: считать LLVM IR полностью платформенно-независимым нельзя.


Итак, нам нужно будет взять, собственно, исходники SQLite, написать связующий код на JavaScala (ну, извините...), а также раздобыть GraalVM с обвязкой и Clang (с его помощью мы скомпилируем SQLite в LLVM IR, который будем подгружать в наш код на Scala).


Сразу оговорюсь, что всё будет происходить на Ubuntu 18.04 LTS (64 bit). С Mac OS X больших проблем, хочется верить, тоже не возникнет, а вот есть ли Graal и все его необходимые компоненты под Windows, я не уверен. Впрочем, даже если сейчас нет, наверное, появятся потом.


Подготовка


  1. Качаем нашего подопытного кролика SQLite (на самом деле, в приложенном к статье репозитории уже всё есть).
  2. Читаем официальную статью SQLite In 5 Minutes Or Less. Поскольку SQLite в данном случае используется только в качестве примера, то как раз то, что нужно. How To Compile SQLite тоже пригодится.
  3. Скачиваем GraalVM Community Edition отсюда и распаковываем его. Я бы не рекомендовал поддаваться на провокации добавить его в PATH — зачем нам node и lli, идентичные натуральным?
  4. Устанавливаем clang — в моём случае это Clang 6 из штатного репозитория Ubuntu

Также в моём тестовом проекте будет использоваться система сборки sbt. Для редактирования проекта лично я предпочитаю IntelliJ Idea Community со штатным Scala-плагином.


И вот тут лично у меня начались первые грабли: на сайте GraalVM сказано, что это просто каталог с JDK. Ну, раз так — то и в Идею его добавлю как простой JDK. «1.8» — сказала Идея. Хм… Странно. Заходим в консоль в каталог с Граалем, говорим bin/javac -version — действительно 1.8. Ну восемь, так восемь — не страшно. Страшно то, что пакеты org.graal и всё такое Идея не видит, а они нам понадобятся. Что же, идём в File -> Other Settings -> Default Project Structure..., там в настройках JDK видим, что в Classpath лежат jar-файлы из jre/lib и jre/lib/ext. Все ли — не проверял. А вот что предположительно нам нужно:


Скрытый текст
trosinenko@trosinenko-pc:~/tmp/graal/graalvm-1.0.0-rc1/jre/lib$ find . -name '*.jar'
./truffle/truffle-dsl-processor.jar
./truffle/truffle-api.jar
./truffle/truffle-nfi.jar
./truffle/locator.jar
./truffle/truffle-tck.jar
./polyglot/polyglot-native-api.jar
./boot/graaljs-scriptengine.jar
./boot/graal-sdk.jar
./management-agent.jar
./rt.jar
./jsse.jar
./resources.jar
./jvmci/jvmci-hotspot.jar
./jvmci/graal.jar
./jvmci/jvmci-api.jar
./installer/installer.jar
./ext/cldrdata.jar
./ext/sunjce_provider.jar
./ext/nashorn.jar
./ext/sunec.jar
./ext/zipfs.jar
./ext/sunpkcs11.jar
./ext/jaccess.jar
./ext/localedata.jar
./ext/dnsns.jar
./jce.jar
./svm/builder/objectfile.jar
./svm/builder/svm.jar
./svm/builder/pointsto.jar
./svm/library-support.jar
./graalvm/svm-driver.jar
./graalvm/launcher-common.jar
./graalvm/sulong-launcher.jar
./graalvm/graaljs-launcher.jar
./charsets.jar
./jvmci-services.jar
./security/policy/unlimited/US_export_policy.jar
./security/policy/unlimited/local_policy.jar
./security/policy/limited/US_export_policy.jar
./security/policy/limited/local_policy.jar

Из итого листинга мы видим ещё некоторое количество подкаталогов, причём, судя по тому, что было добавлено для обычного JDK, ./security нас не интересует. В таком случае, методом «"+"-развернул-каталог-shift-click-click, OK» добавим содержимое подкаталогов truffle, polyglot, boot и graalvm. Если что-то потом не найдётся — ещё добавим — дело-то житейское...


Создаём проект на Scala


Итак, кажется, Идею настроили. Попробуем создать sbt-проект. Собственно, подводных камней никаких нет, всё интуитивно, главное — не забыть указать наш новый JDK.


Теперь просто создаём новый scala-файл и копипастим творчески перерабатываем код, написанный в Polyglot reference в разделе Start Language Java, кликнув в Target Language — LLVM.


Кстати, рекомендую обратить внимание на обилие других Start Language: JavaScript, R, Ruby и даже просто C, но это уже совсем другая история, которую я пока не читал...


object SQLiteTest {
  val polyglot = Context.newBuilder().allowAllAccess(true).build()
  val file: File = ???
  val source = Source.newBuilder("llvm", file).build()
  val cpart = polyglot.eval(source)
  ???
}

Не будем наследовать наш object от App или делать поля приватными — тогда к ним можно будет обращаться из Scala-консоли (её конфигурация уже добавлена в проект).


В итоге, мы почти (на целых 80%) перекатали пример аж из целых пяти содержательных строчек — самое время откинуться на спинку табуретки и почитать наконец что же мы понаписали Javadoc, тем более, что просто вызывать main() как-то скучно, и вообще, наш модельный пример — SQLite, поэтому надо понять, что именно писать вместо пятой строки. Polyglot reference — это прекрасно, но нужна документация по API. Чтобы её найти, нужно походить по репозиторию, там есть readme, а в них — ссылки на Javadoc.


А пока смысл написанного нам ещё не ясен, спросим у JS Ответ на Главный Вопрос: выбираем в Идее конфигурацию Scala console, и...


scala> import org.graalvm.polyglot.Context

val polyglot = Context.newBuilder().allowAllAccess(true).build()
polyglot.eval("js", "6 * 7")
import org.graalvm.polyglot.Context

scala> polyglot: org.graalvm.polyglot.Context = org.graalvm.polyglot.Context@68e24e7

scala> res0: org.graalvm.polyglot.Value = 42

… ну, всё работает, Ответ есть. А Вопрос оставим в качестве упражнения читателю.


Вернёмся к коду примера. Переменная polyglot содержит контекст, в котором живут разные языки — кто-то выключен, кто-то включён, а кто-то уже даже лениво инициализировался. В этом суровом мире даже для доступа к файлам надо просить разрешение, поэтому в примере мы просто отключаем ограничения с помощью allowAllAccess(true).


Далее мы создаём объект Source с нашим LLVM-биткодом. Мы указываем язык и файл, откуда загрузить этот "исходный код". Также можно использовать непосредственно строку с исходником (это мы уже видели), URL (в том числе, из ресурсов в JAR-файле), и просто экземпляр java.io.Reader. Далее, мы вычисляем полученный source в контексте, и получаем Value. В соответствии с документацией на этот метод, мы никогда не получим null, но существует Value, которое представляет собой Null. Но нам всё же нужно загрузить что-то конкретное, поэтому...


Собираем SQLite


… Think of SQLite not as a replacement for Oracle but as a replacement for fopen()
— Из About SQLite. Как видите, позволить запускать в GraalVM SQLite не было страшной ошибкой для разработчиков.


По советам из уже упоминавшейся части документации SQLite, а также инструкции Graal составим командную строку. Вот она:


clang -g -c -O1 -emit-llvm sqlite3.с \
        -DSQLITE_OMIT_LOAD_EXTENSION \
        -DSQLITE_THREADSAFE=0 \
        -o ../../sqlite3.bc

Оптимизация хотя бы -O1 требуется для корректной работы кода внутри Sulong, -g сохранит нам имена (по поводу этих двух, а также других опций подробнее читайте в документации), SQLITE_OMIT_LOAD_EXTENSION мы используем, чтобы не зависеть от libdl.so в нашем тестовом примере (как бы мы вообще это делали, с ходу не ясно), а поскольку с pthread линковаться непонятно как, да и зачем, то и thread safety отключаем (иначе при запуске оно завершится с ошибкой). Вот и всё.


Запускаем наш проект


Теперь у нас есть, что вписать во вторую строчку:


  val file: File = new File("./sqlite3.bc")

Теперь мы можем вытащить необходимые функции из библиотеки:


  val sqliteOpen = cpart.getMember("sqlite3_open")
  val sqliteExec = cpart.getMember("sqlite3_exec")
  val sqliteClose = cpart.getMember("sqlite3_close")
  val sqliteFree = cpart.getMember("sqlite3_free")

И оно работает — осталось всего лишь вызвать их в правильном порядке — и всё! Ну, например, sqlite3_open требует строку с именем файла и указатель на указатель на структуру (внутренности которой нас не интересуют от слова совсем). Хм… и как сформировать второй аргумент? Нужна функция создания указателей — наверное, она Sulong-специфична. Добавляем в Classpath sulong.jar, перезапускаем sbt shell целиком. И ничего. Долго ли, коротко ли, не нашёл ничего умнее создать каталог lib в корне проекта sbt (стандартный каталог для unmanaged jars) и выполнить в нём


find ../../graalvm-1.0.0-rc1/jre/languages/ -name '*.jar' -exec ln -s {} . \;

После sbt refresh компиляция завершилась успешно. Вот только не запускается ничего… Ладно, возвращаем Classpath на место. В общем, думал, допишу пятую строчку. Ну хорошо, перескажу Javadoc по каждой из пяти, получится небольшая статья, и все скажут: "У нас тут Твиттер что ли?"...


Прошло, наверное, часа три, а я всё пытался обернуть у функции sqlite3_open второй аргумент...


В какой-то момент меня осенило: надо как в анекдоте: «Что же ты с "Войны и мира" начинаешь, почитай "Колобок" — как раз для твоего уровня»… Так sqlite3.c временно был заменён на test.c


void f(int *x) {
  *x = 42;
}

Потыкавшись ещё немного во всякие API преобразования типов разной степени приватности, я, мягко говоря, утомился. В голове остались одни анекдоты. Например такой: "iOS — интуитивно понятная система. Чтобы её понять, логика бессильна — нужна интуиция". И действительно, какой главный принцип GraalVM и вот этого всего — всё должно быть прозрачно и ненапряжно, поэтому надо отбросить малейший опыт работы с FFI и думать как разработчик удобной системы. Нам нужен контейнер с интом. Передаём new java.lang.Integer(0) — запись по нулевому адресу. Но чему нас учили на азах C: разница между массивом и указателем на нулевой элемент весьма условна. Фактически, функция f просто принимает массив интов и записывает в нулевой элемент значение. Пробуем:


scala> val x = Array(new java.lang.Integer(12))
x: Array[Integer] = Array(12)

scala> SQLiteTest.cpart.getMember("f").execute(x)
res0: org.graalvm.polyglot.Value = LLVMTruffleObject(null:0)

scala> x
res1: Array[Integer] = Array(42)

ТАДАМ!!!


Тут, казалось бы, быстро написать функцию query и закончить на этом, но что ни передавай в качестве второго аргумента: ни Array(new Object), ни Array(Array(new Object)) — работать оно отказывается, ругаясь на strlen внутри LLVM-биткода O_O (кстати, LLVM IR, в отличие от обычного машинного кода из so-ки вполне себе типизированный).


Ещё энное время спустя я перестал откидывать мысль о том, что просто передать в execute() в качестве первого аргумента java.lang.String и даже Array[Byte] — это уж слишком интуитивно, и переделка нашей void f() это подтвердила.


В итоге во встроенных биндингах Sulong-а (SQLiteTest.polyglot.getBindings("llvm")) была найдена функция с многообещающим именем __sulong_byte_array_to_native. Пробуем:


val str = SQLiteTest.polyglot.getBindings("llvm")
              .getMember("__sulong_byte_array_to_native")
              .execute("toc.db".getBytes)
val db = new Array[Object](1)
SQLiteTest.sqliteOpen.execute(str, db)

scala> str: org.graalvm.polyglot.Value = LLVMTruffleObject(null:139990504321152)

scala> db: Array[Object] = Array(null)

scala> res0: org.graalvm.polyglot.Value = 0

scala> val str = SQLiteTest.polyglot.getBindings("llvm")
                    .getMember("__sulong_byte_array_to_native")
                    .execute("toc123.db".getBytes)
SQLiteTest.sqliteOpen.execute(str, db)
str: org.graalvm.polyglot.Value = LLVMTruffleObject(null:139990517528064)

scala> res1: org.graalvm.polyglot.Value = 0

Работает!!! Ой, а почему с неправильным именем файла тоже работает?.. Затаив дыхание, смотрим в каталог проекта — а там уже лежит новенькая toc123.db. Ура!


Итак, перепишем пример из документации по SQLite на Scala:


  def query(dbFile: String, queryString: String): Unit = {
    val filenameStr = toCString(dbFile)
    val ptrToDb = new Array[Object](1)

    val rc = sqliteOpen.execute(filenameStr, ptrToDb)
    val db = ptrToDb.head

    if (rc.asInt() != 0) {
      println(s"Cannot open $dbFile: ${sqliteErrmsg.execute(db)}!")
      sqliteClose.execute(db)
    } else {
      val zErrMsg = new Array[Object](1)

      val execRc = sqliteExec.execute(db, toCString(queryString), ???, zErrMsg)

      if (execRc.asInt != 0) {
        val errorMessage = zErrMsg.head.asInstanceOf[Value]
        assert(errorMessage.isString)
        println(s"Cannot execute query: ${errorMessage.asString}")
        sqliteFree.execute(errorMessage)
      }
      sqliteClose.executeVoid(db)
    }
  }

Вот только есть одна загвоздка — некий callback. Ну, когда никто не видит, студент-инженер описывает сердечник из дерева, а я попробую написать callback на JavaScript:


  val callback = polyglot.eval("js",
    """function(unused, argc, argv, azColName) {
      |  print("argc = " + argc);
      |  print("argv = " + argv);
      |  print("azColName = " + azColName);
      |  return 0;
      |}
    """.stripMargin)
  // ...
     val execRc = sqliteExec.execute(db, toCString(queryString), callback, Int.box(0), zErrMsg)

И вот, что получаем:


io.github.trosinenko.SQLiteTest.query("toc.db", "select * from toc;")
argc = 5
argv = foreign {}
azColName = foreign {}
argc = 5
argv = foreign {}
azColName = foreign {}
argc = 5
argv = foreign {}
azColName = foreign {}

Ну, магии маловато. К тому же, оказывается, в случае ошибки в zErrMsg лежит какой-то непонятный объект, сам в строку не конвертирующийся. Что же, соберём и загрузим ещё lib.bc, а в его исходнике lib.c напишем следующее:


#include <polyglot.h>

void *fromCString(const char *str) {
  return polyglot_from_string(str, "UTF-8");
}

Почему polyglot_from_string недоступен прямо через bindings, я не понял, поэтому вытащим так и сделаем обвязку:


  val lib_fromCString = lib.getMember("fromCString")
  def fromCString(ptr: Value): String = {
    if (ptr.isNull)
      "<null>"
    else
      lib_fromCString.execute(ptr).asString()
  }

Ну, с возвратом сообщений об ошибках разобрались, а вот callback давайте всё же напишем на Scala:


  val lib_copyToArray = lib.getMember("copy_to_array_from_pointers")
  val callback = new ProxyExecutable {
    override def execute(arguments: Value*): AnyRef = {
      val argc = arguments(1).asInt()

      val xargv = new Array[Long](argc)
      val xazColName = new Array[Long](argc)
      lib_copyToArray.execute(xargv, arguments(2))
      lib_copyToArray.execute(xazColName, arguments(3))

      (0 until argc) foreach { i =>
        val name = fromCString(polyglot.asValue(xazColName(i) ^ 1))
        val value = fromCString(polyglot.asValue(xargv(i) ^ 1))

        println(s"$name = $value")
      }
      println("========================")

      Int.box(0)
    }
  }

При этом в наш lib.c добавим ещё такую магию перекладывания из сишного массива в Polyglot-овский:


void copy_to_array_from_pointers(void *arr, void **ptrs) {
  int size = polyglot_get_array_size(arr);
  for(int i = 0; i < size; ++i) {
    polyglot_set_array_element(arr, i, ((uintptr_t)ptrs[i]) ^ 1);
  }
}

Обратите внимание на указатель ^ 1 — нужно это потому, что кто-то слишком умный: а именно, polyglot_set_array_element — это variadic-функция ровно с тремя аргументами, которая принимает и примитивные типы, и указатели на Polyglot values. В итоге, оно работает:


io.github.atrosinenko.SQLiteTest.query("toc.db", "select * from toc;")
name = sqlite3
type = object
status = 0
title = Database Connection Handle
uri = c3ref/sqlite3.html
========================
name = sqlite3_int64
type = object
status = 0
title = 64-Bit Integer Types
uri = c3ref/int64.html
========================
name = sqlite3_uint64
type = object
status = 0
title = 64-Bit Integer Types
uri = c3ref/int64.html
========================
...

Осталось добавить метод main:


  def main(args: Array[String]): Unit = {
    query(args(0), args(1))
    polyglot.close()
  }

в котором, вообще-то, контекст нужно закрыть, но в самом объекте я этого не делал, поскольку после инициализации SQLiteTest он нам, естественно, ещё нужен для Scala-консоли.


На этом я завершаю свой рассказ, а читателю предлагаю:


  1. Попробовать собрать это всё с помощью SubstrateVM в нативный бинарник, будто и не было тут никакой Scala
  2. (*) Сделать то же самое, но с profile guided optimization

Получившиеся в итоге файлы:


SQLiteTest.scala
package io.github.atrosinenko

import java.io.File

import org.graalvm.polyglot.proxy.ProxyExecutable
import org.graalvm.polyglot.{Context, Source, Value}

object SQLiteTest {
  val polyglot: Context = Context.newBuilder().allowAllAccess(true).build()

  def loadBcFile(file: File): Value = {
    val source = Source.newBuilder("llvm", file).build()
    polyglot.eval(source)
  }
  val cpart: Value = loadBcFile(new File("./sqlite3.bc"))
  val lib:   Value = loadBcFile(new File("./lib.bc"))

  val sqliteOpen:   Value = cpart.getMember("sqlite3_open")
  val sqliteExec:   Value = cpart.getMember("sqlite3_exec")
  val sqliteErrmsg: Value = cpart.getMember("sqlite3_errmsg")
  val sqliteClose:  Value = cpart.getMember("sqlite3_close")
  val sqliteFree:   Value = cpart.getMember("sqlite3_free")

  val bytesToNative: Value = polyglot.getBindings("llvm").getMember("__sulong_byte_array_to_native")
  def toCString(str: String): Value = {
    bytesToNative.execute(str.getBytes())
  }

  val lib_fromCString: Value = lib.getMember("fromCString")
  def fromCString(ptr: Value): String = {
    if (ptr.isNull)
      "<null>"
    else
      lib_fromCString.execute(ptr).asString()
  }

  val lib_copyToArray: Value = lib.getMember("copy_to_array_from_pointers")
  val callback: ProxyExecutable = new ProxyExecutable {
    override def execute(arguments: Value*): AnyRef = {
      val argc = arguments(1).asInt()
      val xargv = new Array[Long](argc)
      val xazColName = new Array[Long](argc)
      lib_copyToArray.execute(xargv, arguments(2))
      lib_copyToArray.execute(xazColName, arguments(3))

      (0 until argc) foreach { i =>
        val name  = fromCString(polyglot.asValue(xazColName(i) ^ 1))
        val value = fromCString(polyglot.asValue(xargv(i) ^ 1))
        println(s"$name = $value")
      }
      println("========================")
      Int.box(0)
    }
  }

  def query(dbFile: String, queryString: String): Unit = {
    val filenameStr = toCString(dbFile)
    val ptrToDb = new Array[Object](1)
    val rc = sqliteOpen.execute(filenameStr, ptrToDb)
    val db = ptrToDb.head
    if (rc.asInt() != 0) {
      println(s"Cannot open $dbFile: ${fromCString(sqliteErrmsg.execute(db))}!")
      sqliteClose.execute(db)
    } else {
      val zErrMsg = new Array[Object](1)
      val execRc = sqliteExec.execute(db, toCString(queryString), callback, Int.box(0), zErrMsg)
      if (execRc.asInt != 0) {
        val errorMessage = zErrMsg.head.asInstanceOf[Value]
        println(s"Cannot execute query: ${fromCString(errorMessage)}")
        sqliteFree.execute(errorMessage)
      }
      sqliteClose.execute(db)
    }
  }

  def main(args: Array[String]): Unit = {
    query(args(0), args(1))
    polyglot.close()
  }
}

lib.c
#include <polyglot.h>

void *fromCString(const char *str) {
  return polyglot_from_string(str, "UTF-8");
}

void copy_to_array_from_pointers(void *arr, void **ptrs) {
  int size = polyglot_get_array_size(arr);
  for(int i = 0; i < size; ++i) {
    polyglot_set_array_element(arr, i, ((uintptr_t)ptrs[i]) ^ 1);
  }
}

Ссылка на репозиторий.

Tags:
Hubs:
+17
Comments 3
Comments Comments 3

Articles