Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Производительность (JVM)

Байт-код JVM

Генерируемый байт-код Libretto в целом соответствует уровню байт-кода типичной компилируемой Java-программы. Но есть особенности как Libretto, так и его компилятора текущей версии, которые ухудшают производительность в сравнении с ручным написанием Java-программы.

Часть этих особенностей возможно исправить по мере развития компилятора Libretto. Другая часть определяется семантикой Libretto.

Пустые () значения

Если тип имеет кардинальность ? или является (), то для представления пустоты используется null, что эффективно. Если тип имеет кардинальность *, то для пустоты используется специальный объект в единственном экземпляре, представляющий пустую последовательность. Это позволяет использовать единообразный код (не обрабатывать null отдельно).

Такая особенность может как положительно, так и отрицательно влиять на производительность. Но не планируется изменять такое отображение типов.

unit значение

Для unit значения используется единственный экземпляр специального объекта, что достаточно эффективно, но не так эффективно как работа с () типом и его значением (). Поэтому для методов, которые должны хоть что-то вернуть (аналог void), предпочтительно использовать (), а не unit - это и является рекомендаций для Libretto.

unit же используется чаще с типом Unit? как заменитель true.

Int/Real типы чисел

Обычные типы чисел Libretto (Int для целых и Real для дробных) всегда используют представление с большой разрядностью: java.math.BigInteger для Int и java.math.BigDecimal для Real. Поэтому вычисления Libretto будут более медленные, чем вычисления Java на примитивных типах (int, float и т.д.). Кроме того, будет больше расход памяти (эти объекты всегда создаются в heap и имеют больший размер).

В качестве альтернативы можно использовать типы чисел из пакета libretto/num. Они имеют фиксированную разрядность, поэтому имеют меньший размер и скорость вычисления ценой потери точности. Как бонус в Libretto возможно использование беззнаковых типов, которых изначально нет в Java.

В текущей реализации компилятора Libretto числа фиксированной разрядности всегда используются как box-объекты (java.lang.Integer, java.lang.Float и т.д.). Даже при использовании типа без указания кардинальности, поэтому создаются в heap, что приводит к лишним расходам памяти и потери скорости. Это не ограничение языка, а упрощение компилятора, что может быть исправлено в будущих версиях без изменения поведения.

Строки (String) и символы

В Libretto строки позволяют использовать символы с кодом больше 0xFFFF, которые на уровне Java представлены двумя char символами (кодирование UTF-16), но которые на уровне Libretto выглядят именно как один символ.

В настоящее время на уровне Libretto отсутствует тип для отдельного символа. Для представления символа используется тоже строка, но из одного символа (как результат разбиения строки на символы) или числовой код символа (как результат разбиения строки на коды). Это, конечно, понижает производительность работы со строками по сравнению с чистой Java, если считается, что символ всегда содержится только в одном char (что может ошибочно на некоторых данных).

Другая вытекающая из этого особенность Libretto - это необходимость более медленной работы при посимвольном проходе по строке, поскольку нужно определять, сколько char занимает тот или иной символ. В текущей версии Libretto оптимизируется проход по строке путём использования двух видов строк: простые строки, где символы только из одного char и остальные (сложные, UTF-16) строки. Но некоторая потеря производительности всё равно есть даже при работе только с простыми строками.

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

Литералы

Поскольку литералы Libretto типов String, Int, Real представлены классами Java, то используется оптимизация повторного использования. Для кэширования объекты заданы как значения статических полей нескольких служебных классов, создаваемых в результате компиляции. Поэтому повторное использование литералов в Libretto достаточно эффективное, без лишнего повторного создания. Но первоначальная загрузка может быть немного дольше из-за необходимости создать сразу несколько значений литералов.

Очень длинные литералы (ограничение 64КБ в UTF8) в Libretto при инициализации упомянутых служебных классов собираются из небольших (допустимых) кусков строк.

Структуры

Для структуры без полей используется один и тот же экземпляр. Экземпляры структур с полями повторно не используются, создаются заново. Но в будущем возможна оптимизация, когда уже существующие экземпляры структур с полями могут быть использованы повторно (с сохранением поведения сравнения, в том числе с учётом equalityKey).

Объединение

Из-за ограничений системы типов Java нельзя напрямую отобразить объединение типов Libretto. Взамен используется работа с Object в случае объединения с дальнейшим кастингом к нужному типу после проверок. Это, конечно, менее эффективно, чем использовать иерархию классов или интерфейсов Java, но даёт гораздо больше возможностей.

var переменные

Переменные, определяемые при помощи var, всегда используют вспомогательный объект-контейнер. Это упрощает реализацию замыкания (closure) и передачи ссылок-буферов (todo уточнить), поскольку ссылка на этот объект-контейнер является неизменяемой.

Но для типов без кардинальности или с кардинальностью ? такое представление не является эффективным в сравнении с хранением переменных на стеке (что возможно в Java для примитивных типов или null). Поэтому вычисления в Libretto в таком случае даже при использовании типов из libretto/num более медленные, чем в Java при использовании примитивных типов, больше и расход памяти. Возможно, такое поведение будет исправлено в будущих версиях компилятора.

Для var с кардинальностью +/* используется буфер copy-on-write на базе растущего массива (подобно java.util.ArrayList создаётся новый увеличенный массив, когда предыдущий заполнен). Поэтому эффективными являются:

  1. Присваивание или первоначальная инициализация без дальнейшего изменения.

    var temp = seq // здесь не будет копирования
    test(temp) // здесь тоже
    
    var temp = seq1
    temp = seq2 // здесь не будет копирования
    test(temp) // здесь тоже
    

    Хотя в таком случае лучше использовать fix.

  2. Отсутствие изменений после получения значения.

    var temp: Int* = ()
    temp += 1
    ...
    test(temp) // здесь не будет копирования
    temp += 100 // всегда медленная операция копирования
    

fix “переменные”

fix всегда использует значение по ссылке из heap. В будущем, возможно, будет реализация использования численных констант примитивных Java типов (для типов Libretto без кардинальности).

Последовательности

Размер любой последовательности в настоящее время представлен типом int на уровне JVM, т.е. это 32-битное знаковое значение. Что разрешает максимум 2 147 483 647 элементов в последовательности. Ограничение реализации, а не самого языка. В языке для индексов и размера используется Int большой размерности.

Построение последовательностей

Поскольку последовательности неизменяемые, то при сборе новой последовательности при помощи скобок (..., ..., ...) производится копирование. И это может быть медленно:

def main = {
  var seq: Int* = ()
  1..10 as i .${
    seq = (seq, i) // медленно
  }
  println(seq)
}

В таких случаях лучше использовать добавление в переменную при помощи +=:

def main = {
  var seq: Int* = ()
  1..10 as i .${
    seq += i
  }
  println(seq)
}

Добавление же в начало при помощи .= гораздо более медленное, чем +=. Это надо учитывать. И добавление сразу последовательности v .= (a, b, c) обычно более эффективное, чем поэлементное добавление v .= c; v .= b; v .= a.

Последовательности диапазона ..

Последовательности диапазона, заданного при помощи .. гораздо эффективнее и экономнее обычных последовательностей, поскольку они хранят только начало и конец диапазона, остальные значения расчётные.

Есть динамическая (во время исполнения) проверка на размер .. последовательности. Если длина не помещается в 31 бит (32 бита int минус знаковый бит), то возникает исключение во время исполнения. Но это не мешает делать .. с большими числами, но с длиной, которая помещается в 31 бит.

Если же начало и конец диапазона укладываются в знаковое 32-битное знаковое значение, то используется ещё более эффективное внутреннее представление (int в Java).

Но это касается только диапазона, заданного в Libretto типом Int. Поддержки для других целочисленных типов из libretto/num нет, но в будущем может появиться.

Доступ по индексу

Все встроенные последовательности обеспечивают эффективный доступ по индексу. Исключением могут быть только последовательности, что определяются программно (todo название трейта).

Передача последовательностей

Последовательности неизменяемые, поэтому при вызове методов не копируются, передаются как есть. Если нужно передавать изменяемую последовательность, то нужно использовать буфер (todo уточнить), что позволяет улучшить производительность за счёт отсутствия лишнего построения новых последовательностей.

Сравнение

Для некоторых типов всегда используется сложное сравнение, которое вместо сравнения на равенство определяет взаимный порядок, что менее эффективно. Но это (историческая) особенность компилятора. В будущем возможно внутреннее разделение на сравнение простое (равно или не равно) и сравнение порядка (больше, меньше или несравнимо). (todo уже сделано?)

Кроме того, исторически и ошибочно сделано автопробразование Real/Int и R64/I64 при сравнении. Это медленно и не соответствует семантике Map.

def main = {    
  println: 1 == 1.0 // 1 равно 1.0 (ошибочное решение!)
  fix m = map[Number, String]()
  m.!(1) = "one"
  println: m.!(1)
  println: m.!(1.0) // а для Map 1 не равно 1.0
}

Выдаёт:

unit
one
()

В будущем, возможно, это автопреобразование будет убрано.

Константы

В Libretto нет глобальных переменных или констант. Некоторые простые методы компилятор Libretto автоматически оптимизирует, кэшируя результат их исполнения:

def value = (1, 2, 1 + 2) // результат будет кэширован

Но гарантировать такое кэширование в текущей версии Libretto нельзя.

В некоторых случаях можно избавится от лишних вычислений использованием локальной функции вместо глобального метода и доступом к константам (при помощи замыкания):

def someValue = ... // медленно

def work(v: Int) = ... // здесь работаем с someValue

def main = {
  ...
  expr.${ work($) }
}

преобразовать в

def someValue = ... // медленно

def main = {
  ...
  fix sv = someValue // вычисляем и запоминаем
  def work(v: Int) = ... // здесь работаем с sv вместо someValue
  ...
  expr.${ work($) }
}

В более сложных случаях можно использовать lazy (todo).

Вызов метода (динамика)

При выборе метода по контексту компилятор выбирает претендентов статически. Но окончательный выбор (в случае альтернативы) производится динамически во время исполнения:

def Any test = "any"
def Int test = "int"
def String test = "string"

def main = {
  fix values = ("abc", 123, unit)  
  values.test // здесь динамический выбор метода из трёх
}

При выборе метода не используются возможности JVM вызова виртуального метода (обычный способ для Java в аналогичной ситуации). Взамен последовательно проверяется тип (аналог instanceof в Java) контекста до нахождения подходящего метода. Корректность проверена статически, но производительность такого метода хуже, чем при вызове виртуального метода.

Поскольку в планах развития Libretto есть поддержка [полу]динамического выбора не только по контексту, но и по аргументам, то переход на вызов виртуального метода станет точно невозможен.

Method too large

Из-за лучше выразительности код на Libretto не такой громоздкий, как аналогичный код на Java. Но это может приводить к тому, что визуально небольшой код может приводить к ошибке Method too large на уровне Java окружения.

Чаще всего это вызвано большим объёмом проверок типа контекста при динамическом выборе метода (см. выше). Особенно при использовании больших объединений.

Пока никаких средств борьбы с этим на уровне компилятора нет. Только ручным изменением кода метода. Например, вынесением частей в локальные или глобальные методы.

Перебор элементов последовательности

Иногда возникает задача перебрать значения элементов последовательности, но без накопления результата. Для этого можно использовать код вида:

(expr).${ body } // body работает с $

body будет выполнено в контексте каждого элемента, но не будет лишнего накопления результата. Результатом выполнения выражения будет сама первоначальная последовательность.

Опционально можно использовать as:

(expr) as n.${ body } // body работает с n

Компилятор распознает и оптимизирует такой код, считая его неким аналогом for (которого в Libretto в явном виде нет).

Возвращаемое значение

В некоторых случаях последним выражением метода является вышепоказанный перебор элементов последовательности без накопления результата:

def work(...) = {
  ...
  (expr).${ println($) }
}

Хотя здесь нет лишнего накопления результата, но последовательность будет возвращена из метода (с выводом соответствующего типа результата метода). Если в этом нет необходимости, то эффективнее явно указать пустоту как результат:

def work(...): () = {
  ...
  (expr).${ println($) }
  ()
}

Трейты, экземпляры, преобразование в трейт

Трейты Libretto в JVM представлены как интерфейсы (interface).

Экземпляры трейтов (создаваемые при помощи new) - это JVM-классы, которые реализуют соответствующий интерфейс. В точке new создаётся JVM-объект - экземпляр класса. Если этот класс является замыканием, связанные переменные передаются в конструкторе, а затем являются полями класса.

Преобразование (автоматическое или ручное) в трейт всегда происходит при помощи оборачивания экземпляра структуры или трейта в JVM-класс, который реализует интерфейс, соответствующий целевому трейту.

Иерархии трейтов как иерархии JVM-интерфейсов нет. Условная иерархия трейтов создаётся только за счёт оборачивания в новый класс. Это может быть менее эффективно, но это семантика трейтов Libretto.

Анонимные функции

Анонимные функции являются частным случаем трейтов, поэтому полностью реализуются через механизм трейтов.

Конкретному трейту Func/FuncN соответствует JVM-интерфейс.

Экземпляр анонимной функции создаётся как экземпляр JVM-класса, реализующего соответствующий JVM-интерфейс. Если этот класс является замыканием, связанные переменные передаются в конструкторе, а затем являются полями класса.

Локальные функции

Локальные (вложенные) функции всегда отображаются в классы Java использованием переменных замыкания в качестве значений полей вместо определения статических методов и передачей переменных замыкания дополнительными параметрами. Даже при отсутствии переменных замыкания - результатом будет класс без полей (todo уточнить). Т.е.технически локальные функции - это анонимные функции с другим синтаксисом. Это может немного ухудшать эффективность работы локальной функции по сравнению с глобальным методом.

Кроме того, это означает, что каждый раз в месте определения (def) локальной функции вызывается создание объекта соответствующего класса. Поэтому определения локальных функций нужно выносить из нагруженных циклов, если есть возможность:

Было:

seq.${ 
  ...
  def work(...) = ... // каждый раз создаётся объект локальной функции
  work(...)
  ...
}

Стало:

def work(...) = ... // создание вне цикла
seq.${ 
  ...
  work(...)
  ...
}

В будущем возможна доработка компилятора.

breaking/break

Для реализации break всегда используется исключение с ловлей его на уровне breaking. Для лучшей производительности исключение не строит стек-трейс.

Хвостовая рекурсия

В настоящее время компилятор Libretto никак не оптимизирует хвостовую рекурсию (в отличие, например, от Scala). В случае необходимости можно вручную использовать механизм Trampoline (todo пример).

Взаимодействие с JVM

Взаимодействие с JVM делается в Libretto при помощи методов, которые принимают строковые литералы. (todo см. раздел) Это выглядит как работа с рефлексией, но компилятор уже на стадии компиляции делает все необходимые проверки и связь кода. Эффективность таких вызова эквивалентна обычным вызовам из Java, а ошибки (отсутствие подходящих сущностей) выдаются на стадии компиляции.

Ленивая загрузка классов

Для улучшения отзывчивости компилятор Libretto не генерирует сразу весь байт-код программы. Наоборот, байт-код генерируется из внутреннего представления Libretto только при запросе (загрузке) связанного класса.

Недостатком такого подхода является возможное проявление ошибки Method too large не во время компиляции, а во время выполнения программы Libretto.

Для проверки можно использовать прагму (todo), которая заставляет принудительно сгенерировать весь байт-код всех классов, что получились после компиляции Libretto.