Как стать автором
Обновить

Ускоряем Dart. Нативно, недорого

Время на прочтение6 мин
Количество просмотров5K

Релиз Dart 2.12 принёс, помимо всего прочего, поддержку FFI в стабильной версии, что позволит относительно легко добавить биндинги к своим любимым библиотекам, которые используют сишный ABI для экспорта. А это позволяют сделать в том числе и Rust, Go, Swift и другие.


Считаем логарифмы

Для начала попробуем написать синтетический код на чистом Dart, который будет выполняться относительно долго, но не слишком. Например, посчитаем сто миллионов логарифмов.

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

import 'dart:math';

// считаем n логарифмов функцией из стандартной библиотеки
double log_loop(int n) {
    double l = 0;
    for (int i = 0; i < n; i++) {
        l = log(i);
    }
    return l;
}

Чтобы проверить результат, напишем минимальную функцию main, которая замеряет время выполнения

var w = Stopwatch()..start();

double result = log_loop(100000000);

print('${w.elapsed.inMilliseconds}');

Запустив полученный код без компиляции, используя dart run, узнаем что расчёты выполнялись 10.05 секунд.

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

Можно ли лучше? В принципе да. Выполняем dart compile exe и получаем ускорение на 10% или 9.00 секунд.

Идём за горизонт событий

Если для вас 10 секунд - это слишком долго, можно попробовать пострелять себе в ноги.

Пишем эквивалентный код на C, используя log из <math.h>. Здесь и далее подразумевается, что log входит в состав libm из glibc, а код компилируется GCC 9.1.

// log_loop.c
#include <math.h>

double log_loop(int n) {
    double l = 0;
    for (int i = 0; i < n; i++) {
        l = log(i);
    }
    return l;
}

Видите разницу? И я не вижу. Поэтому тоже сделаем небольшую обёртку чтобы замерить время, за которое посчитает реализация на C. Предполагаем, что код log_loop.c просто входит в состав main.c и функция main содержит следующий код:

clock_t ts = clock();

double result = log_loop(100000000);

printf("%.2f\n", 1000.0*(clock() - ts)/CLOCKS_PER_SEC);

Компилируя в обычный ELF при помощи gcc -o main -lm main.c увидим, что полученный бинарник выполняет свои действия за 1.69 секунд.

Не мудрствуя лукаво, сразу пробуем скомпилировать с уровнями оптимизации O3, O2 и O1:

  • 03 - 0.822 с

  • 02 - 0.832 с

  • 01 - 0.834 с

Как видно, разница не слишком большая, поэтому далее ограничимся рассмотрением только уровней O3 и O0.

Стыкуемся

Перед тем, как использовать dart:ffi попробуем проверить, несёт ли какие-то накладные расходы подключение библиотек без использования FFI, непосредственно из кода на C, для которого сишные библиотеки совсем не инородные.

В наш main.c добавим объявление double log_loop(int) вместо реализации, описанной выше.

Предварительно компилируем log_loop.c в объектный файл через gcc -c -o log_loop.o log_loop.c и далее:

  • ar rcs liblog_loop.a log_loop.o — для компиляции в статически линкуемый архив

  • gcc -L. -o main -lm -llog_loop main.c — для сборки бинарника

В результате получим 1.69 секунд для O0 и 0.83 секунд для O3.

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

  • gcc -shared -o liblog_loop.so log_loop.o — для получения динамической библиотеки

  • gcc -L. -o main -lm -llog_loop main.c — для сборки бинарника

Для запуска приложению будет нужна log_loop.so поэтому будем использовать LD_LIBRARY_PATH=..
Здесь результаты примерно такие же: 1.70 секунд для O0 и 0.83 секунд для O3.

Начинаем рисковать

Чтобы использовать библиотеку через FFI, её надо подключить в рантайме. Используем для этого dlopen.

В код библиотеки никаких изменений не вносим, а вот процесс подключения библиотеки в исполняемом файле существенно меняется:

#include <dlfcn.h>

// предполагаем, что ошибок не будет, поэтому для простоты пропускаем всю обработку

void *loader;
if ((loader = dlopen("liblog_loop.so", RTLD_NOW)) == NULL)  // в конце нужно не забыть про dlclose(loader)
	return 1;

double (*log_loop)(int);
*(void **)(&log_loop) = dlsym(l, "log_loop");

// аналогичным образом замеряем производительность

И в этом случае показатели абсолютно не меняются: 1.69 секунд для O0 и 0.83 секунд для O3.

Небольшой итог

Независимо от того, каким образом вычисляющий метод попадает в бинарник - будь то dlopen, статическая или динамическая библиотека, непосредственно подключение библиотеки на время выполнения значительным образом не влияет, поэтому за базовую величину для дальнейшего сравнения примем, соответственно, 1.69 секунд для O0 и 0.83 секунд для O3.

Поехали

Ну штош, приступим к тому, для чего так долго готовились.

Готовим неправильно

Как мы помним, 10 секунд нас не устраивают и мы хотим быстрее. Раз функция log есть в libm, то попробуем оттуда её и взять.

Функцию main в нашем dart-коде оставим неизменной, а вот вычислительный метод будет таким:

import 'dart:ffi';

typedef LogFFI = Double Function(Double);
typedef Log = double Function(double);

double log_loop(int n) {
    final libm = DynamicLibrary.open("/lib/.../libm.so");  // путь зависит от libc

    double l = 0;
    for (int i = 0; i < n; i++) {
      	final log = libm.lookup<NativeFunction<LogFFI>>('log').asFunction<Log>();
        l = log(i * 1.0);
    }

    return l;
}

Принцип подключения разделяемых библиотек в Dart похож на работу с dlopen, но более типобезопасен. Здесь мы описываем сигнатуру для нативной функции double log(double)из libm.so через типы dart:ffi и приводим её к функции со встроенными типами Dart.

Получаем, что теперь наш код выполняется за 70 секунд в скомпилированном виде и 75 секунд через dart run, что в 7 раз медленнее чем реализация на основе dart:math.

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

Перепишем функцию по-нормальному, вынеся lookup за пределы цикла:

double log_loop(int n) {
    final libm = DynamicLibrary.open("/lib/.../libm.so");  // путь зависит от libc
  	final log = libm.lookup<NativeFunction<LogFFI>>('log').asFunction<Log>();

    double l = 0;
    for (int i = 0; i < n; i++) {
        l = log(i * 1.0);
    }

    return l;
}

После таких манипуляций получим 2.96 секунд в скомпилированном виде и 3.08 секунд через dart run. Это в 3 раза быстрее оригинальной реализации, но всё же пока что примерно в 3.5 раза медленнее, чем самая быстрая нативная реализация на C.

Делаем хорошо

Раз мы уже имеем скомпилированные библиотеки, почему бы не взять готовое? Справедливое заключение, поэтому так и сделаем, подключив вместо libm готовую реализацию log_loop на C. Сначала определим типы для функции:

import 'dart:ffi';

// В Dart нет типа Int для нативных функций, поэтому пользуемся тем что есть
typedef LogLoopFFI = Double Function(IntPtr);
typedef LogLoop = double Function(int);

Код вычисления времени выполнении остаётся аналогичным самому первому примеру, за исключением предварительной работы по подключению библиотеки. Для этого в начале функции main просто добавим следующее (предполагая, что библиотеку мы расположили в директории lib/):

final loader = DynamicLibrary.open("lib/liblog_loop.so");
final log_loop = loader.lookup<NativeFunction<LogLoopFFI>>('log_loop').asFunction<LogLoop>();

Для библиотеки, скомпилированной с уровнем оптимизаций O3 получим 0.83 секунды для скомпилированного файла и 0.86 секунд для dart run.

Как можно заметить, это ничем не отличается от нативной реализации, примерно в 10 раз быстрее, чем нативная реализация на Dart и в 2.5 раза эффективнее вызова logнапрямую из libm.

Большой итог

Закругляясь, для наглядности приведу сводную табличку с результатами всех тестов:

Среда

-O

exe

Время, сек

C

0

+

1.69

3

0.82

С (shared)

0

1.70

3

0.83

C (static)

0

1.69

3

0.83

C (dlopen)

0

1.69

3

0.83

Dart

+

9.00

-

10.1

Dart (libm, loop lookup)

+

70.2

-

75.8

Dart (libm, fixed)

+

2.96

-

3.07

Dart (ffi)

0

+

1.71

-

1.72

Dart (ffi)

3

+

0.83

-

0.86

Как можно заметить, при правильном подходе среда выполнения практически не оказывает влияние на производительность при работе с FFI. При этом, несмотря на то, что вызов log через FFI ускоряет выполнение, хорошо видно влияние интерфейса между Dart и C.

При сравнении с реализацией на C получим что на каждый вызов log Dart тратит дополнительно (2.96-0.83)×1e8/1e9 = 0.213 нс на каждую итерацию. Считая, что каждая операция выполняется за 0.82×1e8/1e9 = 0.082 нс получаем более чем двукратную разницу.

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

Стоит, однако, сделать ремарку, что работа со сложными типами вроде структур или передача больших объёмов данных через указатели, преобразование строк из Utf8Pointer и т. д. безусловно приведут к дополнительному оверхеду. В качестве бонуса отмечу существование пакета ffigen для генерации биндингов к библиотекам из сопутствующих заголовочных файлов.

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии5

Публикации

Истории

Ближайшие события