Русский
Русский
English
Статистика
Реклама

Байт-код

Опыт оптимизации вычислений через динамическую генерацию байт-кода JVM

19.08.2020 12:13:25 | Автор: admin
В своем небольшом проекте по моделированию случайных величин я столкнулся с проблемой низкой производительности вычисления математических выражений, вводимых пользователем, и долго искал разные способы ее решения: попробовал написать интерпретатор на С++ в надежде, что он будет быстрым, сочинил свой байт-код. Наиболее удачной идеей оказалась генерация классов JVM и их загрузка во время выполнения.

Узнав про KMath, я решил обобщить эту оптимизацию для множества математических структур и операторов, определенных на них.

KMath это библиотека для математики и компьютерной алгебры, активно использующая контекстно-ориентированное программирование в Kotlin. В KMath разделены математические сущности (числа, векторы, матрицы) и операции над ними они поставляются отдельным объектом, алгеброй, соответствующей типу операндов, Algebra<T>.

import scientifik.kmath.operations.*ComplexField {   (i pow 2) + Complex(1.0, 3.0)}

Таким образом, после написания генератора байт-кода, с учетом оптимизаций JVM можно получить быстрые расчеты для любого математического объекта достаточно определить операции над ними на Kotlin.

API


Для начала нужно было разработать API выражений и только после этого приступать к грамматике языка и синтаксическому дереву. Также появилась удачная идея определить алгебру над самими выражениями, чтобы представить более наглядный интерфейс.

База всего API выражений интерфейс Expression<T>, а простейший способ его реализовать прямо определить метод invoke от данных параметров или, например, вложенных выражений. Подобная реализация была интегрирована в корневой модуль как справочная, хоть и самая медленная.

interface Expression<T> {   operator fun invoke(arguments: Map<String, T>): T}

Более продвинутые реализации уже основаны именно на MST. К ним относятся:

  • интерпретатор MST,
  • генератор классов по MST.

API для парсинга выражений из строки в MST уже доступно в dev-ветке KMath как и более или менее окончательный генератор кода JVM.

Перейдем к MST. Сейчас в MST представлены четыре вида узлов:

  • терминальные:
    • символы (то есть переменные)
    • числа;
  • унарные операции;
  • бинарные операции.



Первое, что с ним можно сделать, это обойти и посчитать результат по имеющимся данным. Передав в целевую алгебру ID операции, например +, и аргументы, например 1.0 и 1.0, мы можем надеяться на результат, если эта операция определена. В противном случае при вычислении выражение упадет с исключением.



Для работы с MST, помимо языка выражения, еще есть алгебра например MstField:

import scientifik.kmath.ast.*import scientifik.kmath.operations.*import scientifik.kmath.expressions.*RealField.mstInField { number(1.0) + number(1.0) }() // 2.0

Результатом метода выше является реализация Expression<T>, при вызове вызывающая обход дерева, полученного при вычислении функции, переданной в mstInField.

Генерация кода


Но это не все: при обходе мы можем как угодно применять параметры дерева и не волноваться о порядке действий и арности операций. Именно это и используется для генерации байт-кода.

Генерация кода в kmath-ast это параметризованная сборка класса JVM. Входные данные MST и целевая алгебра, на выходе инстанс Expression<T>.

Соответствующий класс, AsmBuilder, и еще несколько extension-функций предоставляют методы для императивной сборки байт-кода поверх ObjectWeb ASM. С их помощью обход MST и сборка класса выглядят чисто и занимают менее 40 строк кода.

Рассмотрим сгенерированный класс для выражения 2*x, приведен декомпилированный из байт-кода исходник на Java:

package scientifik.kmath.asm.generated;import java.util.Map;import scientifik.kmath.asm.internal.MapIntrinsics;import scientifik.kmath.expressions.Expression;import scientifik.kmath.operations.RealField;public final class AsmCompiledExpression_1073786867_0 implements Expression<Double> {   private final RealField algebra;   public final Double invoke(Map<String, ? extends Double> arguments) {       return (Double)this.algebra.add(((Double)MapIntrinsics.getOrFail(arguments, "x")).doubleValue(), 2.0D);   }   public AsmCompiledExpression_1073786867_0(RealField algebra) {       this.algebra = algebra;   }}

Сначала здесь был сгенерирован метод invoke, в котором были последовательно расставлены операнды (т.к. они находятся глубже в дереве), потом вызов add. После invoke был записан соответствующий бридж-метод. Далее было записано поле algebra и конструктор. В некоторых случаях, когда константы нельзя просто положить в пул констант класса, записывается еще поле constants, массив java.lang.Object.

Впрочем, из-за множества крайних случаев и оптимизаций реализация генератора довольно сложная.

Оптимизация вызовов к Algebra


Чтобы вызвать операцию от алгебры, нужно передать ее ID и аргументы:

RealField { binaryOperation("+", 1.0, 1.0) } // 2.0

Однако такой вызов дорог по производительности: для того чтобы выбрать, какой метод вызвать, RealField выполнит сравнительно дорогую инструкцию tableswitch, а еще нужно помнить о боксинге. Поэтому, хотя все операции MST можно представить в такой форме, лучше делать прямой вызов:

RealField { add(1.0, 1.0) } // 2.0


Никакой особенной конвенции о маппинге ID операций к методам в реализациях Algebra<T> нет, поэтому пришлось вставить костыль, в котором вручную прописано, что +, например, соответствует методу add. Также есть поддержка для благоприятных ситуаций, когда для ID операции можно найти метод с таким же именем, нужным количеством аргументов и их типами.

private val methodNameAdapters: Map<Pair<String, Int>, String> by lazy {    hashMapOf(        "+" to 2 to "add",        "*" to 2 to "multiply",        "/" to 2 to "divide",...

private fun <T> AsmBuilder<T>.findSpecific(context: Algebra<T>, name: String, parameterTypes: Array<MstType>): Method? =    context.javaClass.methods.find { method ->...        nameValid && arityValid && notBridgeInPrimitive && paramsValid    }

Другая серьезная проблема это боксинг. Если мы посмотрим на Java-сигнатуры методов, которые получаются после компиляции того же RealField, увидим два метода:

public Double add(double a, double b)// $FF: synthetic method// $FF: bridge methodpublic Object add(Object var1, Object var2)

Конечно, легче не мучаться с боксингом и анбоксингом и вызвать бридж-метод: он тут появился из-за type erasure, чтобы правильно реализовывать метод add(T, T): T, тип T в дескрипторе которого на самом деле стерся до java.lang.Object.

Прямой вызов add от двух double тоже не идеален, потому что боксит возвращаемое значение (по этому поводу есть обсуждение в YouTrack Kotlin (KT-29460), но лучше вызывать именно его, чтобы в лучшем случае сэкономить два приведения типа входных объектов к java.lang.Number и их анбоксинга в double.

На решение этой проблемы ушло больше всего времени. Сложность здесь заключается не в создании вызовов к примитивному методу, а в том, что нужно сочетать на стеке и примитивные типы (как double), и их обертки (java.lang.Double, например), а в нужных местах вставлять боксинг (например, java.lang.Double.valueOf) и анбоксинг (doubleValue) здесь категорически не хватало работы с типами инструкций в байт-коде.

У меня были идеи навесить свою типизированную абстракцию поверх байт-кода. Для этого мне пришлось глубже разобраться в API ObjectWeb ASM. В итоге я обратился к бэкенду Kotlin/JVM, подробно изучил класс StackValue (типизированный фрагмент байт-кода, который в итоге приводит к получению какого-то значения на стеке операндов), разобрался с утилитой Type, которая позволяет удобно оперировать с типами, доступными в байт-коде (примитивы, объекты, массивы), и переписал генератор с ее использованием. Проблема, нужно ли боксить или анбоксить значение на стеке, решилась сама собой путем добавления ArrayDeque, хранящего типы, которые ожидаются следующим вызовом.

  internal fun loadNumeric(value: Number) {        if (expectationStack.peek() == NUMBER_TYPE) {            loadNumberConstant(value, true)            expectationStack.pop()            typeStack.push(NUMBER_TYPE)        } else ...?.number(value)?.let { loadTConstant(it) }            ?: error(...)    }

Выводы


В итоге мне удалось сделать генератор кода с помощью ObjectWeb ASM для вычисления выражений MST в KMath. Прирост производительности по сравнению с простым обходом MST зависит от количества узлов, так как байт-код линеен и в итоге не тратит время на выбор узла и рекурсию. Например, для выражения с 10 узлами разница во времени между вычислением с помощью сгенерированного класса и интерпретатора составляет от 19 до 30%.

Изучив проблемы, с которыми я столкнулся, я сделал следующие выводы:

  • нужно сразу изучить возможности и утилиты ASM они сильно упрощают разработку и делают код читаемым (Type, InstructionAdapter, GeneratorAdapter);
  • нет смысла тратить время на подсчет MaxStack самостоятельно, если это не критично для производительности, есть ClassWriter COMPUTE_MAXS и COMPUTE_FRAMES;
  • очень полезно изучать бэкенды компиляторов реальных языков;
  • следует разбираться в синтаксисе дескрипторов и, в частности, сигнатур, чтобы не ошибаться при использовании дженериков;
  • и наконец, далеко не во всех случаях нужно лезть настолько глубоко есть более удобные способы работать с классами в рантайме, например, ByteBuddy и cglib.

Cпасибо, что прочитали.

Авторы статьи:

Ярослав Сергеевич Постовалов, МБОУ Лицей 130 им. академика Лаврентьева, участник лаборатории математического моделирования под руководством Войтишека Антона Вацлавовича

Татьяна Абрамова, исследователь лаборатории методов ядерно-физических экспериментов в JetBrains Research.
Подробнее..

Новый язык программирования Relax

04.04.2021 14:14:58 | Автор: admin

Вступление

Всем привет, я являюсь автором языка программирования Relax. На данный момент я разрабатываю RVM(RelaxVirtualMachine) И Relasm(Relax Assembly). Первые попытки сделать свой язык начались в конце лета 2020, тогда я и не думал что делать язык - это так сложно. Сам же проект Relax начался 30 декабря 2020 года. Прошло полтора месяца, а на нем уже можно написать что-нибудь простенькое.

первое лого языкапервое лого языка

Как компилировать код?

Начнем с того, что файлы relasm лучше сохранять с расширением .rasm, файлы байт-кода - .ree. Для того чтобы скомпилировать и запустить код нужно скачать 3 файла: Relasm.exe, RelaxVM.exe, QtCore.dll. Сделать вы это сможете вот по этим ссылкам: https://github.com/UnbelievableDevelopmentCompany/RVM/tree/master/x64/Release
https://github.com/UnbelievableDevelopmentCompany/Relasm/tree/master/x64/Release

После того как скачали, желательно добавить эти 3 файла в любую папку, которая есть в переменной PATH(или же создать новую папку). Далее в cmd переходим в папку с программой на Relasm и вводим следующие команды:

Relasm main.rasm program.reeRelaxVM program.ree

Первая команда компилирует relasm в байт-код, а вторая уже запускает программу.

Примеры кода на Relasm

Как же выглядит код на Relasm?

mclass MainClassmethod public static void MainClass.Main():.maxstack 1push.str "hello world"callm std static Relax.Console.Write(Relax.String)

Это самая простая программа - hello world! Давайте пройдемся по коду. Первая строчка создает главный класс, в котором обязана быть функция Main(начало выполнения). Во второй строчке мы как раз таки создаем этот метод. Следующие строчки - это тело метода, так как пишутся с табуляцией в начале. Третья строчка кода указывает, что максимальное количество объектов, которые могут находится на стеке равно 1. Четвертая строчка кода добавляет строку "hello world" в стек. Ну и наконец пятая строчка вызывает метод вывода строки на консоль. Строка берется из стека, как и любые другие аргументы в Relasm. Я не буду подробно останавливаться на каждой детали в этом коде.

Хорошо, мы написали hello world, теперь можно что-нибудь по серьёзнее.

mclass MainClassmethod public static void MainClass.Main():.maxstack 2; Объявление переменныхlocal firstNum Relax.Int32local secondNum Relax.Int32local result Relax.Int32local op Relax.String; Получение первого числаcallm std static Relax.Console.Read()callm std static Relax.Converter.StringToInt32(Relax.String)set firstNum; Получение знака операцииcallm std static Relax.Console.Read()set op; Получение второго числаcallm std static Relax.Console.Read()callm std static Relax.Converter.StringToInt32(Relax.String)set secondNum; Проверки на знаки операций; Проверка на сложениеget oppush.str "+"callm std instance Relax.String.operator==(Relax.String)jmpif opAdd; Проверка на вычитаниеget oppush.str "-"callm std instance Relax.String.operator==(Relax.String)jmpif opSub; Проверка на произведениеget oppush.str "*"callm std instance Relax.String.operator==(Relax.String)jmpif opMul; Проверка на делениеget oppush.str "/"callm std instance Relax.String.operator==(Relax.String)jmpif opDivopAdd: ; Сумма чиселget firstNumget secondNumaddset resultjmp endopSub: ; Разность чиселget secondNumget firstNumsubset resultjmp endopMul: ; Произведение чиселget firstNumget secondNummulset resultjmp endopDiv: ; Деление чиселget secondNumget firstNumdivset resultjmp endend: ; вывод результата на экранpush.str "\nResult: "callm std static Relax.Console.Write(Relax.String)get resultcallm std static Relax.Console.Write(Relax.Int32)

Это простой калькулятор. Сначала мы создаем все переменные. Затем считываем данные с консоли. Далее определяем какую операцию нужно выполнять и в зависимости от этого переходим на нужную метку. В каждой метке операции мы получаем 2 числа, выполняем определенную операцию устанавливаем результат в переменную result и переходим в метку end, в которой мы выводим результат в консоль.

Теперь давайте сделаем свой собственный метод.

mclass MainClassmethod public static void MainClass.Main():.maxstack 2; Помещаем аргументы для нашего метода на стекpush.int32 10push.str "Result - "; Вызываем методcallm usr static MainClass.StringPlusInt32(Relax.String, Relax.Int32); Возвращаемый результат выводим на консольcallm std static Relax.Console.Write(Relax.String)method public static Relax.String MainClass.StringPlusInt32(Relax.String str, Relax.Int32 num):.maxstack 2get numcallm std static Relax.Converter.Int32ToString(Relax.Int32) ; конвертируем число в строкуget strcallm std instance Relax.String.Concat(Relax.String) ; добавляем в переменной str конвертированное значениеreturn ; возвращаем результат

Метод StringPlusInt32 нужен для того, чтобы конкатенировать строку и число, для этого мы преобразуем число в строку при помощи метода Relax.Converter.Int32ToString и конкатенируем параметр str с числом, преобразованным в строку. И возвращаем результат при помощи инструкции return. Далее в методе Main просто выводим этот результат в консоль.

Вывод

Relax'у всего лишь полтора месяца, а он уже такое может. Он будет развиваться еще долго. Но даже сейчас можно писать простенькие консольные программы.

Репозиторий виртуальной машины(там есть документация relasm) - https://github.com/UnbelievableDevelopmentCompany/RVM

Репозиторий компилятора Relasm - https://github.com/UnbelievableDevelopmentCompany/Relasm

Пакет для sublime text 3 - RelasmST3Package

Подробнее..
Категории: C++ , Qt , Assembler , Байт-код , Relax , Relasm , Яп , Pl , Udc , Lofectr

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru