Ранее…

В последнем выпуске я представил новую реализацию языка обезьян под названием Monyet.

Второй взгляд на макросы

Отзывы, которые я получил из последнего поста, заключаются в том, что мой пример с макросами можно легко реализовать в Kotlin и Crystal с помощью функций/процессов высокого порядка.

Я собираюсь представить более убедительный пример.

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

Без макросов мне нужно будет написать простую реализацию для каждого подкласса:

В моей иерархии у меня более десяти классов. Но с макросами я могу определить его в одном месте и использовать повторно.

С помощью макроса define_type_desc мы можем определить метод и использовать его повторно. Внутри макроса мы используем специальную переменную экземпляра с именем type (я не могу ввести @, потому что Medium понимает это как упоминание), которая дает нам информацию о типе во время компиляции.

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

Полная реализация Monkey поставляется с двумя режимами выполнения: интерпретатор или eval и скомпилированный/VM. Также для тестов мы будем использовать традиционный тест на обезьян, который представляет собой fibonacci(35);

Я сравню реализации Go и Crystal, так как они больше похожи, чем Kotlin.

Режим переводчика

Мы будем использовать hyperfine в качестве тестового инструмента. Тест 1 — это Go, а тест 2 — Crystal.

Реализация Go в 1,31 раза быстрее, чем реализация Crystal.

Это как-то удивительно, так как я читал другие бенчмарки, где Crystal быстрее, но эти цифры надо брать с оговоркой.

Во-первых, у команды Go было больше времени для работы над производительностью, например, было значительное увеличение производительности с Go 1.16 до 1.17 из-за изменений в способе передачи аргументов функции (регистры против стеков).

Во-вторых, переводчики — это не те проекты, которыми вы занимаетесь в своей повседневной работе. Было бы интересно посмотреть, как это работает в других типах проектов, таких как серверная часть REST и других. На данный момент последний раунд старых и знаменитых тестов TechPower Web Framework еще не включает фреймворк Crystal.

В-третьих, возможно, я использую не те идиомы, чтобы извлечь из Crystal каждую каплю производительности (в общем, я просто перевел свой код Kotlin в Crystal). Я старался максимально следовать официальному руководству по производительности, но, возможно, что-то упустил.

В-четвертых, я подозреваю, что есть некоторые параметры компилятора или выполнения, которые я могу передать компилятору для его настройки. Я всегда стараюсь тестировать как можно более нестандартно, но, возможно, есть куда расти. Например, когда я анализировал режим eval с помощью Time Profiler от Instruments, большая часть процессорного времени досталась группе потоков с именем GC_Mark_Thread. Возможно, сборщик мусора слишком агрессивен и его можно немного настроить? (Подробнее об этом позже)

Это привело меня к следующему пункту

Потребление памяти

Если сравнивать по потреблению памяти, то одна область, где Go обычно сияет, Crystal, даже лучше.

В этом случае для Go:

И для Кристалла:

Итого, Go потребляет 10,32 Мб, а Crystal 2,23 Мб, почти в 5 раз лучше.

Тюнинг сборщика мусора Crystal (кувалдой)

Единственная задокументированная возможность настроить GC — отключить его с опцией gc_none. Теперь Crystal в 1,05~ раза быстрее, чем Go:

С оговоркой, что он также занимает почти 8 ГБ памяти.

режим виртуальной машины

Опять же, мы будем использовать сверхтонкий для наших тестов.

И это нехорошо. Go в 7,26 раза быстрее, чем Crystal. Мало того, режим интерпретатора также быстрее, чем режим виртуальной машины. Что-то происходит, и нам нужно исследовать это.

Захват преступника

Проверяя с помощью Time Profiler от Instruments, я вижу, что метод, который занимает больше времени (не считая потоков GC), — это offset. Итак, давайте посмотрим.

Метод offset использует метод [](range : Range) : Array(T) из класса Array(T).

Внутренне создается новый Array(T) и содержимое копируется из исходного массива с помощью build.

Помимо затрат на создание нового массива плюс цикл, мы оказываем давление на GC, так как этот массив почти всегда считывается и отбрасывается.

Я очень плохо понимаю, что такого рода операции в Go используют срезы и не создают новый массив, поскольку срезы в основном являются указателями.

Кэширование для большего блага

Учитывая неизменный характер байт-кода Монье, наш метод offset является хорошим кандидатом на кеширование. После различных итераций это лучшая реализация, которая у меня есть.

Во-первых, макрос cache_arrays используется для запроса и заполнения кеша. Почему макрос? Потому что есть метод onset, который может извлечь выгоду из кеша, и потому что макросы забавны, и они мне нравятся, хорошо?

Структура InstructionsCache имеет методы для запроса и заполнения кеша. Ключ кэша — Tuple(Instructions, Int32). Тем не менее, внутренняя структура кеша представляет собой Hash(UInt64, Array(Instructions?)), то есть двухуровневый кеш, ключ хэша — это Instructions.object_id, а значение хэша — это Array(Instructions?), где мы сохранили возможные ответы, используя параметр i в качестве индекса. Когда мы читаем кеш, мы используем object_id в хэше, который возвращает массив, а затем мы используем Int32 в качестве индекса.

Смотрим производительность:

Это огромное улучшение: с 7,26~ раза до 1,97~ раза. До Го еще далеко.

Потребление памяти

Потребление памяти для Go немного лучше для Go в режиме VM:

И немного выше для Кристалла:

Таким образом, Go в режиме VM составляет 9,42 Мбит/с, а Crystal в режиме VM — 2,37 Мбит/с.

Пробуем другой подход

Вместо того, чтобы создавать кеш, мы можем смоделировать (до определенной степени) поведение Go, то есть срезы. Во-первых, постараемся не создавать новые массивы.

Структура OffsetArray просто украшает исходный массив и не оказывает давления на сборщик мусора. Но есть одна оговорка: наш метод не возвращает Instructions (помните, псевдоним для Array(UInt8)), а это значит, что мне нужно изменить все использования offset. Это также затрудняет переход между различными реализациями.

Чтобы решить эту проблему, мы можем использовать тип Union (использования также должны использовать тип Union), а чтобы принять другой девиз реализации, мы можем использовать флаги компиляции.

Теперь мы можем переключаться между реализациями просто с помощью флага на компиляторе.

Как насчет производительности?

После запуска теста несколько раз лучшее время, которое я могу получить, это улучшение с 1,97~ до 1,79~. В целом, я не вижу большой разницы между двумя подходами, в том числе и по потреблению памяти.

Было сказано, что подход со смещением работает лучше для более длинных тестов, то есть fibonacci(40), чем с кэшированием, и именно поэтому я выбираю смещение в качестве реализации по умолчанию.

Максимальный прирост производительности

Запустите тесты на игровом ноутбуке AMD Ryzen с помощью Linux.

Замечательный.

Что дальше?

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

Дополнение не по теме.

Если вы пропустили отсылку к названию, я имею в виду прекрасную песню «Vestido de Cristal» колумбийской рок-группы Kraken.