Главная » Хабрахабр » GraalVM: смешались в кучу C и Scala

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

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

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

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

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

04 LTS (64 bit). Сразу оговорюсь, что всё будет происходить на Ubuntu 18. Впрочем, даже если сейчас нет, наверное, появятся потом. С 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

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

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

Скрытый текст

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

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

Попробуем создать 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-консоли (её конфигурация уже добавлена в проект).

Polyglot reference — это прекрасно, но нужна документация по API. В итоге, мы почти (на целых 80%) перекатали пример аж из целых пяти содержательных строчек — самое время откинуться на спинку табуретки и почитать наконец что же мы понаписали Javadoc, тем более, что просто вызывать main() как-то скучно, и вообще, наш модельный пример — SQLite, поэтому надо понять, что именно писать вместо пятой строки. Чтобы её найти, нужно походить по репозиторию, там есть 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-биткодом. Reader. Также можно использовать непосредственно строку с исходником (это мы уже видели), URL (в том числе, из ресурсов в JAR-файле), и просто экземпляр java.io. В соответствии с документацией на этот метод, мы никогда не получим null, но существует Value, которое представляет собой Null. Далее, мы вычисляем полученный source в контексте, и получаем Value. Но нам всё же нужно загрузить что-то конкретное, поэтому...

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

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

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

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

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

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

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-ки вполне себе типизированный).

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

Пробуем: В итоге во встроенных биндингах 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 на JavaScript: Вот только есть одна загвоздка — некий callback.

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); }
}

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Классный тимлид ответит за сервис

Кто такой тимлид в Яндексе? Чем хороший отличается от плохого и стоит ли приглашать на эту должность человека со стороны — в нашем интервью с Алексеем Шаргаевым, занимающим одну из руководящих должностей в поисковых службах Яндекса. — Чем ты занимаешься ...

Job system и поиск пути

Карта В предыдущей статье я разобрал, что из себя представляет новая система задач Job system, как она работает, как создавать задачи, наполнять их данными и выполнять многопоточные вычисления и лишь в двух словах объяснил где можно использовать эту систему. В ...