Когда Flutter на WebAssembly быстрее JavaScript: разберем 46 тестов.
Когда мы апгрейдим процессор или видеокарту и начинаем играть в привычную нам игрушку с новым железом, то увеличение производительности на некоторое количество процентов становится сразу же заметным. Например, увеличение производительности заметно уже с 5 процентов, если дело касается FPS в какой-то игре. 10 процентов — более заметно, 15 процентов — ещё заметнее. А увеличение производительности на 50 процентов не заметить практически невозможно. Примерно так же я смотрел на отрисовку интерфейса при компиляции Flutter-приложений в WebAssembly-код вместо JavaScript. Примерно по таким же ощущениям для большого проекта я получил ускорение, ну, примерно от 5 до 10 процентов. Хотя должен был получить 50. Поэтому я решил очень подробно исследовать данную ситуацию.
Я решил досконально проверить, насколько можно улучшить веб приложения переходом на компиляцию в WASM вместо JS, в отличии от официальных цифр у меня получились другие цифры и результат работы приложения их подтверждает.
После перехода на Wasm приложение может стать лишь немного быстрее. Я собрал отдельный Flutter-стенд, чтобы разобраться, откуда берётся этот результат: какие операции ускоряются, где выигрывает JavaScript и как вычисления связаны с плавностью интерфейса.
На чём и как проводился эксперимент
Компьютер: Apple M4, 16 ГиБ оперативной памяти, arm64, Darwin 24.3.0.
Браузер: Chrome/154.0.8037.98, движок V8 15.4.80.20.
Разработка: Flutter 3.47.1, Dart 3.13.1, сборки release.
Графика: ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Version 15.3.1 (Build 24D70)). Окно 1280 × 900 CSS-пикселей, DPR 1.
Размещение: локальный сервер на том же компьютере. Все основные и дополнительные замеры выполнены в настоящем настольном Chrome.
Из одного Dart-кода получены две веб-сборки. В JavaScript-варианте Flutter рисует через CanvasKit. Wasm-вариант использует Skwasm; его я запускал с отрисовкой в основном потоке и в отдельном рабочем потоке. В таблицах они обозначены JavaScript, Wasm ST и Wasm MT.
Основная серия содержит шесть сессий каждого режима и семь замеров задачи в каждой сессии. Порядок режимов менялся, чтобы запуск первым или последним распределялся между ними. Для семи пограничных и нестабильных случаев я выполнил расширенную серию: 12 сессий на режим, 15 предварительных пакетов и затем 21 учитываемый замер на задачу.
Задачи охватывают числовые вычисления, память, объекты, текст, браузерные службы и Flutter-интерфейс. Под каждым кейсом есть код из измеренного стенда. Общие классы подготовки данных и полный измерительный цикл собраны в конце статьи. Все численные результаты рассчитаны по сохранённым замерам.
Как читать результаты
Время указано в миллисекундах на один повтор описанной задачи. Меньшее значение означает более быстрое выполнение. Типичное время получено медианой: сначала внутри сессии, затем по сессиям. Очень короткие задачи выполнялись многократно в одном пакете; его длительность делилась на количество повторов.
Коэффициент JS / Wasm показывает отношение времени. Например, значение 2 означает, что Wasm выполнил работу вдвое быстрее. Коэффициент 0,5 означает двукратный выигрыш JavaScript. Коэффициенты вычисляются по парным сессиям, поэтому могут слегка отличаться от деления двух соседних значений в таблице.
Содержание
Плавность и время кадра · Запуск приложения · Практические выводы · Подробности измерений · Сводная таблица · Полные исходники
Вычисления: числа, матрицы, изображения и звук
1. Вычисления с целыми числами
В обработке двоичных данных часто приходится сдвигать биты, применять маски и смешивать значения. Для такого типа работы выбран xorshift — небольшой алгоритм, в котором почти всё время занимают целочисленные операции.
Выполняются 300 000 шагов. После каждого шага результат ограничивается 32 битами. Это сохраняет одинаковый смысл вычисления в обеих сборках. Выигрыш относится к длинной последовательности таких операций; полное время обработки файла дополнительно включает чтение и запись. В одном измерительном пакете задача повторялась 54 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
1,678 |
0,617 |
0,615 |
Основная серия. Wasm быстрее в 2,72 раза. JS / Wasm ST: 2,72× [2,71; 2,72].
Dart-код этого теста
int bitMix(int seed, int count) { var x = seed; for (var i = 0; i < count; i++) { x = (x ^ (x << 13)) & 0xffffffff; x = (x ^ (x >> 17)) & 0xffffffff; x = (x ^ (x << 5)) & 0xffffffff; } return x;}Kernel( 'int32', 'Вычисления', 'Целые: xorshift / битовые операции', '300 000 шагов; u32', () => bitMix(data.seed, 300000), )
2. Перемножение и сложение дробных чисел
Представим, что нужно вычислить сходство двух числовых векторов или вклад нескольких сигналов в общий результат. Для этого элементы двух массивов попарно перемножают и складывают.
В тесте перемножаются 65 536 пар чисел из Float64List, после чего произведения складываются простым циклом. Массивы подготовлены заранее. В одном измерительном пакете задача повторялась 143 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,078 |
0,241 |
0,239 |
Основная серия. JavaScript быстрее в 3,09 раза. JS / Wasm ST: 0,32× [0,32; 0,34].
Dart-код этого теста
double dot(List<double> a, List<double> b) { var sum = 0.0; for (var i = 0; i < a.length; i++) { sum += a[i] * b[i]; } return sum;}Kernel( 'double_dot', 'Вычисления', 'Float64: скалярное произведение', '65 536 пар', () => dot(a, b), )
3. Синусы, косинусы и квадратные корни
Синусы и косинусы встречаются в анимации, геометрии и обработке сигналов. Квадратный корень нужен, например, при вычислении расстояния.
Для каждого из 30 000 аргументов выполняется выражение sin(x) × cos(x) + sqrt(x). В одном измерительном пакете задача повторялась 77 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,252 |
0,255 |
0,254 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,00× [0,99; 1,01].
Dart-код этого теста
Kernel( 'transcendental', 'Вычисления', 'sin / cos / sqrt', '30 000 аргументов', () { var sum = 0.0; for (var i = 0; i < 30000; i++) { final x = a[i]; sum += math.sin(x) * math.cos(x) + math.sqrt(x); } return sum; }, )
4. Умножение матриц
Умножение матриц встречается в преобразованиях координат и численных алгоритмах. В этом опыте используются две квадратные матрицы размером 64 × 64.
Функция выделяет память под результат, выполняет три вложенных цикла и затем суммирует элементы полученной матрицы. Порядок циклов i–k–j обеспечивает последовательный проход по строкам. В одном измерительном пакете задача повторялась 63 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,307 |
0,443 |
0,443 |
Основная серия. JavaScript быстрее в 1,44 раза. JS / Wasm ST: 0,69× [0,67; 0,72].
Dart-код этого теста
Float64List multiply(List<double> a, List<double> b, int n) { final out = Float64List(n * n); for (var i = 0; i < n; i++) { for (var k = 0; k < n; k++) { final v = a[i * n + k]; for (var j = 0; j < n; j++) { out[i * n + j] += v * b[k * n + j]; } } } return out;}Kernel( 'matrix', 'Вычисления', 'Матрицы: обычный тройной цикл', '64×64; Float64; включая output', () => multiply(matrixA, matrixB, 64).fold(0.0, (x, y) => x + y), )
5. Фильтр изображения
Простой способ сгладить изображение — заменить яркость пикселя средним значением его соседей. Здесь для каждой внутренней точки берётся квадрат 3 × 3.
Тест обрабатывает числовое поле 256 × 256 на процессоре. Граничные пиксели пропускаются; вычисленные средние складываются в контрольную сумму. Замер охватывает арифметику и обращения к соседним элементам уже подготовленного массива. В редакторе изображений к этому добавятся декодирование файла и показ результата. В одном измерительном пакете задача повторялась 34 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,349 |
0,184 |
0,182 |
Основная серия. Wasm быстрее в 1,88 раза. JS / Wasm ST: 1,88× [1,86; 1,97].
Dart-код этого теста
int stencil(Uint32List image, int width) { var sum = 0; for (var y = 1; y < width - 1; y++) { for (var x = 1; x < width - 1; x++) { final p = y * width + x; sum += (image[p - width - 1] + image[p - width] + image[p - width + 1] + image[p - 1] + image[p] + image[p + 1] + image[p + width - 1] + image[p + width] + image[p + width + 1]) ~/ 9; } } return sum;}Kernel( 'image_stencil', 'Вычисления', 'Изображение: box-filter 3×3', '256×256; без декодера/GPU', () => stencil(data.values, 256), )
6. Фильтр звукового сигнала
При фильтрации звука текущий отсчёт можно вычислять как взвешенную сумму предыдущих. Это основа FIR-фильтра.
Подготовлены массивы из 8 192 отсчётов и 32 коэффициентов. Цикл начинает работу с позиции 32, перемножает отсчёты с коэффициентами и накапливает контрольную сумму. Работа аудиоустройства и расписание воспроизведения относятся к следующему уровню приложения. В одном измерительном пакете задача повторялась 50 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,163 |
0,169 |
0,168 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 0,96× [0,95; 1,03].
Dart-код этого теста
Kernel( 'audio_fir', 'Вычисления', 'Аудио: FIR-фильтр', '8 192 отсчёта × 32 taps', () { var sum = 0.0; for (var i = 32; i < 8192; i++) { for (var j = 0; j < 32; j++) { sum += a[i - j] * b[j]; } } return sum; }, )
7. Вычисления с условиями
Обработка записей часто содержит несколько правил: для малых значений одно действие, для средних другое, для больших третье. Такой код нагружает условные переходы.
262 144 значения проходят через три ветви if/else. Распределение входов фиксировано, поэтому сборки получают одну и ту же последовательность решений. Скорость такого кода чувствительна к расположению ветвей и профилю оптимизации. В одном измерительном пакете задача повторялась 19 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,631 |
0,809 |
0,654 |
|
Расширенная серия |
0,564 |
0,712 |
0,795 |
Основная серия. Скорость заметно меняется между запусками. JS / Wasm ST: 0,77× [0,69; 0,97].
Расширенная серия. Скорость заметно меняется между запусками. JS / Wasm ST: 0,81× [0,71; 1,00].
Dart-код этого теста
Kernel( 'branch', 'Вычисления', 'Условные переходы по данным', '262 144 значений', () { var sum = 0; for (var i = 0; i < 262144; i++) { final v = data.values[i]; if (v < 20000) { sum += v * 3; } else if (v < 40000) { sum -= v; } else { sum += v ~/ 3; } } return sum; }, )
8. Многократные вложенные вызовы функции
Рекурсивная функция вызывает сама себя. Подобная структура встречается при обходе дерева и разборе вложенных выражений. Для создания большого числа вызовов выбран рекурсивный Fibonacci(25).
Этот вариант многократно повторяет одни и те же вычисления, поэтому хорошо проявляет стоимость вызова и возврата. Для прикладного вычисления чисел Фибоначчи разумнее использовать линейный алгоритм. Здесь алгоритм служит нагрузкой на механизм вызовов. В одном измерительном пакете задача повторялась 48 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,505 |
0,130 |
0,128 |
Основная серия. Wasm быстрее в 3,89 раза. JS / Wasm ST: 3,89× [3,84; 3,90].
Dart-код этого теста
int fibonacci(int n) => n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);Kernel( 'recursion', 'Вычисления', 'Рекурсивные вызовы: Fibonacci', 'n=25', () => fibonacci(25), )
9. Арифметика с очень большими целыми числами
Обычных чисел бывает мало, когда нужна точная арифметика с длинными целыми. Dart предоставляет для этого BigInt.
Выполняются 1 000 шагов: квадрат текущего значения, прибавление номера шага и остаток по модулю 2¹²⁷ − 1. В обеих сборках используется одна и та же Dart-библиотека и сохраняется точность. В одном измерительном пакете задача повторялась 15 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
1,319 |
0,475 |
0,475 |
Основная серия. Wasm быстрее в 2,78 раза. JS / Wasm ST: 2,78× [2,75; 2,84].
Dart-код этого теста
Kernel( 'bigint', 'Вычисления', 'BigInt: точная арифметика', '1 000 modular multiply; 127 бит', () { var x = BigInt.from(data.seed); for (var i = 0; i < 1000; i++) { x = (x * x + BigInt.from(i)) % big; } return (x & BigInt.from(0x7fffffff)).toInt(); }, )
10. Та же сумма обычным циклом
Этот тест измеряет сложение массива целых чисел обычным циклом.
Массив содержит 262 144 значения, ограниченных маской 4095. Простой цикл накапливает сумму в int. Вариант Int32x4 обрабатывает те же числа группами по четыре. В одном измерительном пакете задача повторялась 105 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,100 |
0,108 |
0,108 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 0,92× [0,91; 0,95].
Dart-код этого теста
Kernel('dart_scalar_vector','Вычисления','Dart int: контроль суммы векторов','262 144 целых; тот же результат',(){ var sum=0;for(var i=0;i<scalarLanes.length;i++) {sum+=scalarLanes[i];}return sum; })
11. Сложение чисел группами по четыре
Int32x4 позволяет выразить операцию над четырьмя 32-битными числами одновременно. В этой задаче складываются 65 536 таких групп.
После прохода четыре компонента результата объединяются в одну сумму. В Wasm-сборке этот код быстрее, чем в JavaScript-сборке. При сравнении с обычным циклом внутри Wasm выигрывает обычный цикл: стоимость доступа к векторным значениям и их представлениям влияет на время всей задачи. В одном измерительном пакете задача повторялась 48 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,349 |
0,190 |
0,187 |
Основная серия. Wasm быстрее в 1,85 раза. JS / Wasm ST: 1,85× [1,74; 1,89].
Dart-код этого теста
Kernel('dart_vector','Вычисления','Dart Int32x4: сумма векторов','65 536 векторов; API не гарантирует SIMD',(){ var sum=Int32x4(0,0,0,0);for(var i=0;i<lanes.length;i++) {sum=sum+lanes[i];}return sum.x+sum.y+sum.z+sum.w; })
Память, объекты и коллекции
12. Чтение небольшого массива из памяти
Небольшие массивы встречаются в локальных таблицах и временных буферах. Здесь проверяется последовательное чтение 16 КиБ данных.
Все 4 096 значений складываются в сумму. Одна операция занимает несколько микросекунд, поэтому она повторяется в пакете. При таком масштабе становятся особенно заметны прогрев, оптимизация цикла и накладные расходы измерения. В одном измерительном пакете задача повторялась 512 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,003 |
0,002 |
0,002 |
|
Расширенная серия |
0,003 |
0,003 |
0,003 |
Основная серия. Скорость заметно меняется между запусками. JS / Wasm ST: 1,23× [1,00; 1,25].
Расширенная серия. Скорость практически одинакова. JS / Wasm ST: 0,97× [0,96; 1,00].
Dart-код этого теста
for (final length in [4096, 1048576]) Kernel( 'memory_$length', 'Память', 'Последовательное чтение ${length == 4096 ? '16 KiB' : '4 MiB'}', '$length × Uint32', () { var sum = 0; for (var i = 0; i < length; i++) { sum += data.values[i]; } return sum; }, )
13. Чтение большого массива из памяти
Тот же последовательный проход выполнен по более крупному массиву: 4 МиБ, или 1 048 576 целых чисел.
Время включает чтение и суммирование. Оно описывает конкретный проход по массиву; для измерения предельной пропускной способности памяти используются существенно иные объёмы и схемы доступа. В одном измерительном пакете задача повторялась 35 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,642 |
0,531 |
0,529 |
Основная серия. Wasm быстрее в 1,20 раза. JS / Wasm ST: 1,20× [1,20; 1,22].
Dart-код этого теста
for (final length in [4096, 1048576]) Kernel( 'memory_$length', 'Память', 'Последовательное чтение ${length == 4096 ? '16 KiB' : '4 MiB'}', '$length × Uint32', () { var sum = 0; for (var i = 0; i < length; i++) { sum += data.values[i]; } return sum; }, )
14. Чтение памяти в непоследовательном порядке
При проходе по связанным данным адрес следующего элемента становится известен только после чтения текущего. Такой порядок затрудняет заблаговременную загрузку данных процессором.
В массиве размером 1 МиБ выполнены 262 144 зависимых перехода. Таблица ссылок формируется воспроизводимо и охватывает весь набор. В одном измерительном пакете задача повторялась 17 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
1,388 |
1,471 |
1,487 |
|
Расширенная серия |
1,249 |
1,562 |
1,534 |
Основная серия. Скорость заметно меняется между запусками. JS / Wasm ST: 0,90× [0,78; 0,97].
Расширенная серия. JavaScript быстрее в 1,17 раза. JS / Wasm ST: 0,85× [0,72; 0,91].
Dart-код этого теста
Kernel( 'random_memory', 'Память', 'Зависимое случайное чтение', '1 MiB; 262 144 переходов', () { var p = data.seed & (links.length - 1), sum = 0; for (var i = 0; i < links.length; i++) { p = links[p]; sum += p; } return sum; }, )
15. Копирование массива
Перед отправкой или обработкой данных приложение часто копирует буфер. Проверяем копирование 4 МиБ из одного типизированного массива в другой.
Приёмный массив выделен заранее. Внутри замера вызывается setAll, затем читаются крайние элементы для контрольного результата. В одном измерительном пакете задача повторялась 278 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,059 |
0,070 |
0,070 |
Основная серия. JavaScript быстрее в 1,20 раза. JS / Wasm ST: 0,83× [0,83; 0,84].
Dart-код этого теста
Kernel( 'bulk_copy', 'Память', 'TypedData.setAll: копирование', '4 MiB; заранее выделенный output', () { copy.setAll(0, data.values); return copy[0] + copy[copy.length - 1]; }, )
16. Создание временных объектов
При преобразовании данных создаётся много временных объектов: обёртки строк, промежуточные результаты и модели для отображения. Они быстро становятся доступными для удаления.
Тест создаёт 32 партии по 1 024 объекта Cell и читает их значения. Время включает выделение объектов и обход каждой партии. Работа сборщика мусора может попадать в отдельные замеры и отражается в их распределении. В одном измерительном пакете задача повторялась 91 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,101 |
0,169 |
0,169 |
Основная серия. JavaScript быстрее в 1,68 раза. JS / Wasm ST: 0,59× [0,59; 0,60].
Dart-код этого теста
Kernel( 'allocation_short', 'Объекты', 'Короткоживущие объекты', '32 партии × 1 024 Cell', () { var sum = 0; for (var round = 0; round < 32; round++) { final cells = List.generate( 1024, (i) => Cell(data.values[i + round]), ); for (final c in cells) { sum += c.value; } } return sum; }, )
17. Создание цепочки объектов и проход по ней
Связанные списки и деревья хранят ссылки на другие объекты. Чтобы проверить этот механизм, создаётся цепочка из 32 768 объектов.
Сначала строится вся цепочка, затем выполняется проход по ссылкам и суммирование значений. В отличие от последовательного числового массива здесь участвуют выделение объектов, их размещение и чтение ссылок. В одном измерительном пакете задача повторялась 91 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,078 |
0,084 |
0,084 |
|
Расширенная серия |
0,078 |
0,087 |
0,086 |
Основная серия. Разница находится около принятого порога 10%. JS / Wasm ST: 0,92× [0,91; 0,94].
Расширенная серия. JavaScript быстрее в 1,13 раза. JS / Wasm ST: 0,88× [0,88; 0,90].
Dart-код этого теста
Kernel( 'allocation_graph', 'Объекты', 'Объектный граф и проход по ссылкам', '32 768 связанных Cell; allocation включён', () { Cell? head; for (var i = 0; i < 32768; i++) { head = Cell(data.values[i], head); } var sum = 0; while (head != null) { sum += head.value; head = head.next; } return sum; }, )
18. Вызов методов разных типов объектов
В приложении несколько классов могут реализовывать один интерфейс: например, разные операции над документом. Вызов идёт через общий тип, а нужная реализация выбирается во время выполнения.
Тест 300 000 раз вызывает apply у трёх чередующихся реализаций. Каждая делает короткую целочисленную операцию. Такой профиль показывает совместную стоимость выбора метода, самого вызова и оптимизации повторяющейся последовательности типов. В одном измерительном пакете задача повторялась 22 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,553 |
0,486 |
0,484 |
Основная серия. Wasm быстрее в 1,13 раза. JS / Wasm ST: 1,13× [1,11; 1,14].
Dart-код этого теста
Kernel( 'dispatch', 'Объекты', 'Полиморфные вызовы', '300 000 вызовов; 3 реализации', () { var x = data.seed & 65535; for (var i = 0; i < 300000; i++) { x = operations[i % 3].apply(x); } return x; }, )
19. Сортировка
Сортировка нужна спискам, таблицам и результатам поиска. Для опыта взяты 20 000 целых значений с воспроизводимым содержимым.
В каждом повторе создаётся копия входного списка, затем вызывается sort. После сортировки вычисляется контрольная сумма, зависящая от порядка элементов. Измеряется полный путь подготовки, сортировки и проверки результата. В одном измерительном пакете задача повторялась 7 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
3,441 |
3,679 |
3,738 |
|
Расширенная серия |
3,308 |
3,650 |
3,670 |
Основная серия. Скорость заметно меняется между запусками. JS / Wasm ST: 0,94× [0,88; 1,40].
Расширенная серия. Разница находится около принятого порога 10%. JS / Wasm ST: 0,91× [0,89; 0,93].
Dart-код этого теста
Kernel( 'sort', 'Коллекции', 'Сортировка чисел', '20 000 значений; копирование включено', () { final out = data.values.take(20000).toList()..sort(); var sum = 0; for (var i = 0; i < out.length; i++) { sum = (sum + out[i] * (i + 1)) % 1000000007; } return sum; }, )
20. Поиск записи по строковому ключу
Типичный пример — найти запись по строковому идентификатору. В Dart для этого часто используется Map.
Словарь и ключи подготовлены заранее. Во время замера выполняются 20 000 поисков в перемешанном порядке. Результаты складываются в сумму. В одном измерительном пакете задача повторялась 77 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,258 |
0,395 |
0,421 |
Основная серия. JavaScript быстрее в 1,54 раза. JS / Wasm ST: 0,65× [0,61; 0,68].
Dart-код этого теста
Kernel( 'map', 'Коллекции', 'Хеш-таблица: поиск строковых ключей', '20 000 поисков; Map подготовлен', () { var sum = 0; for (final k in keys) { sum += map[k]!; } return sum; }, )
Текст и обмен данными
21. Сборка текста из фрагментов
В журнале событий или экспорте приходится собирать строку из множества фрагментов. Для этой задачи используется StringBuffer.
В буфер записываются 2 000 фрагментов с номером и числом, затем получается итоговая строка и её длина. В одном измерительном пакете задача повторялась 313 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,039 |
0,089 |
0,090 |
Основная серия. JavaScript быстрее в 2,30 раза. JS / Wasm ST: 0,43× [0,43; 0,44].
Dart-код этого теста
Kernel( 'string_build', 'Текст', 'StringBuffer / форматирование', '2 000 строк', () { final out = StringBuffer(); for (var i = 0; i < 2000; i++) { out.write('item_$i:${data.values[i]};'); } return out.toString().length; }, )
22. Преобразование текста в байты
Перед сохранением текста или отправкой по сети строку часто переводят в байты UTF-8. Вход содержит латиницу, кириллицу, японские символы и эмодзи.
Кодируется текст из 2 000 строк. Затем байты суммируются, чтобы результат вычисления использовался. Поэтому время охватывает кодирование и контрольный проход по полученному массиву. В одном измерительном пакете задача повторялась 25 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,661 |
0,216 |
0,215 |
Основная серия. Wasm быстрее в 3,08 раза. JS / Wasm ST: 3,08× [1,86; 4,37].
Dart-код этого теста
Kernel( 'utf8_encode', 'Текст', 'UTF-8: кодирование Unicode', '2 000 многоязычных строк', () => utf8.encode(data.text).fold<int>(0, (a, b) => a + b), )
23. Преобразование байтов обратно в текст
После получения файла или сетевого ответа байты UTF-8 нужно превратить в строку. Здесь используются те же многоязычные данные.
Закодированный массив с 2 000 многоязычных строк подготовлен заранее. Во время замера выполняется utf8.decode и читается длина строки. Направление преобразования существенно: кодирование и декодирование используют разные алгоритмы и могут иметь разных победителей. В одном измерительном пакете задача повторялась 358 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,055 |
0,174 |
0,174 |
Основная серия. JavaScript быстрее в 3,14 раза. JS / Wasm ST: 0,32× [0,32; 0,33].
Dart-код этого теста
Kernel( 'utf8_decode', 'Текст', 'UTF-8: декодирование Unicode', '2 000 многоязычных строк', () => utf8.decode(encoded).length, )
24. Поиск в тексте по шаблону
Из текста журнала нужно извлечь номера записей вида item_123. Для этого подходит регулярное выражение с группой захвата.
Поиск находит 2 000 совпадений, извлекает цифры, переводит их в int и суммирует. В одном измерительном пакете задача повторялась 114 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,153 |
0,284 |
0,286 |
Основная серия. JavaScript быстрее в 1,85 раза. JS / Wasm ST: 0,54× [0,53; 0,55].
Dart-код этого теста
Kernel('regexp', 'Текст', 'RegExp: поиск и группы', '2 000 совпадений', () { var sum = 0; for (final m in regular.allMatches(data.text)) { sum += int.parse(m.group(1)!); } return sum; })
25. Подготовка JSON для отправки или сохранения
Перед отправкой набора записей в API приложение превращает объекты в JSON. В тесте каждая из 2 000 записей содержит четыре поля: id, name, value и active.
Список объектов существует к началу измерения. jsonEncode создаёт строку, затем читается её длина. Полученный коэффициент относится к подготовке JSON в памяти; в полном пользовательском действии учитывается ещё передача запроса. В одном измерительном пакете задача повторялась 46 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,470 |
0,340 |
0,341 |
Основная серия. Wasm быстрее в 1,38 раза. JS / Wasm ST: 1,38× [1,36; 1,40].
Dart-код этого теста
Kernel( 'json_encode', 'Сериализация', 'JSON: encode', '2 000 объектов × 4 поля', () => jsonEncode(data.records).length, )
26. Чтение полученного JSON
При открытии списка приложение получает JSON и извлекает нужные поля. Для сравнения используется текст с теми же 2 000 объектами.
jsonDecode создаёт коллекции, после чего код проходит по строкам и складывает поле value. Измерение включает разбор, создание объектов и доступ к полям. Этот путь отличается от подготовки JSON, поэтому результаты двух направлений рассматриваются отдельно. В одном измерительном пакете задача повторялась 87 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,211 |
0,321 |
0,325 |
Основная серия. JavaScript быстрее в 1,53 раза. JS / Wasm ST: 0,65× [0,65; 0,66].
Dart-код этого теста
Kernel( 'json_decode', 'Сериализация', 'JSON: decode и чтение поля', '2 000 объектов × 4 поля', () { final rows = jsonDecode(data.json) as List; var sum = 0; for (final r in rows) { sum += (r['value'] as num).toInt(); } return sum; }, )
Вызовы JavaScript и асинхронный код
27. Много маленьких обращений из Dart к JavaScript
Flutter-код иногда много раз обращается к маленьким функциям JavaScript-библиотеки. Здесь такая функция всего лишь прибавляет единицу.
20 000 вызовов объединены в цикл. V8 может встроить простую функцию и оптимизировать этот цикл целиком. В одном измерительном пакете задача повторялась 228 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,006 |
0,197 |
0,197 |
Основная серия. JavaScript быстрее в 33,52 раза. JS / Wasm ST: 0,03× [0,03; 0,03].
Dart-код этого теста
Workload('interop_scalar','Граница JS','Dart → JS: маленькие вызовы','20 000 вызовов',sync:(){ var sum=0.0; for(var i=0;i<20000;i++) {sum+=bridge.scalar(i.toDouble());} return sum; })
28. Передача массива из Dart в JavaScript
Второй вариант интеграции передаёт сразу весь массив одним вызовом. JavaScript получает 65 536 чисел и суммирует их.
Время включает преобразование представления через toJS и работу функции суммирования. Его объём полезной работы отличается от серии маленьких вызовов. В одном измерительном пакете задача повторялась 512 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,079 |
0,155 |
0,155 |
Основная серия. JavaScript быстрее в 1,98 раза. JS / Wasm ST: 0,51× [0,50; 0,52].
Dart-код этого теста
Workload('interop_batch','Граница JS','Dart → JS: один пакет','65 536 чисел; toJS + sum',sync:()=>bridge.sum(interop.toJS))
29. Передача массива в JavaScript и обратно
Иногда после обработки массива JavaScript-библиотекой нужно снова обратиться к нему из Dart. Здесь проверяется такой круговой путь.
Код получает JavaScript-представление, считает сумму в JavaScript, преобразует представление обратно через toDart и читает последний элемент. Конкретная реализация преобразования зависит от среды и может использовать обёртку или копирование. В одном измерительном пакете задача повторялась 417 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,078 |
0,155 |
0,154 |
Основная серия. JavaScript быстрее в 1,98 раза. JS / Wasm ST: 0,50× [0,50; 0,51].
Dart-код этого теста
Workload('interop_roundtrip','Граница JS','TypedData: toJS → toDart','65 536 чисел; включая доступ JS',sync:(){ final js=interop.toJS; final sum=bridge.sum(js); return sum+js.toDart.last; })
30. Цепочка асинхронных продолжений
Асинхронная функция может выполнять множество коротких продолжений даже при уже готовых данных. Это бывает в цепочках Future и обработчиках событий.
Тест 1 000 раз выполняет await Future.value(i) и суммирует значения. Так измеряется организация асинхронных продолжений. Реальные сетевые операции добавляют собственное время ожидания. В одном измерительном пакете задача повторялась 109 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,103 |
0,057 |
0,056 |
Основная серия. Wasm быстрее в 1,80 раза. JS / Wasm ST: 1,80× [1,77; 1,85].
Dart-код этого теста
Workload('microtasks','Асинхронность','Future / microtasks','1 000 await Future.value',async:()async{ var sum=0;for(var i=0;i<1000;i++) {sum+=await Future.value(i);}return sum; })
Работа, которую выполняет браузер
31. Вычисление контрольной суммы средствами браузера
Для проверки целостности данных браузер умеет вычислять SHA-256 через WebCrypto. Обе сборки передают в него одинаковый буфер 64 КиБ.
Замер включает вызов, ожидание готового хеша и суммирование его байтов. Криптографическое вычисление выполняет браузерная служба. В одном измерительном пакете задача повторялась 512 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,024 |
0,024 |
0,024 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,02× [1,01; 1,03].
Dart-код этого теста
for(final c in const [ ['webcrypto','WebCrypto: SHA-256','64 KiB; браузерный native API'], ['timer_wait','Ожидание таймера','15 ms requested; elapsed measured'], ['local_fetch','Локальный HTTP fetch','64 KiB; no-store; без WAN'], ['indexeddb','IndexedDB: put/get/delete','64 KiB; одна транзакция'], ['worker_transfer','Web Worker: transfer + ответ','64 KiB; копирование и суммирование'], ]) Workload(c[0],'Браузер / I/O',c[1],c[2],async:()async=>(await bridge.nativeAsync(c[0].toJS).toDart).toDartDouble),@JS('bench.nativeAsync') external JSPromise<JSNumber> nativeAsync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'webcrypto':return [...new Uint8Array(await crypto.subtle.digest('SHA-256',bench.bytes))].reduce((a,b)=>a+b,0);
32. Ожидание таймера
Приложение запрашивает задержку, после которой продолжает выполнение. Проверяем обычный таймер на 15 миллисекунд.
Время берётся по фактическому завершению ожидания. Браузер ставит продолжение в очередь, поэтому реальная задержка может быть больше запрошенной. В одном измерительном пакете задача повторялась 2 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
16,766 |
17,044 |
16,672 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 0,98× [0,92; 1,03].
Dart-код этого теста
for(final c in const [ ['webcrypto','WebCrypto: SHA-256','64 KiB; браузерный native API'], ['timer_wait','Ожидание таймера','15 ms requested; elapsed measured'], ['local_fetch','Локальный HTTP fetch','64 KiB; no-store; без WAN'], ['indexeddb','IndexedDB: put/get/delete','64 KiB; одна транзакция'], ['worker_transfer','Web Worker: transfer + ответ','64 KiB; копирование и суммирование'], ]) Workload(c[0],'Браузер / I/O',c[1],c[2],async:()async=>(await bridge.nativeAsync(c[0].toJS).toDart).toDartDouble),@JS('bench.nativeAsync') external JSPromise<JSNumber> nativeAsync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'timer_wait':await new Promise(r=>setTimeout(r,15));return 15;
33. Получение данных с локального сервера
Для просмотра влияния сборки на получение данных загружается небольшой двоичный файл с локального сервера.
Запрос fetch получает 64 КиБ с отключённым HTTP-кэшированием, преобразует ответ в массив и суммирует байты. Сервер расположен на том же компьютере. В одном измерительном пакете задача повторялась 32 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,565 |
0,523 |
0,498 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,07× [1,00; 1,10].
Dart-код этого теста
for(final c in const [ ['webcrypto','WebCrypto: SHA-256','64 KiB; браузерный native API'], ['timer_wait','Ожидание таймера','15 ms requested; elapsed measured'], ['local_fetch','Локальный HTTP fetch','64 KiB; no-store; без WAN'], ['indexeddb','IndexedDB: put/get/delete','64 KiB; одна транзакция'], ['worker_transfer','Web Worker: transfer + ответ','64 KiB; копирование и суммирование'], ]) Workload(c[0],'Браузер / I/O',c[1],c[2],async:()async=>(await bridge.nativeAsync(c[0].toJS).toDart).toDartDouble),@JS('bench.nativeAsync') external JSPromise<JSNumber> nativeAsync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'local_fetch':{const r=await fetch('payload.bin',{cache:'no-store'});if(!r.ok)throw Error('fetch '+r.status);return bench.sum(new Uint8Array(await r.arrayBuffer()));}
34. Работа с локальным хранилищем браузера
Локальный кэш веб-приложения может хранить буферы в IndexedDB. В опыте один буфер записывается, читается и удаляется.
Все действия выполняются в одной транзакции. Таймер останавливается после её завершения; прочитанные байты проверяются суммой. В одном измерительном пакете задача повторялась 73 раза.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,264 |
0,253 |
0,244 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,05× [1,02; 1,07].
Dart-код этого теста
for(final c in const [ ['webcrypto','WebCrypto: SHA-256','64 KiB; браузерный native API'], ['timer_wait','Ожидание таймера','15 ms requested; elapsed measured'], ['local_fetch','Локальный HTTP fetch','64 KiB; no-store; без WAN'], ['indexeddb','IndexedDB: put/get/delete','64 KiB; одна транзакция'], ['worker_transfer','Web Worker: transfer + ответ','64 KiB; копирование и суммирование'], ]) Workload(c[0],'Браузер / I/O',c[1],c[2],async:()async=>(await bridge.nativeAsync(c[0].toJS).toDart).toDartDouble),@JS('bench.nativeAsync') external JSPromise<JSNumber> nativeAsync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'indexeddb':return await new Promise((resolve,reject)=>{const tx=bench.db.transaction('scratch','readwrite');const store=tx.objectStore('scratch'); store.put(bench.bytes,'sample');let sum;store.get('sample').onsuccess=e=>{sum=bench.sum(e.target.result);};store.delete('sample'); tx.oncomplete=()=>resolve(sum);tx.>reject(tx.error);tx.onabort=()=>reject(tx.error);});
35. Обмен данными с фоновым обработчиком
Фоновый Web Worker позволяет вынести обработку данных из основного потока. Здесь ему передаётся буфер, а обратно возвращается сумма байтов.
Сначала создаётся копия 64 КиБ, затем её ArrayBuffer передаётся с передачей владения. Worker суммирует данные и отвечает. Полное время включает подготовку, обмен сообщениями, очередь фонового потока и вычисление. В одном измерительном пакете задача повторялась 76 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,046 |
0,045 |
0,045 |
|
Расширенная серия |
0,046 |
0,048 |
0,048 |
Основная серия. Разница находится около принятого порога 10%. JS / Wasm ST: 0,99× [0,95; 1,16].
Расширенная серия. Скорость практически одинакова. JS / Wasm ST: 0,96× [0,93; 0,97].
Dart-код этого теста
for(final c in const [ ['webcrypto','WebCrypto: SHA-256','64 KiB; браузерный native API'], ['timer_wait','Ожидание таймера','15 ms requested; elapsed measured'], ['local_fetch','Локальный HTTP fetch','64 KiB; no-store; без WAN'], ['indexeddb','IndexedDB: put/get/delete','64 KiB; одна транзакция'], ['worker_transfer','Web Worker: transfer + ответ','64 KiB; копирование и суммирование'], ]) Workload(c[0],'Браузер / I/O',c[1],c[2],async:()async=>(await bridge.nativeAsync(c[0].toJS).toDart).toDartDouble),@JS('bench.nativeAsync') external JSPromise<JSNumber> nativeAsync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'worker_transfer':return await new Promise((resolve,reject)=>{const copy=bench.bytes.slice().buffer;bench.worker.onmessage=e=>resolve(e.data);bench.worker.onmessage = ({data}) => { let sum=0; for(const v of new Uint32Array(data)) sum+=v; postMessage(sum);};
36. Изменение обычной HTML-страницы
При работе с обычной HTML-страницей изменение размеров элемента может потребовать пересчёта расположения. Этот эффект проверяется отдельной задачей.
JavaScript-помощник 100 раз меняет ширину элемента и читает offsetWidth. Чтение заставляет браузер завершить пересчёт. В обеих сборках работает один помощник, поэтому большая часть затрат относится к браузерному механизму раскладки. В одном измерительном пакете задача повторялась 46 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,576 |
0,553 |
0,549 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,04× [1,00; 1,06].
Dart-код этого теста
Workload('dom_layout','Браузер / I/O','DOM: запись стиля + forced layout','100 layout flushes; общий JS helper',sync:()=>bridge.nativeSync('dom_layout'.toJS))
JavaScript-часть, которую вызывает Dart
case 'dom_layout':{let sum=0;for(let i=0;i<100;i++){bench.dom.style.width=`${200+(i%17)}px`;sum+=bench.dom.offsetWidth;}return sum;}
Flutter: текст, виджеты, прокрутка и графика
37. Раскладка текста во Flutter
В текстовом редакторе или списке сообщений Flutter вычисляет размеры абзацев и переносы строк. Это заметная часть работы сложных экранов.
Создаются 100 уникальных абзацев Roboto размером 14 пикселей и шириной 300 пикселей. Каждый Paragraph проходит layout, его высота добавляется к контрольному результату, затем объект освобождается. Влияние компилятора здесь сочетается с работой графического движка. В одном измерительном пакете задача повторялась 6 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
3,473 |
3,226 |
3,194 |
|
Расширенная серия |
3,318 |
3,121 |
3,098 |
Основная серия. Разница находится около принятого порога 10%. JS / Wasm ST: 1,08× [1,05; 1,13].
Расширенная серия. Скорость практически одинакова. JS / Wasm ST: 1,06× [1,05; 1,07].
В одном запуске изменились размеры абзацев. Я повторил этот блок во всех трёх режимах и включил в таблицу результаты с одинаковой раскладкой текста.
Dart-код этого теста
Workload('text_layout','Flutter', 'Paragraph: формирование и layout','100 уникальных абзацев',sync:(){ var height=0.0; for(var i=0;i<100;i++) { final builder=ui.ParagraphBuilder(ui.ParagraphStyle(fontFamily:'Roboto',fontSize:14)) ..addText('Paragraph $i: Flutter text layout and measurement. ' * 3); final p=builder.build()..layout(const ui.ParagraphConstraints(width:300));height+=p.height;p.dispose(); }return height; })
38. Обновление экрана с 600 ячейками
Представим плотную панель из 600 небольших ячеек с текстом и меняющимся цветом. При каждом обновлении Flutter заново готовит виджеты и сцену.
Тест выполняет подготовительный кадр и 24 обновления. Каждое обновление ждёт endOfFrame. Поэтому отдельно измерены длительность всей серии и работа внутри кадра. Именно здесь особенно хорошо виден запас времени, который появляется после ускорения подготовки сцены. В одном замере выполнялась одна такая серия.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
427,875 |
410,513 |
410,495 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,04× [1,04; 1,05].
Подготовка одного кадра: JavaScript — 15,31 мс, Wasm ST — 7,92 мс, Wasm MT — 7,94 мс.
Dart-код этого теста
Future<num> runScene(String id)async{ setState((){scene=id;tick=0;}); await SchedulerBinding.instance.endOfFrame; num checksum=0; for(var i=0;i<uiFrames;i++){ setState(()=>tick=i+1); if(id=='ui_scroll'&&scroll.hasClients){scroll.jumpTo((i*127.0)%scroll.position.maxScrollExtent);} await SchedulerBinding.instance.endOfFrame; checksum+=i+1; } return checksum; }Widget sceneWidget(){ switch(scene){ case 'ui_widgets':return Wrap(children:List.generate(600,(i)=>SizedBox(width:34,height:20, child:ColoredBox(color:Color(0xff17304b+((i+tick)%12)*512),child:Text('${(i+tick)%99}',style:const TextStyle(fontSize:10)))))); case 'ui_scroll':return ListView.builder(controller:scroll,itemCount:50000,itemExtent:42, itemBuilder:(context,i)=>ListTile(dense:true,leading:const Icon(Icons.data_object),title:Text('Record $i'),trailing:Text('${i*17}'))); case 'ui_paint':case 'ui_layers':return CustomPaint(painter:StressPainter(tick,scene=='ui_layers'),size:Size.infinite); default:return Center(child:Column(mainAxisSize:MainAxisSize.min,children:[ const Icon(Icons.science_outlined,size:48,color:Color(0xff38bdf8)),const SizedBox(height:12), Text('${cases.length} задач · 3 режима',style:Theme.of(context).textTheme.titleLarge), const SizedBox(height:8),const Text('Вычисления · память · браузер · Flutter'), ])); } }
39. Прокрутка большого списка
В длинном списке важно, сколько работы требуется при смене видимой области. Используется ListView.builder с 50 000 строками фиксированной высоты.
Позиция прокрутки меняется 24 раза по одинаковой формуле через jumpTo. Flutter создаёт и раскладывает элементы видимой области. Такой сценарий воспроизводит смену содержимого при прокрутке, а характер движения определяется заданными программными шагами. В одном замере выполнялась одна такая серия.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
410,195 |
409,913 |
410,355 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,00× [1,00; 1,00].
Подготовка одного кадра: JavaScript — 5,33 мс, Wasm ST — 3,23 мс, Wasm MT — 3,17 мс.
Dart-код этого теста
Future<num> runScene(String id)async{ setState((){scene=id;tick=0;}); await SchedulerBinding.instance.endOfFrame; num checksum=0; for(var i=0;i<uiFrames;i++){ setState(()=>tick=i+1); if(id=='ui_scroll'&&scroll.hasClients){scroll.jumpTo((i*127.0)%scroll.position.maxScrollExtent);} await SchedulerBinding.instance.endOfFrame; checksum+=i+1; } return checksum; }Widget sceneWidget(){ switch(scene){ case 'ui_widgets':return Wrap(children:List.generate(600,(i)=>SizedBox(width:34,height:20, child:ColoredBox(color:Color(0xff17304b+((i+tick)%12)*512),child:Text('${(i+tick)%99}',style:const TextStyle(fontSize:10)))))); case 'ui_scroll':return ListView.builder(controller:scroll,itemCount:50000,itemExtent:42, itemBuilder:(context,i)=>ListTile(dense:true,leading:const Icon(Icons.data_object),title:Text('Record $i'),trailing:Text('${i*17}'))); case 'ui_paint':case 'ui_layers':return CustomPaint(painter:StressPainter(tick,scene=='ui_layers'),size:Size.infinite); default:return Center(child:Column(mainAxisSize:MainAxisSize.min,children:[ const Icon(Icons.science_outlined,size:48,color:Color(0xff38bdf8)),const SizedBox(height:12), Text('${cases.length} задач · 3 режима',style:Theme.of(context).textTheme.titleLarge), const SizedBox(height:8),const Text('Вычисления · память · браузер · Flutter'), ])); } }
40. Рисование множества фигур
Диаграмма или визуализация может рисовать тысячи простых фигур каждый кадр. Для такого случая выбран CustomPainter с 2 000 кругами.
В течение 24 кадров меняются координаты фигур. Замер включает подготовку команды рисования и ожидание кадров; показатели FrameTiming отдельно показывают этапы Flutter. Итог зависит одновременно от Dart-кода, рендерера и расписания браузера. В одном замере выполнялась одна такая серия.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
410,127 |
410,448 |
410,475 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,00× [1,00; 1,00].
Подготовка одного кадра: JavaScript — 5,13 мс, Wasm ST — 2,00 мс, Wasm MT — 2,03 мс.
Dart-код этого теста
Future<num> runScene(String id)async{ setState((){scene=id;tick=0;}); await SchedulerBinding.instance.endOfFrame; num checksum=0; for(var i=0;i<uiFrames;i++){ setState(()=>tick=i+1); if(id=='ui_scroll'&&scroll.hasClients){scroll.jumpTo((i*127.0)%scroll.position.maxScrollExtent);} await SchedulerBinding.instance.endOfFrame; checksum+=i+1; } return checksum; }Widget sceneWidget(){ switch(scene){ case 'ui_widgets':return Wrap(children:List.generate(600,(i)=>SizedBox(width:34,height:20, child:ColoredBox(color:Color(0xff17304b+((i+tick)%12)*512),child:Text('${(i+tick)%99}',style:const TextStyle(fontSize:10)))))); case 'ui_scroll':return ListView.builder(controller:scroll,itemCount:50000,itemExtent:42, itemBuilder:(context,i)=>ListTile(dense:true,leading:const Icon(Icons.data_object),title:Text('Record $i'),trailing:Text('${i*17}'))); case 'ui_paint':case 'ui_layers':return CustomPaint(painter:StressPainter(tick,scene=='ui_layers'),size:Size.infinite); default:return Center(child:Column(mainAxisSize:MainAxisSize.min,children:[ const Icon(Icons.science_outlined,size:48,color:Color(0xff38bdf8)),const SizedBox(height:12), Text('${cases.length} задач · 3 режима',style:Theme.of(context).textTheme.titleLarge), const SizedBox(height:8),const Text('Вычисления · память · браузер · Flutter'), ])); } }class StressPainter extends CustomPainter { StressPainter(this.tick,this.layers);final int tick;final bool layers; @override void paint(Canvas canvas,Size size){ final paint=Paint(); if(layers){ for(var i=0;i<100;i++){ final x=(i*31+tick*2)%size.width,y=(i*17)%size.height; final r=Rect.fromLTWH(x,y,72,60);canvas.saveLayer(r,Paint()..color=const Color(0xaaeeeeee)); canvas.clipRRect(RRect.fromRectAndRadius(r,const Radius.circular(14))); canvas.drawRect(r,paint..color=const Color(0xff38bdf8)); canvas.drawCircle(Offset(x+24,y+24),22,paint..color=const Color(0xffa78bfa));canvas.restore(); } }else{ for(var i=0;i<2000;i++){ paint.color=Color.fromARGB(160,(i*17)%256,(i*31)%256,190); canvas.drawCircle(Offset((i*37+tick*3)%size.width,(i*19)%size.height),3+(i%7).toDouble(),paint); } } } @override bool shouldRepaint(StressPainter old)=>old.tick!=tick||old.layers!=layers;}
41. Отрисовка с обрезкой и промежуточными слоями
Полупрозрачность, скруглённая обрезка и промежуточные слои усложняют отрисовку. Здесь эти операции повторяются для 100 элементов в каждом кадре.
Каждый элемент вызывает saveLayer, clipRRect, рисование прямоугольника и круга, затем restore. При сравнении JavaScript и Wasm одновременно меняются компилятор Dart и используемый Flutter-рендерер. В одном замере выполнялась одна такая серия.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
410,485 |
410,653 |
410,580 |
Основная серия. Скорость практически одинакова. JS / Wasm ST: 1,00× [1,00; 1,00].
Подготовка одного кадра: JavaScript — 3,41 мс, Wasm ST — 1,76 мс, Wasm MT — 1,69 мс.
Dart-код этого теста
Future<num> runScene(String id)async{ setState((){scene=id;tick=0;}); await SchedulerBinding.instance.endOfFrame; num checksum=0; for(var i=0;i<uiFrames;i++){ setState(()=>tick=i+1); if(id=='ui_scroll'&&scroll.hasClients){scroll.jumpTo((i*127.0)%scroll.position.maxScrollExtent);} await SchedulerBinding.instance.endOfFrame; checksum+=i+1; } return checksum; }Widget sceneWidget(){ switch(scene){ case 'ui_widgets':return Wrap(children:List.generate(600,(i)=>SizedBox(width:34,height:20, child:ColoredBox(color:Color(0xff17304b+((i+tick)%12)*512),child:Text('${(i+tick)%99}',style:const TextStyle(fontSize:10)))))); case 'ui_scroll':return ListView.builder(controller:scroll,itemCount:50000,itemExtent:42, itemBuilder:(context,i)=>ListTile(dense:true,leading:const Icon(Icons.data_object),title:Text('Record $i'),trailing:Text('${i*17}'))); case 'ui_paint':case 'ui_layers':return CustomPaint(painter:StressPainter(tick,scene=='ui_layers'),size:Size.infinite); default:return Center(child:Column(mainAxisSize:MainAxisSize.min,children:[ const Icon(Icons.science_outlined,size:48,color:Color(0xff38bdf8)),const SizedBox(height:12), Text('${cases.length} задач · 3 режима',style:Theme.of(context).textTheme.titleLarge), const SizedBox(height:8),const Text('Вычисления · память · браузер · Flutter'), ])); } }class StressPainter extends CustomPainter { StressPainter(this.tick,this.layers);final int tick;final bool layers; @override void paint(Canvas canvas,Size size){ final paint=Paint(); if(layers){ for(var i=0;i<100;i++){ final x=(i*31+tick*2)%size.width,y=(i*17)%size.height; final r=Rect.fromLTWH(x,y,72,60);canvas.saveLayer(r,Paint()..color=const Color(0xaaeeeeee)); canvas.clipRRect(RRect.fromRectAndRadius(r,const Radius.circular(14))); canvas.drawRect(r,paint..color=const Color(0xff38bdf8)); canvas.drawCircle(Offset(x+24,y+24),22,paint..color=const Color(0xffa78bfa));canvas.restore(); } }else{ for(var i=0;i<2000;i++){ paint.color=Color.fromARGB(160,(i*17)%256,(i*31)%256,190); canvas.drawCircle(Offset((i*37+tick*3)%size.width,(i*19)%size.height),3+(i%7).toDouble(),paint); } } } @override bool shouldRepaint(StressPainter old)=>old.tick!=tick||old.layers!=layers;}
Отдельные вычислительные модули
Следующие пять задач сравнивают специально написанные вычислительные модули. Dart организует запуск через JavaScript interop. В базовой JavaScript-сборке поочерёдно вызываются ручная JavaScript-функция, обычный Wasm-модуль и SIMD-вариант. Подготовка входной памяти выполнена заранее.
42. Суммирование массива в отдельной JavaScript-функции
Чтобы увидеть стоимость отдельного вычислительного ядра, сначала измеряем ручную JavaScript-функцию суммирования.
Функция читает 262 144 целых значения из линейной памяти Wasm через Uint32Array. Данные общие с двумя следующими вариантами. Полученное время служит базой сравнения скалярного и SIMD-модулей. В одном измерительном пакете задача повторялась 114 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,261 |
0,260 |
0,259 |
Это исходная JavaScript-реализация для сопоставления с Wasm-модулем. Три времени выше показывают её вызов из разных сборок Flutter.
Dart-код этого теста
for(final c in const [ ['external_js_sum','Сумма: написанный вручную JS','262 144 uint32; 1 MiB'], ['external_wasm_sum','Сумма: скалярный Wasm','тот же массив, точный i32 result'], ['external_simd_sum','Сумма: явный Wasm SIMD','i32x4.add; тот же массив'], ['external_js_i64','Точные 64 бита: JS BigInt','10 000 multiply/xor modulo 2^64'], ['external_wasm_i64','Точные 64 бита: Wasm i64','тот же алгоритм и точный результат'], ]) Workload(c[0],'Контрольные Wasm-модули',c[1],c[2],sync:()=>bridge.nativeSync(c[0].toJS)),@JS('bench.nativeSync') external double nativeSync(JSString id);
JavaScript-часть, которую вызывает Dart
sum:a=>{let s=0;for(let i=0;i<a.length;i++)s+=a[i];return s;}, case 'external_js_sum':return bench.sum(bench.ints);
43. Суммирование массива в отдельном Wasm-модуле
Теперь ту же сумму вычисляет небольшой модуль WebAssembly. На каждом шаге он загружает одно число и прибавляет его к накопленной сумме.
Объём и содержимое памяти совпадают с JavaScript-вариантом. Все значения выбраны так, чтобы итоговая сумма помещалась в используемый диапазон. В одном измерительном пакете задача повторялась 218 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,070 |
0,069 |
0,069 |
Wasm быстрее в 3,76 раза. Сравниваются модули внутри JavaScript-сборки. Отношение времени и 95%-й интервал: 3,76× [2,22; 4,27].
Dart-код этого теста
for(final c in const [ ['external_js_sum','Сумма: написанный вручную JS','262 144 uint32; 1 MiB'], ['external_wasm_sum','Сумма: скалярный Wasm','тот же массив, точный i32 result'], ['external_simd_sum','Сумма: явный Wasm SIMD','i32x4.add; тот же массив'], ['external_js_i64','Точные 64 бита: JS BigInt','10 000 multiply/xor modulo 2^64'], ['external_wasm_i64','Точные 64 бита: Wasm i64','тот же алгоритм и точный результат'], ]) Workload(c[0],'Контрольные Wasm-модули',c[1],c[2],sync:()=>bridge.nativeSync(c[0].toJS)),@JS('bench.nativeSync') external double nativeSync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'external_wasm_sum':return bench.wasm.sum(bench.ints.length);
Код измеренного WebAssembly-модуля
(module (memory (export "memory") 64) (func (export "sum") (param $n i32) (result i32) (local $p i32) (local $s i32) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32.add (local.get $s) (i32.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 1))) (br $loop))) (local.get $s)) ;; n must be a multiple of four. Tests cover n=0,4,8 and full input. (func (export "simd") (param $n i32) (result i32) (local $p i32) (local $s v128) (local.set $s (v128.const i32x4 0 0 0 0)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32x4.add (local.get $s) (v128.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 4))) (br $loop))) (i32.add (i32.add (i32x4.extract_lane 0 (local.get $s)) (i32x4.extract_lane 1 (local.get $s))) (i32.add (i32x4.extract_lane 2 (local.get $s)) (i32x4.extract_lane 3 (local.get $s))))) (func (export "i64mix") (param $n i32) (result i32) (local $i i32) (local $x i64) (local.set $x (i64.const 1234567)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $i) (local.get $n))) (local.set $x (i64.xor (i64.mul (local.get $x) (i64.const 6364136223846793005)) (i64.extend_i32_u (local.get $i)))) (local.set $x (i64.xor (local.get $x) (i64.shr_u (local.get $x) (i64.const 33)))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $loop))) (i32.and (i32.wrap_i64 (local.get $x)) (i32.const 2147483647))))
44. Суммирование массива с SIMD
Третий вариант складывает числа группами по четыре с помощью SIMD. Процессор выполняет несколько одинаковых операций одной инструкцией.
Модуль загружает вектор v128, применяет i32x4.add и в конце объединяет четыре компонента. Входная длина кратна четырём. В одном измерительном пакете задача повторялась 157 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,031 |
0,032 |
0,031 |
Wasm SIMD быстрее в 8,46 раза. Сравниваются модули внутри JavaScript-сборки. Отношение времени и 95%-й интервал: 8,46× [5,08; 9,47].
Dart-код этого теста
for(final c in const [ ['external_js_sum','Сумма: написанный вручную JS','262 144 uint32; 1 MiB'], ['external_wasm_sum','Сумма: скалярный Wasm','тот же массив, точный i32 result'], ['external_simd_sum','Сумма: явный Wasm SIMD','i32x4.add; тот же массив'], ['external_js_i64','Точные 64 бита: JS BigInt','10 000 multiply/xor modulo 2^64'], ['external_wasm_i64','Точные 64 бита: Wasm i64','тот же алгоритм и точный результат'], ]) Workload(c[0],'Контрольные Wasm-модули',c[1],c[2],sync:()=>bridge.nativeSync(c[0].toJS)),@JS('bench.nativeSync') external double nativeSync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'external_simd_sum':return bench.wasm.simd(bench.ints.length);
Код измеренного WebAssembly-модуля
(module (memory (export "memory") 64) (func (export "sum") (param $n i32) (result i32) (local $p i32) (local $s i32) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32.add (local.get $s) (i32.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 1))) (br $loop))) (local.get $s)) ;; n must be a multiple of four. Tests cover n=0,4,8 and full input. (func (export "simd") (param $n i32) (result i32) (local $p i32) (local $s v128) (local.set $s (v128.const i32x4 0 0 0 0)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32x4.add (local.get $s) (v128.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 4))) (br $loop))) (i32.add (i32.add (i32x4.extract_lane 0 (local.get $s)) (i32x4.extract_lane 1 (local.get $s))) (i32.add (i32x4.extract_lane 2 (local.get $s)) (i32x4.extract_lane 3 (local.get $s))))) (func (export "i64mix") (param $n i32) (result i32) (local $i i32) (local $x i64) (local.set $x (i64.const 1234567)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $i) (local.get $n))) (local.set $x (i64.xor (i64.mul (local.get $x) (i64.const 6364136223846793005)) (i64.extend_i32_u (local.get $i)))) (local.set $x (i64.xor (local.get $x) (i64.shr_u (local.get $x) (i64.const 33)))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $loop))) (i32.and (i32.wrap_i64 (local.get $x)) (i32.const 2147483647))))
45. Точные 64-битные вычисления в JavaScript
Для точных 64-битных вычислений в JavaScript используется BigInt. В этой задаче значения многократно умножаются и перемешиваются.
На каждом из 10 000 шагов сохраняются младшие 64 бита, затем выполняется XOR со сдвинутым значением. Старшие разряды влияют на последующие шаги. Это базовая реализация для следующего Wasm-модуля. В одном измерительном пакете задача повторялась 112 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,181 |
0,179 |
0,180 |
Это исходная JavaScript-реализация для сопоставления с Wasm-модулем. Три времени выше показывают её вызов из разных сборок Flutter.
Dart-код этого теста
for(final c in const [ ['external_js_sum','Сумма: написанный вручную JS','262 144 uint32; 1 MiB'], ['external_wasm_sum','Сумма: скалярный Wasm','тот же массив, точный i32 result'], ['external_simd_sum','Сумма: явный Wasm SIMD','i32x4.add; тот же массив'], ['external_js_i64','Точные 64 бита: JS BigInt','10 000 multiply/xor modulo 2^64'], ['external_wasm_i64','Точные 64 бита: Wasm i64','тот же алгоритм и точный результат'], ]) Workload(c[0],'Контрольные Wasm-модули',c[1],c[2],sync:()=>bridge.nativeSync(c[0].toJS)),@JS('bench.nativeSync') external double nativeSync(JSString id);
JavaScript-часть, которую вызывает Dart
bench.i64js=n=>{let x=1234567n;for(let i=0;i<n;i++){x=BigInt.asUintN(64,x*6364136223846793005n^BigInt(i));x^=x>>33n;}return Number(x&2147483647n);}; case 'external_js_i64':return bench.i64js(10000);
46. Точные 64-битные вычисления в Wasm
WebAssembly имеет встроенные инструкции для 64-битных целых. Они позволяют выразить тот же алгоритм непосредственно машинными операциями.
Последовательность действий и контрольные ответы совпадают с JavaScript BigInt. Разница времени описывает стоимость двух способов выполнения точной 64-битной арифметики. Для произвольной длины числа, как в 127-битном тесте Dart BigInt, нужен другой механизм. В одном измерительном пакете задача повторялась 512 раз.
|
Серия |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
|---|---|---|---|
|
Основная серия |
0,014 |
0,014 |
0,014 |
Wasm быстрее в 13,07 раза. Сравниваются модули внутри JavaScript-сборки. Отношение времени и 95%-й интервал: 13,07× [12,93; 21,78].
Dart-код этого теста
for(final c in const [ ['external_js_sum','Сумма: написанный вручную JS','262 144 uint32; 1 MiB'], ['external_wasm_sum','Сумма: скалярный Wasm','тот же массив, точный i32 result'], ['external_simd_sum','Сумма: явный Wasm SIMD','i32x4.add; тот же массив'], ['external_js_i64','Точные 64 бита: JS BigInt','10 000 multiply/xor modulo 2^64'], ['external_wasm_i64','Точные 64 бита: Wasm i64','тот же алгоритм и точный результат'], ]) Workload(c[0],'Контрольные Wasm-модули',c[1],c[2],sync:()=>bridge.nativeSync(c[0].toJS)),@JS('bench.nativeSync') external double nativeSync(JSString id);
JavaScript-часть, которую вызывает Dart
case 'external_wasm_i64':return bench.wasm.i64mix(10000);
Код измеренного WebAssembly-модуля
(module (memory (export "memory") 64) (func (export "sum") (param $n i32) (result i32) (local $p i32) (local $s i32) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32.add (local.get $s) (i32.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 1))) (br $loop))) (local.get $s)) ;; n must be a multiple of four. Tests cover n=0,4,8 and full input. (func (export "simd") (param $n i32) (result i32) (local $p i32) (local $s v128) (local.set $s (v128.const i32x4 0 0 0 0)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32x4.add (local.get $s) (v128.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 4))) (br $loop))) (i32.add (i32.add (i32x4.extract_lane 0 (local.get $s)) (i32x4.extract_lane 1 (local.get $s))) (i32.add (i32x4.extract_lane 2 (local.get $s)) (i32x4.extract_lane 3 (local.get $s))))) (func (export "i64mix") (param $n i32) (result i32) (local $i i32) (local $x i64) (local.set $x (i64.const 1234567)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $i) (local.get $n))) (local.set $x (i64.xor (i64.mul (local.get $x) (i64.const 6364136223846793005)) (i64.extend_i32_u (local.get $i)))) (local.set $x (i64.xor (local.get $x) (i64.shr_u (local.get $x) (i64.const 33)))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $loop))) (i32.and (i32.wrap_i64 (local.get $x)) (i32.const 2147483647))))
Почему ускорение Dart и скорость интерфейса различаются
В сценариях интерфейса я измерял две вещи: сколько длится вся серия из 24 обновлений после подготовительного кадра и сколько работы Flutter выполняет внутри каждого кадра. Между обновлениями код ждёт endOfFrame. Из-за этого длительность серии чувствительна к расписанию кадров браузера.
Подготовка кадра (build) охватывает работу Flutter с виджетами, расположением элементов и формированием сцены. Растеризация (raster) — этап обработки сцены и отправки графических команд, который фиксирует движок Flutter. Аппаратное время GPU и физический показ пикселей требуют отдельной инструментальной трассировки.
В таблице приведены медианы и p95. Последний показатель означает, что 95% измеренных значений уложились в это время. Интервалы дольше 25 мс используются как диагностический признак задержек между кадрами.
|
Сценарий и режим |
Подготовка кадра, мс |
Подготовка: p95, мс |
Растеризация, мс |
Растеризация: p95, мс |
Интервал кадров: p95, мс |
Интервалы дольше 25 мс |
|---|---|---|---|---|---|---|
|
Прокрутка большого спискаJavaScript · 1050 кадров |
5,332 |
6,448 |
0,416 |
0,540 |
18,563 |
0,0% |
|
Прокрутка большого спискаWasm · один поток отрисовки · 1050 кадров |
3,226 |
5,262 |
0,412 |
0,627 |
18,538 |
0,0% |
|
Прокрутка большого спискаWasm · отдельный поток отрисовки · 1050 кадров |
3,170 |
5,074 |
0,625 |
1,203 |
18,570 |
0,0% |
|
Рисование множества фигурJavaScript · 1050 кадров |
5,133 |
5,567 |
1,585 |
1,735 |
18,555 |
0,0% |
|
Рисование множества фигурWasm · один поток отрисовки · 1050 кадров |
2,000 |
2,674 |
2,650 |
3,019 |
18,523 |
0,0% |
|
Рисование множества фигурWasm · отдельный поток отрисовки · 1050 кадров |
2,025 |
2,468 |
2,812 |
3,300 |
18,618 |
0,0% |
|
Отрисовка с обрезкой и промежуточными слоямиJavaScript · 1050 кадров |
3,413 |
3,930 |
1,246 |
1,456 |
18,580 |
0,0% |
|
Отрисовка с обрезкой и промежуточными слоямиWasm · один поток отрисовки · 1050 кадров |
1,756 |
2,880 |
2,248 |
2,655 |
18,546 |
0,0% |
|
Отрисовка с обрезкой и промежуточными слоямиWasm · отдельный поток отрисовки · 1050 кадров |
1,688 |
2,328 |
2,413 |
3,036 |
18,570 |
0,0% |
|
Обновление экрана с 600 ячейкамиJavaScript · 1050 кадров |
15,310 |
16,621 |
1,040 |
1,205 |
18,036 |
0,0% |
|
Обновление экрана с 600 ячейкамиWasm · один поток отрисовки · 1050 кадров |
7,920 |
8,805 |
1,208 |
1,362 |
18,559 |
0,0% |
|
Обновление экрана с 600 ячейкамиWasm · отдельный поток отрисовки · 1050 кадров |
7,943 |
8,583 |
1,370 |
1,797 |
18,575 |
0,0% |
Все четыре сценария показали близкую общую длительность в JavaScript и Wasm ST. При этом подготовка кадров у Wasm занимала меньше времени. Такой запас полезен для усложнения виджетов, дополнительной обработки данных и уменьшения риска задержек при более тяжёлой нагрузке.
Wasm MT также дал близкую длительность серий к Wasm ST. Измеряемый этап растеризации в сценариях списка и 600 ячеек стал дольше. Это отражает затраты организации работы рендерера; оценка видимого эффекта учитывает и всю серию, и интервалы между кадрами.
Все отношения времени кадров и доверительные интервалы
JS / ST и JS / MT сравнивают подготовку кадра. В столбцах ST / MT сопоставлены два режима Wasm. Значение выше единицы означает, что режим в знаменателе выполнил этап быстрее.
|
Сценарий |
Подготовка: JS / ST |
Подготовка: JS / MT |
Вся серия: ST / MT |
Растеризация: ST / MT |
|---|---|---|---|---|
|
Прокрутка большого списка |
1,65× [1,60; 1,73] |
1,69× [1,66; 1,75] |
1,00× [1,00; 1,00] |
0,66× [0,63; 0,72] |
|
Рисование множества фигур |
2,56× [2,43; 2,61] |
2,54× [2,44; 2,60] |
1,00× [1,00; 1,00] |
0,94× [0,93; 0,97] |
|
Отрисовка с обрезкой и промежуточными слоями |
1,94× [1,87; 2,10] |
2,02× [1,97; 2,09] |
1,00× [1,00; 1,00] |
0,92× [0,91; 0,96] |
|
Обновление экрана с 600 ячейками |
1,93× [1,90; 1,96] |
1,91× [1,87; 1,99] |
1,00× [1,00; 1,00] |
0,88× [0,87; 0,90] |
Обычный цикл и Dart Int32x4 внутри одной сборки
Векторный код быстрее работал после перехода с JavaScript на Wasm. Внутри самой Wasm-сборки обычный цикл суммирования оказался быстрее Int32x4. Выбор структуры данных и способа доступа способен заметно повлиять на конечный результат.
|
Режим |
Обычный цикл, мс |
Dart Int32x4, мс |
Время цикла / Int32x4 · интервал 95% |
|---|---|---|---|
|
JavaScript |
0,100 |
0,349 |
0,28× [0,28; 0,30] |
|
Wasm · один поток отрисовки |
0,108 |
0,190 |
0,57× [0,54; 0,58] |
|
Wasm · отдельный поток отрисовки |
0,108 |
0,187 |
0,58× [0,54; 0,58] |
Установленный Dart 3.13.1 использует NaiveInt32x4List для хранения списка, а I32x4.operator + обращается к WasmI32x4. Время всей операции складывается из доступа к данным, представления значений, проверок и арифметики. Эти отдельные составляющие в данном опыте измерены совместно.
Инструмент WABT 1.0.39 успешно прочитал специальный контрольный модуль и подтвердил SIMD-инструкцию i32x4.add. На GC-типах полного Flutter-приложения разбор остановился. Вывод о реализации Dart опирается на исходники установленного SDK и фактические замеры.
Сколько занимает открытие приложения
Время запуска измерено до первого завершённого кадра Flutter и подготовки тестовых данных. Первое открытие выполнялось в новом контексте Chrome с пустым HTTP-кэшем. При повторном открытии использовался тот же контекст. Браузерный процесс, ОС и её файловый кэш сохранялись.
|
Режим |
Первое открытие, мс |
Повторное открытие, мс |
Переданные ресурсы при первом открытии, МиБ |
|---|---|---|---|
|
JavaScript |
337,650 |
145,845 |
7,56 |
|
Wasm · один поток отрисовки |
225,775 |
96,325 |
5,43 |
|
Wasm · отдельный поток отрисовки |
181,795 |
63,278 |
5,43 |
На этом локальном стенде быстрее открывались Wasm-сборки. Ресурсы передавались с того же компьютера в исходном виде, без сжатия. Поэтому таблица описывает локальный запуск при указанных условиях. Публичный сайт добавляет задержки сети и свои настройки доставки ресурсов.
Программный маркер готовности ставится после первого endOfFrame и подготовки данных. Сумма переданных ресурсов рассчитана по Resource Timing; охват заголовков, основного документа и загрузок рабочих потоков определяется этим API. Замеры запуска относятся к основной серии, до последнего уточнения 64-битного контрольного модуля.
Размеры окончательных файлов сборки и их контрольные суммы
|
Файл приложения |
Размер, байт |
Контрольная сумма SHA-256 |
|---|---|---|
|
site/js/main.dart.js |
2,211,934 |
|
|
site/wasm/main.dart.wasm |
1,802,312 |
|
|
site/wasm/main.dart.mjs |
30,966 |
|
Что это означает для разработки
Для обычного интерфейса Wasm даёт прежде всего запас времени внутри кадра. На нашем экране с 600 ячейками подготовка сцены ускорилась почти вдвое, а длительность серии сократилась примерно на четыре процента. Такой результат закономерен для сценария, который ждёт следующий кадр браузера.
Для вычислений важны операция и форма данных. Wasm хорошо показал себя в целочисленных операциях, рекурсии и фильтре изображения. JavaScript выиграл обычные циклы с дробными числами и часть обработки текста. Даже близкие операции — кодирование и декодирование — дали разные результаты.
Крупные ускорения получены в специально организованных модулях. Суммирование с явным SIMD выиграло у ручного JavaScript-цикла в 8,46 раза, а точные 64-битные вычисления — в 13,07 раза. Эти реализации используют конкретные машинные возможности Wasm. Полный путь приложения добавляет подготовку и передачу данных.
Частые короткие вызовы JavaScript требуют внимания к организации кода. В тестах interop быстрее работала JavaScript-сборка. Передача массива за один вызов позволяет объединить полезную работу, а её результат зависит от размера массива и стоимости преобразования представлений.
Браузерные службы и ожидание имеют собственную стоимость. В основном сравнении WebCrypto, IndexedDB, таймер и локальный запрос показали близкую скорость. Переход компилятора влияет на окружающий Dart-код; время браузерной службы продолжает участвовать в результате.
Общее ускорение подчиняется простой зависимости: чем больше исходного времени занимает ускоренная часть, тем заметнее выигрыш всего действия. Это выражается законом Амдала:
Общее ускорение = 1 / ((1 − p) + p / s)p — доля исходного времени в ускоренной частиs — ускорение этой части
Веб-сборки Flutter уже сочетают технологии: JavaScript-вариант использует Wasm внутри CanvasKit, а Wasm-вариант запускает Dart через WasmGC и рисует через Skwasm. Поэтому эффект перехода складывается из компиляции Dart, рендерера, взаимодействия с браузером и характера нагрузки.
Подробности измерений и проверки
Подготовка и порядок запусков
Перед реализацией сохранены гипотезы и протокол исследования. JavaScript-пилот подобрал количество повторов в пакете, после чего оно зафиксировано одинаковым для трёх режимов. Целевое время пакета составляло 25 мс, верхний предел — 512 повторов. После прогрева некоторые быстрые пакеты занимают меньше целевого времени; результаты нормированы на фактическое число повторов.
В основной серии выполнены все шесть перестановок порядка режимов. Для сопоставляемых сессий порядок задач перемешан одинаково. Во время замера таблица результатов остаётся неподвижной; данные прошлых запусков временно убираются из интерфейса и возвращаются после завершения. Между задачами предусмотрены паузы. Компиляция завершена до начала измерений.
Обычный вычислительный тест предварительно выполнялся минимум три пакета и 150 мс, максимум 200 пакетов. Для xorshift после диагностики установлен минимум 1 000 мс. UI-сценарий проходил один полный предварительный пакет. Дополнительная серия семи задач добавила 15 предварительных пакетов перед 21 учитываемым замером. Её план записан до запуска, все 36 сессий сохранены целиком.
Статистика и правильность ответов
Доверительные интервалы получены bootstrap-пересчётом: 4 000 повторных выборок из парных сессионных отношений. Границы практической разницы — 1 / 1,10 и 1,10. Интервалы рассчитаны отдельно для каждой задачи; исследование имеет разведочный характер, с оценками без поправки на множественные сравнения. Кадры внутри одной сессии используются для описания распределения, а единицей статистического сравнения остаётся сессия.
Каждая задача возвращает используемый контрольный ответ. Между повторениями проверяются конечность значения и воспроизводимость. Целочисленные ответы должны точно совпадать между режимами; для скалярного произведения, математических функций, матриц и аудиофильтра принят относительный допуск 10⁻⁸. Основные целочисленные задачи сохраняют промежуточные значения в точном диапазоне JavaScript.
Дополнительно проверены известные значения xorshift, скалярного произведения, матриц, фильтра изображения, Fibonacci, Unicode, JSON, регулярных выражений и BigInt. Матрицы и фильтр сверены с отдельными реализациями на нескольких размерах. Для SHA-256 использован известный ответ для abc. Wasm-модули проверены на нескольких входах, а 64-битная арифметика — независимой реализацией Python.
В принятых в расчёт основных и дополнительных сессиях подтверждены фактическая компиляция Wasm, видимость страницы и успешное завершение задач. В основном опыте отдельно проверены изоляция страницы и рабочий поток отрисовки. Ошибки браузерной консоли отсутствовали. Автоматизация проверила выбор категории, ручной запуск, экспорт и повторное чтение результатов, переход между сборками, открытие отчёта и компоновку на узкой ширине.
Dart: независимые контрольные и граничные тесты
import 'dart:typed_data';import 'package:flutter_test/flutter_test.dart';import 'package:wasm_versus_javascript/kernels.dart';void main(){ test('Independent known answers and boundary cases',(){expect(validateKernels()['passed'],15);}); test('Matrix product matches independent dot-product reference',(){ for(final n in [1,2,3,5,9]){ final a=List.generate(n*n,(i)=>(i%7-3).toDouble()); final b=List.generate(n*n,(i)=>(i%11-5).toDouble()); final got=multiply(a,b,n); for(var row=0;row<n;row++){for(var col=0;col<n;col++){ final expected=List.generate(n,(k)=>a[row*n+k]*b[k*n+col]).fold<double>(0,(x,y)=>x+y); expect(got[row*n+col],expected); }} } }); test('Stencil matches an independent neighborhood implementation',(){ for(final w in [2,3,4,7,16]){ final values=Uint32List.fromList(List.generate(w*w,(i)=>(i*17)%256)); var expected=0; for(var y=1;y<w-1;y++){for(var x=1;x<w-1;x++){ var pixel=0;for(final dy in [-1,0,1]){for(final dx in [-1,0,1]){pixel+=values[(y+dy)*w+x+dx];}} expected+=pixel~/9; }}expect(stencil(values,w),expected); } }); test('Seeded kernels return stable finite observable outputs',(){ final kernels=makeKernels(DataSet(20261009)); expect(kernels.map((k)=>k.id).toSet().length,kernels.length); for(final k in kernels){final a=k.run(),b=k.run();expect(a.isFinite,true,reason:k.id);expect(a,b,reason:k.id);} });}
Как уточнялся эксперимент
В первоначальном xorshift-тесте первые замеры JavaScript были быстрее последующих. Для проверки выполнены 18 дополнительных сессий: 15 предварительных пакетов и семь учитываемых. Отношение последних и первых трёх значений во всех этих сессиях попало в диапазон от 0,8 до 1,25. После этого прогрев xorshift увеличен, исправлены границы доступности элементов управления и стабилизирована UI-нагрузка. Все 18 основных сессий запущены заново.
Диагностическая серия дала JavaScript 1,680 мс, Wasm ST 0,619 мс, Wasm MT 0,619 мс. Она содержит 126 учитываемых и 270 предварительных пакетов. Окончательные основные таблицы используют полный повторный прогон.
Первый вариант 64-битного контроля позволял получить младший результат 32-битными операциями. После аудита добавлено перемешивание старших разрядов через сдвиг на каждом шаге. Обе исправленные задачи измерены в отдельной серии из 18 контекстов и семи пакетов. Новые 252 пакета заменили прежние в итоговых строках. Остальные 44 задачи сохранили основную серию. В окончательной основной таблице 5 796 пакетов.
В расширенной серии один запуск дал другую высоту абзацев. Это означало изменение результата работы, поэтому соответствующий блок сессии 10 целиком сохранён отдельно и повторён во всех трёх режимах. Архив включает 441 учитываемый и 315 предварительных пакетов этого блока. В итог вошёл повтор с одинаковой раскладкой текста во всех сборках.
Для этой статьи дополнительно завершена расширенная проверка семи задач, результаты которой показаны в начале и внутри соответствующих кейсов. Она хранится отдельной серией с тем же браузером и V8. Такое представление сохраняет и исходный результат, и поведение при увеличенном числе наблюдений.
Задачи с изменением скорости внутри основной сессии
Диагностика сравнивает медиану последних и первых трёх замеров. Хотя бы одна сессия следующих задач вышла за диапазон отношения от 0,8 до 1,25: Обмен данными с фоновым обработчиком; Вычисления с условиями; Изменение обычной HTML-страницы. Все эти значения сохранены в расчётах. Возможные составляющие разброса — прогрев, сборка мусора и фоновая нагрузка.
Область применимости
Выводы относятся к описанным реализациям, размерам данных и указанному настольному Chrome на Apple M4. Повторения в контекстах одного процесса характеризуют одну среду. Температура, фоновые службы ОС, сборка мусора и кэши могут влиять на разброс. Входы микротестов повторяются, поэтому оптимизатор может специализировать код под них.
Подготовка данных выполняется до таймера, кроме тестов создания, копирования и кодирования. Затраты вызова и таймера входят в результаты. Работа с данными измеряется на конкретных объёмах — например, 16 КиБ и 4 МиБ для последовательного чтения. Такие рабочие наборы позволяют сравнить эти сценарии.
Сводная таблица основной серии
В этой таблице сохранены все исходные сопоставления. Ссылки ведут к разбору задачи, её коду и результатам расширенной серии, когда она проводилась.
|
Задача |
JavaScript, мс |
Wasm ST, мс |
Wasm MT, мс |
JS / ST · 95% |
JS / MT · 95% |
Результат основной серии |
|---|---|---|---|---|---|---|
|
1,678 |
0,617 |
0,615 |
2,72× [2,71; 2,72] |
2,72× [2,70; 2,72] |
Wasm быстрее в 2,72 раза. |
|
|
0,078 |
0,241 |
0,239 |
0,32× [0,32; 0,34] |
0,33× [0,32; 0,33] |
JavaScript быстрее в 3,09 раза. |
|
|
0,252 |
0,255 |
0,254 |
1,00× [0,99; 1,01] |
1,00× [0,99; 1,01] |
Скорость практически одинакова. |
|
|
0,307 |
0,443 |
0,443 |
0,69× [0,67; 0,72] |
0,69× [0,67; 0,72] |
JavaScript быстрее в 1,44 раза. |
|
|
0,349 |
0,184 |
0,182 |
1,88× [1,86; 1,97] |
1,91× [1,88; 1,97] |
Wasm быстрее в 1,88 раза. |
|
|
0,163 |
0,169 |
0,168 |
0,96× [0,95; 1,03] |
0,97× [0,94; 1,04] |
Скорость практически одинакова. |
|
|
0,631 |
0,809 |
0,654 |
0,77× [0,69; 0,97] |
0,99× [0,71; 1,08] |
Скорость заметно меняется между запусками. |
|
|
0,505 |
0,130 |
0,128 |
3,89× [3,84; 3,90] |
3,94× [3,89; 4,01] |
Wasm быстрее в 3,89 раза. |
|
|
1,319 |
0,475 |
0,475 |
2,78× [2,75; 2,84] |
2,80× [2,69; 2,84] |
Wasm быстрее в 2,78 раза. |
|
|
0,100 |
0,108 |
0,108 |
0,92× [0,91; 0,95] |
0,93× [0,91; 0,95] |
Скорость практически одинакова. |
|
|
0,349 |
0,190 |
0,187 |
1,85× [1,74; 1,89] |
1,87× [1,75; 1,92] |
Wasm быстрее в 1,85 раза. |
|
|
0,003 |
0,002 |
0,002 |
1,23× [1,00; 1,25] |
1,25× [1,00; 1,27] |
Скорость заметно меняется между запусками. |
|
|
0,642 |
0,531 |
0,529 |
1,20× [1,20; 1,22] |
1,22× [1,21; 1,22] |
Wasm быстрее в 1,20 раза. |
|
|
1,388 |
1,471 |
1,487 |
0,90× [0,78; 0,97] |
0,89× [0,77; 0,97] |
Скорость заметно меняется между запусками. |
|
|
0,059 |
0,070 |
0,070 |
0,83× [0,83; 0,84] |
0,84× [0,82; 0,85] |
JavaScript быстрее в 1,20 раза. |
|
|
0,101 |
0,169 |
0,169 |
0,59× [0,59; 0,60] |
0,60× [0,58; 0,60] |
JavaScript быстрее в 1,68 раза. |
|
|
0,078 |
0,084 |
0,084 |
0,92× [0,91; 0,94] |
0,93× [0,89; 0,95] |
Разница находится около принятого порога 10%. |
|
|
0,553 |
0,486 |
0,484 |
1,13× [1,11; 1,14] |
1,13× [1,12; 1,14] |
Wasm быстрее в 1,13 раза. |
|
|
3,441 |
3,679 |
3,738 |
0,94× [0,88; 1,40] |
0,92× [0,88; 1,41] |
Скорость заметно меняется между запусками. |
|
|
0,258 |
0,395 |
0,421 |
0,65× [0,61; 0,68] |
0,62× [0,61; 0,65] |
JavaScript быстрее в 1,54 раза. |
|
|
0,039 |
0,089 |
0,090 |
0,43× [0,43; 0,44] |
0,43× [0,43; 0,44] |
JavaScript быстрее в 2,30 раза. |
|
|
0,661 |
0,216 |
0,215 |
3,08× [1,86; 4,37] |
3,10× [1,91; 4,32] |
Wasm быстрее в 3,08 раза. |
|
|
0,055 |
0,174 |
0,174 |
0,32× [0,32; 0,33] |
0,32× [0,32; 0,33] |
JavaScript быстрее в 3,14 раза. |
|
|
0,153 |
0,284 |
0,286 |
0,54× [0,53; 0,55] |
0,54× [0,53; 0,55] |
JavaScript быстрее в 1,85 раза. |
|
|
0,470 |
0,340 |
0,341 |
1,38× [1,36; 1,40] |
1,38× [1,37; 1,38] |
Wasm быстрее в 1,38 раза. |
|
|
0,211 |
0,321 |
0,325 |
0,65× [0,65; 0,66] |
0,65× [0,65; 0,66] |
JavaScript быстрее в 1,53 раза. |
|
|
0,006 |
0,197 |
0,197 |
0,03× [0,03; 0,03] |
0,03× [0,03; 0,03] |
JavaScript быстрее в 33,52 раза. |
|
|
0,079 |
0,155 |
0,155 |
0,51× [0,50; 0,52] |
0,51× [0,50; 0,51] |
JavaScript быстрее в 1,98 раза. |
|
|
0,078 |
0,155 |
0,154 |
0,50× [0,50; 0,51] |
0,51× [0,51; 0,51] |
JavaScript быстрее в 1,98 раза. |
|
|
0,103 |
0,057 |
0,056 |
1,80× [1,77; 1,85] |
1,84× [1,80; 1,87] |
Wasm быстрее в 1,80 раза. |
|
|
0,024 |
0,024 |
0,024 |
1,02× [1,01; 1,03] |
1,02× [1,02; 1,03] |
Скорость практически одинакова. |
|
|
16,766 |
17,044 |
16,672 |
0,98× [0,92; 1,03] |
1,01× [0,98; 1,04] |
Скорость практически одинакова. |
|
|
0,565 |
0,523 |
0,498 |
1,07× [1,00; 1,10] |
1,13× [1,10; 1,16] |
Скорость практически одинакова. |
|
|
0,264 |
0,253 |
0,244 |
1,05× [1,02; 1,07] |
1,08× [1,06; 1,09] |
Скорость практически одинакова. |
|
|
0,046 |
0,045 |
0,045 |
0,99× [0,95; 1,16] |
1,00× [0,94; 1,12] |
Разница находится около принятого порога 10%. |
|
|
0,576 |
0,553 |
0,549 |
1,04× [1,00; 1,06] |
1,05× [1,04; 1,06] |
Скорость практически одинакова. |
|
|
3,473 |
3,226 |
3,194 |
1,08× [1,05; 1,13] |
1,09× [1,04; 1,15] |
Разница находится около принятого порога 10%. |
|
|
427,875 |
410,513 |
410,495 |
1,04× [1,04; 1,05] |
1,04× [1,04; 1,05] |
Скорость практически одинакова. |
|
|
410,195 |
409,913 |
410,355 |
1,00× [1,00; 1,00] |
1,00× [1,00; 1,00] |
Скорость практически одинакова. |
|
|
410,127 |
410,448 |
410,475 |
1,00× [1,00; 1,00] |
1,00× [1,00; 1,00] |
Скорость практически одинакова. |
|
|
410,485 |
410,653 |
410,580 |
1,00× [1,00; 1,00] |
1,00× [1,00; 1,00] |
Скорость практически одинакова. |
|
|
0,261 |
0,260 |
0,259 |
1,00× [0,99; 1,01] |
1,00× [0,99; 1,02] |
Отдельный модуль: сравнение внутри кейса |
|
|
0,070 |
0,069 |
0,069 |
1,01× [0,99; 1,03] |
1,01× [0,99; 1,02] |
Отдельный модуль: сравнение внутри кейса |
|
|
0,031 |
0,032 |
0,031 |
0,99× [0,94; 1,02] |
0,99× [0,98; 1,01] |
Отдельный модуль: сравнение внутри кейса |
|
|
0,181 |
0,179 |
0,180 |
1,01× [0,99; 1,02] |
1,01× [0,99; 1,02] |
Отдельный модуль: сравнение внутри кейса |
|
|
0,014 |
0,014 |
0,014 |
0,99× [0,98; 1,00] |
0,99× [0,97; 1,00] |
Отдельный модуль: сравнение внутри кейса |
Полный код приложения
В приложении используются четыре Dart-файла: вычислительные задачи, измерительный цикл, экран Flutter и объявления взаимодействия с JavaScript. Ниже — их полный код вместе с браузерными обработчиками и конфигурацией.
Входные данные и вычислительные задачи — kernels.dart
import 'dart:convert';import 'dart:math' as math;import 'dart:typed_data';/// Values keep all intermediate integer products below 2^53 on dart2js.class DataSet { DataSet(this.seed) { var state = seed; values = Uint32List(1 << 20); for (var i = 0; i < values.length; i++) { state = (state * 1664525 + 1013904223) & 0xffffffff; values[i] = state & 65535; } text = List.generate( 2000, (i) => 'item_$i: alpha βета 日本語 😀 ${values[i]}\n', ).join(); records = List.generate( 2000, (i) => <String, Object>{ 'id': i, 'name': 'item_$i', 'value': values[i], 'active': i.isEven, }, ); json = jsonEncode(records); } final int seed; late final Uint32List values; late final String text; late final List<Map<String, Object>> records; late final String json;}class Kernel { const Kernel(this.id, this.group, this.name, this.size, this.run); final String id, group, name, size; final num Function() run;}int bitMix(int seed, int count) { var x = seed; for (var i = 0; i < count; i++) { x = (x ^ (x << 13)) & 0xffffffff; x = (x ^ (x >> 17)) & 0xffffffff; x = (x ^ (x << 5)) & 0xffffffff; } return x;}double dot(List<double> a, List<double> b) { var sum = 0.0; for (var i = 0; i < a.length; i++) { sum += a[i] * b[i]; } return sum;}Float64List multiply(List<double> a, List<double> b, int n) { final out = Float64List(n * n); for (var i = 0; i < n; i++) { for (var k = 0; k < n; k++) { final v = a[i * n + k]; for (var j = 0; j < n; j++) { out[i * n + j] += v * b[k * n + j]; } } } return out;}int stencil(Uint32List image, int width) { var sum = 0; for (var y = 1; y < width - 1; y++) { for (var x = 1; x < width - 1; x++) { final p = y * width + x; sum += (image[p - width - 1] + image[p - width] + image[p - width + 1] + image[p - 1] + image[p] + image[p + 1] + image[p + width - 1] + image[p + width] + image[p + width + 1]) ~/ 9; } } return sum;}int fibonacci(int n) => n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);class Cell { Cell(this.value, [this.next]); final int value; final Cell? next;}abstract class Operation { int apply(int x);}class Add implements Operation { @override int apply(int x) => (x + 17) & 65535;}class Xor implements Operation { @override int apply(int x) => x ^ 8191;}class Multiply implements Operation { @override int apply(int x) => (x * 3) & 65535;}List<Kernel> makeKernels(DataSet data) { final a = Float64List.fromList( List.generate(65536, (i) => data.values[i] / 65536), ); final b = Float64List.fromList( List.generate(65536, (i) => data.values[i + 100] / 65536), ); final matrixA = Float64List.fromList(a.take(64 * 64).toList()); final matrixB = Float64List.fromList(b.take(64 * 64).toList()); final copy = Uint32List(data.values.length); final links = Uint32List(1 << 18); // Odd multiplier and increment form a full-period permutation modulo 2^18. for (var i = 0; i < links.length; i++) { links[i] = (i * 1664525 + 1013904223) & (links.length - 1); } final map = {for (var i = 0; i < 20000; i++) 'key_$i': data.values[i]}; final keys = List.generate(20000, (i) => 'key_${(i * 7919) % 20000}'); final operations = <Operation>[Add(), Xor(), Multiply()]; final regular = RegExp(r'item_(\d+):'); final encoded = utf8.encode(data.text); final big = (BigInt.one << 127) - BigInt.one; final lanes=Int32x4List(65536); final scalarLanes=Int32List(262144); for(var i=0;i<scalarLanes.length;i++) {scalarLanes[i]=data.values[i]&4095;} for(var i=0;i<lanes.length;i++) {lanes[i]=Int32x4(scalarLanes[i*4],scalarLanes[i*4+1],scalarLanes[i*4+2],scalarLanes[i*4+3]);} return [ Kernel('dart_vector','Вычисления','Dart Int32x4: сумма векторов','65 536 векторов; API не гарантирует SIMD',(){ var sum=Int32x4(0,0,0,0);for(var i=0;i<lanes.length;i++) {sum=sum+lanes[i];}return sum.x+sum.y+sum.z+sum.w; }), Kernel('dart_scalar_vector','Вычисления','Dart int: контроль суммы векторов','262 144 целых; тот же результат',(){ var sum=0;for(var i=0;i<scalarLanes.length;i++) {sum+=scalarLanes[i];}return sum; }), Kernel( 'int32', 'Вычисления', 'Целые: xorshift / битовые операции', '300 000 шагов; u32', () => bitMix(data.seed, 300000), ), Kernel( 'double_dot', 'Вычисления', 'Float64: скалярное произведение', '65 536 пар', () => dot(a, b), ), Kernel( 'transcendental', 'Вычисления', 'sin / cos / sqrt', '30 000 аргументов', () { var sum = 0.0; for (var i = 0; i < 30000; i++) { final x = a[i]; sum += math.sin(x) * math.cos(x) + math.sqrt(x); } return sum; }, ), Kernel( 'matrix', 'Вычисления', 'Матрицы: обычный тройной цикл', '64×64; Float64; включая output', () => multiply(matrixA, matrixB, 64).fold(0.0, (x, y) => x + y), ), Kernel( 'image_stencil', 'Вычисления', 'Изображение: box-filter 3×3', '256×256; без декодера/GPU', () => stencil(data.values, 256), ), Kernel( 'audio_fir', 'Вычисления', 'Аудио: FIR-фильтр', '8 192 отсчёта × 32 taps', () { var sum = 0.0; for (var i = 32; i < 8192; i++) { for (var j = 0; j < 32; j++) { sum += a[i - j] * b[j]; } } return sum; }, ), Kernel( 'branch', 'Вычисления', 'Условные переходы по данным', '262 144 значений', () { var sum = 0; for (var i = 0; i < 262144; i++) { final v = data.values[i]; if (v < 20000) { sum += v * 3; } else if (v < 40000) { sum -= v; } else { sum += v ~/ 3; } } return sum; }, ), Kernel( 'recursion', 'Вычисления', 'Рекурсивные вызовы: Fibonacci', 'n=25', () => fibonacci(25), ), for (final length in [4096, 1048576]) Kernel( 'memory_$length', 'Память', 'Последовательное чтение ${length == 4096 ? '16 KiB' : '4 MiB'}', '$length × Uint32', () { var sum = 0; for (var i = 0; i < length; i++) { sum += data.values[i]; } return sum; }, ), Kernel( 'random_memory', 'Память', 'Зависимое случайное чтение', '1 MiB; 262 144 переходов', () { var p = data.seed & (links.length - 1), sum = 0; for (var i = 0; i < links.length; i++) { p = links[p]; sum += p; } return sum; }, ), Kernel( 'bulk_copy', 'Память', 'TypedData.setAll: копирование', '4 MiB; заранее выделенный output', () { copy.setAll(0, data.values); return copy[0] + copy[copy.length - 1]; }, ), Kernel( 'allocation_short', 'Объекты', 'Короткоживущие объекты', '32 партии × 1 024 Cell', () { var sum = 0; for (var round = 0; round < 32; round++) { final cells = List.generate( 1024, (i) => Cell(data.values[i + round]), ); for (final c in cells) { sum += c.value; } } return sum; }, ), Kernel( 'allocation_graph', 'Объекты', 'Объектный граф и проход по ссылкам', '32 768 связанных Cell; allocation включён', () { Cell? head; for (var i = 0; i < 32768; i++) { head = Cell(data.values[i], head); } var sum = 0; while (head != null) { sum += head.value; head = head.next; } return sum; }, ), Kernel( 'dispatch', 'Объекты', 'Полиморфные вызовы', '300 000 вызовов; 3 реализации', () { var x = data.seed & 65535; for (var i = 0; i < 300000; i++) { x = operations[i % 3].apply(x); } return x; }, ), Kernel( 'sort', 'Коллекции', 'Сортировка чисел', '20 000 значений; копирование включено', () { final out = data.values.take(20000).toList()..sort(); var sum = 0; for (var i = 0; i < out.length; i++) { sum = (sum + out[i] * (i + 1)) % 1000000007; } return sum; }, ), Kernel( 'map', 'Коллекции', 'Хеш-таблица: поиск строковых ключей', '20 000 поисков; Map подготовлен', () { var sum = 0; for (final k in keys) { sum += map[k]!; } return sum; }, ), Kernel( 'string_build', 'Текст', 'StringBuffer / форматирование', '2 000 строк', () { final out = StringBuffer(); for (var i = 0; i < 2000; i++) { out.write('item_$i:${data.values[i]};'); } return out.toString().length; }, ), Kernel( 'utf8_encode', 'Текст', 'UTF-8: кодирование Unicode', '2 000 многоязычных строк', () => utf8.encode(data.text).fold<int>(0, (a, b) => a + b), ), Kernel( 'utf8_decode', 'Текст', 'UTF-8: декодирование Unicode', '2 000 многоязычных строк', () => utf8.decode(encoded).length, ), Kernel('regexp', 'Текст', 'RegExp: поиск и группы', '2 000 совпадений', () { var sum = 0; for (final m in regular.allMatches(data.text)) { sum += int.parse(m.group(1)!); } return sum; }), Kernel( 'json_encode', 'Сериализация', 'JSON: encode', '2 000 объектов × 4 поля', () => jsonEncode(data.records).length, ), Kernel( 'json_decode', 'Сериализация', 'JSON: decode и чтение поля', '2 000 объектов × 4 поля', () { final rows = jsonDecode(data.json) as List; var sum = 0; for (final r in rows) { sum += (r['value'] as num).toInt(); } return sum; }, ), Kernel( 'bigint', 'Вычисления', 'BigInt: точная арифметика', '1 000 modular multiply; 127 бит', () { var x = BigInt.from(data.seed); for (var i = 0; i < 1000; i++) { x = (x * x + BigInt.from(i)) % big; } return (x & BigInt.from(0x7fffffff)).toInt(); }, ), ];}/// Independent reference cases; executed in BOTH release browser targets too.Map<String, Object> validateKernels() { void check(bool yes, String name) { if (!yes) throw StateError('Reference failed: $name'); } check(bitMix(1, 0) == 1, 'xorshift empty'); check(bitMix(1, 1) == 270369, 'xorshift known vector'); check(bitMix(1, 2) == 67634689, 'xorshift second vector'); check(dot([], []) == 0, 'dot empty'); check(dot([1, 2, 3], [4, 5, 6]) == 32, 'dot exact'); check((dot([0.1, 0.2], [0.3, 0.4]) - 0.11).abs() < 1e-12, 'dot fractional'); check( multiply([1, 2, 3, 4], [5, 6, 7, 8], 2).asMap().entries.every((e)=>e.value==[19,22,43,50][e.key]), 'matrix known answer', ); check(multiply([2.5], [4], 1).single == 10, 'matrix scalar'); check( stencil(Uint32List.fromList(List.filled(9, 9)), 3) == 9, 'filter constant', ); check( stencil(Uint32List.fromList(List.generate(9, (i) => i)), 3) == 4, 'filter ramp', ); check( fibonacci(0) == 0 && fibonacci(1) == 1 && fibonacci(10) == 55, 'fibonacci references', ); check( utf8.decode(utf8.encode('β 日本語 😀')) == 'β 日本語 😀', 'unicode roundtrip', ); check(jsonDecode('{"n":17,"b":true}')["n"] == 17, 'JSON known answer'); check( RegExp(r'item_(\d+)').firstMatch('item_123')!.group(1) == '123', 'regexp reference', ); check( BigInt.from(7).modPow(BigInt.from(3), BigInt.from(13)) == BigInt.from(5), 'BigInt reference', ); return {'passed': 15, 'status': 'passed'};}
Измерительный цикл — benchmark.dart
import 'dart:convert';import 'dart:js_interop';import 'dart:math' as math;import 'dart:typed_data';import 'dart:ui' as ui;import 'package:flutter/scheduler.dart';import 'bridge.dart' as bridge;import 'kernels.dart';const protocolVersion='1.1';const dataSeed=20261009;const defaultSamples=7;const targetBatchMs=25.0;const warmupMs=150.0;const integerWarmupMs=1000.0;const maxRepeats=512;const uiFrames=24;final frameTimings=<Map<String,num>>[];void recordFrameTimings(List<ui.FrameTiming> frames) { for(final f in frames) { frameTimings.add({'startMs':f.timestampInMicroseconds(ui.FramePhase.buildStart)/1000, 'buildMs':f.buildDuration.inMicroseconds/1000,'rasterMs':f.rasterDuration.inMicroseconds/1000, 'totalMs':f.totalSpan.inMicroseconds/1000}); }}double median(List<double> values) { final sorted=[...values]..sort(); final n=sorted.length; return n.isOdd?sorted[n~/2]:(sorted[n~/2-1]+sorted[n~/2])/2;}class Workload { Workload(this.id,this.group,this.name,this.size,{this.sync,this.async}); final String id,group,name,size; final num Function()? sync; final Future<num> Function()? async; Map<String,Object> get description=>{'id':id,'group':group,'name':name,'size':size}; Future<num> batch(int repeats) async { num checksum=0; if(sync!=null) {for(var i=0;i<repeats;i++) {checksum+=sync!();}} else {for(var i=0;i<repeats;i++) {checksum+=await async!();}} return checksum/repeats; }}List<Workload> workloads(Future<num> Function(String) uiRun) { final data=DataSet(dataSeed); final interop=Uint32List.fromList(data.values.take(65536).toList()); return [ for(final k in makeKernels(data)) Workload(k.id,k.group,k.name,k.size,sync:k.run), Workload('interop_scalar','Граница JS','Dart → JS: маленькие вызовы','20 000 вызовов',sync:(){ var sum=0.0; for(var i=0;i<20000;i++) {sum+=bridge.scalar(i.toDouble());} return sum; }), Workload('interop_batch','Граница JS','Dart → JS: один пакет','65 536 чисел; toJS + sum',sync:()=>bridge.sum(interop.toJS)), Workload('interop_roundtrip','Граница JS','TypedData: toJS → toDart','65 536 чисел; включая доступ JS',sync:(){ final js=interop.toJS; final sum=bridge.sum(js); return sum+js.toDart.last; }), Workload('microtasks','Асинхронность','Future / microtasks','1 000 await Future.value',async:()async{ var sum=0;for(var i=0;i<1000;i++) {sum+=await Future.value(i);}return sum; }), for(final c in const [ ['webcrypto','WebCrypto: SHA-256','64 KiB; браузерный native API'], ['timer_wait','Ожидание таймера','15 ms requested; elapsed measured'], ['local_fetch','Локальный HTTP fetch','64 KiB; no-store; без WAN'], ['indexeddb','IndexedDB: put/get/delete','64 KiB; одна транзакция'], ['worker_transfer','Web Worker: transfer + ответ','64 KiB; копирование и суммирование'], ]) Workload(c[0],'Браузер / I/O',c[1],c[2],async:()async=>(await bridge.nativeAsync(c[0].toJS).toDart).toDartDouble), Workload('dom_layout','Браузер / I/O','DOM: запись стиля + forced layout','100 layout flushes; общий JS helper',sync:()=>bridge.nativeSync('dom_layout'.toJS)), for(final c in const [ ['external_js_sum','Сумма: написанный вручную JS','262 144 uint32; 1 MiB'], ['external_wasm_sum','Сумма: скалярный Wasm','тот же массив, точный i32 result'], ['external_simd_sum','Сумма: явный Wasm SIMD','i32x4.add; тот же массив'], ['external_js_i64','Точные 64 бита: JS BigInt','10 000 multiply/xor modulo 2^64'], ['external_wasm_i64','Точные 64 бита: Wasm i64','тот же алгоритм и точный результат'], ]) Workload(c[0],'Контрольные Wasm-модули',c[1],c[2],sync:()=>bridge.nativeSync(c[0].toJS)), Workload('text_layout','Flutter', 'Paragraph: формирование и layout','100 уникальных абзацев',sync:(){ var height=0.0; for(var i=0;i<100;i++) { final builder=ui.ParagraphBuilder(ui.ParagraphStyle(fontFamily:'Roboto',fontSize:14)) ..addText('Paragraph $i: Flutter text layout and measurement. ' * 3); final p=builder.build()..layout(const ui.ParagraphConstraints(width:300));height+=p.height;p.dispose(); }return height; }), for(final c in const [ ['ui_widgets','Widget build + layout','600 ячеек; 24 обновления'], ['ui_scroll','Прокрутка списка','50 000 строк; 24 jumpTo'], ['ui_paint','Canvas: векторная отрисовка','2 000 кругов × 24 кадра'], ['ui_layers','Canvas: clipping / saveLayer','100 слоёв × 24 кадра'], ]) Workload(c[0],'Flutter',c[1],c[2],async:()=>uiRun(c[0])), ];}Future<Map<String,Object?>> measure({ required List<Workload> cases, required Map<String,dynamic> config, required void Function(String, Map<String,Object?>?) progress,}) async { final metadata=jsonDecode(bridge.metadata().toDart) as Map<String,dynamic>; const wasm=bool.fromEnvironment('dart.tool.dart2wasm'); final mode=wasm?(metadata['single']==true?'wasm-st':'wasm-mt'):'js'; final validation=validateKernels(); final count=(config['samples'] as num? ?? defaultSamples).toInt().clamp(1,31); final session=(config['session'] as num? ?? 0).toInt(); final pilot=config['pilot']==true; final supplied=Map<String,dynamic>.from(config['repeats'] as Map? ?? {}); final only=(config['only'] as List?)?.cast<String>(); final selected=cases.where((c)=>only==null||only.contains(c.id)).toList(); selected.shuffle(math.Random(dataSeed+session)); final results=<Map<String,Object?>>[]; final chosen=<String,int>{}; final began=DateTime.now().toUtc().toIso8601String(); bridge.startRun(); frameTimings.clear(); for(final c in selected) { progress(c.name,null); bridge.status(c.id.toJS); await Future<void>.delayed(const Duration(milliseconds:30)); try { var repeats=(supplied[c.id] as num?)?.toInt() ?? 1; final isUi=c.id.startsWith('ui_'); if((pilot||!supplied.containsKey(c.id))&&!isUi) { // Calibration occurs before measured samples, never across target variants. await c.batch(1); final t=bridge.clockNow(); await c.batch(1); final elapsed=bridge.clockNow()-t; repeats=(targetBatchMs/math.max(elapsed,0.02)).ceil().clamp(1,maxRepeats); } if(isUi) repeats=1; if(repeats<1||repeats>maxRepeats) throw ArgumentError('invalid batch size'); chosen[c.id]=repeats; final warmStart=bridge.clockNow();var warmCount=0; final warm=<double>[]; do { final t=bridge.clockNow(); await c.batch(repeats);warm.add(bridge.clockNow()-t);warmCount++; }while(!isUi && (warmCount<3||bridge.clockNow()-warmStart<(c.id=='int32'?integerWarmupMs:warmupMs)) && warmCount<200); final samples=<double>[]; final checks=<num>[]; final ranges=<List<double>>[]; for(var sample=0;sample<count;sample++) { final t=bridge.clockNow();final value=await c.batch(repeats);final elapsed=bridge.clockNow()-t; if(!value.isFinite)throw StateError('Non-finite result'); if(checks.isNotEmpty&&(value-checks.first).abs()>1e-8*math.max(1,checks.first.abs()))throw StateError('Unstable output'); checks.add(value);samples.add(elapsed/repeats); if(isUi) ranges.add([t,t+elapsed]); await Future<void>.delayed(const Duration(milliseconds:5)); } final result=<String,Object?>{...c.description,'repeats':repeats,'samplesMs':samples, 'medianMs':median(samples),'minMs':samples.reduce(math.min),'maxMs':samples.reduce(math.max), 'result':checks.first,'warmupBatchMs':warm,'status':'passed','frameRangesMs':ranges, 'metric':isUi?'24 scheduled Flutter frames; wall ms':'wall ms per workload'}; results.add(result);progress(c.name,result); }catch(e) { final result=<String,Object?>{...c.description,'status':'failed','error':e.toString()}; results.add(result);progress(c.name,result); } } await Future<void>.delayed(const Duration(milliseconds:140)); SchedulerBinding.instance.scheduleFrame(); await SchedulerBinding.instance.endOfFrame; await Future<void>.delayed(const Duration(milliseconds:40)); for(final r in results) { final ranges=r['frameRangesMs'] as List<List<double>>? ?? []; if(ranges.isNotEmpty) r['frameTimings']=frameTimings.where((f)=>ranges.any((w)=>f['startMs']!>=w[0]&&f['startMs']!<=w[1])).toList(); } final result=<String,Object?>{'protocol':protocolVersion,'mode':mode,'actualWasm':wasm, 'session':session,'pilot':pilot,'startedUtc':began,'finishedUtc':DateTime.now().toUtc().toIso8601String(), 'environment':metadata,'firstFrameMs':bridge.firstFrameMs,'validation':validation, 'visibleThroughout':bridge.validVisibility(),'repeats':chosen,'results':results, 'comparableBatches':supplied.isNotEmpty,'seed':dataSeed}; bridge.publish(jsonEncode(result).toJS);bridge.status('done'.toJS); return result;}
Flutter-приложение — main.dart
import 'dart:convert';import 'dart:js_interop';import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';import 'package:web/web.dart' as web;import 'benchmark.dart';import 'bridge.dart' as bridge;void main()=>runApp(const BenchmarkApp());class BenchmarkApp extends StatelessWidget { const BenchmarkApp({super.key}); @override Widget build(BuildContext context)=>MaterialApp( title:'WASM versus JavaScript',debugShowCheckedModeBanner:false, theme:ThemeData(colorScheme:ColorScheme.fromSeed(seedColor:const Color(0xff38bdf8),brightness:Brightness.dark), scaffoldBackgroundColor:const Color(0xff0b1220),useMaterial3:true), home:const BenchmarkScreen());}class BenchmarkScreen extends StatefulWidget { const BenchmarkScreen({super.key}); @override State<BenchmarkScreen> createState()=>_BenchmarkScreenState();}class _BenchmarkScreenState extends State<BenchmarkScreen> { late final List<Workload> cases; bool running=false,ready=false; String active='Подготовка',scene='';int tick=0; Map<String,Object?>? current; final rows=<String,Map<String,Object?>>{}; List<dynamic> saved=[]; Map<String,dynamic> reportSummary={}; String group='Все задачи'; final scroll=ScrollController(); @override void initState(){super.initState();cases=workloads(runScene);SchedulerBinding.instance.addTimingsCallback(recordFrameTimings);initialize();} Future<void> initialize()async{ try{ await bridge.prepare().toDart; saved=jsonDecode(bridge.saved().toDart) as List; bridge.install(((JSString c)=>execute(jsonDecode(c.toDart) as Map<String,dynamic>).toJS).toJS); setState((){ready=true;active='Готово к измерению';}); await SchedulerBinding.instance.endOfFrame; bridge.markReady(); try { final response=await web.window.fetch('../results/summary.json'.toJS).toDart; if(response.ok){final s=await response.text().toDart;if(mounted)setState(()=>reportSummary=jsonDecode(s.toDart));} }catch(_){} }catch(e){setState(()=>active='Ошибка подготовки: $e');bridge.status(active.toJS);} } Future<JSString> execute(Map<String,dynamic> config)async{ if(running)throw StateError('Benchmark already running'); final previousSummary=reportSummary;var savedAfterRun=saved; setState((){running=true;rows.clear();current=null;reportSummary={};saved=[];}); try { final result=await measure(cases:cases,config:config,progress:(name,row){ if(mounted)setState((){active=name;if(row!=null)rows[row['id'] as String]=row;}); }); if(mounted)setState((){current=result;active='Завершено';scene='';}); if(config['pilot']!=true){bridge.save(jsonEncode(result).toJS);savedAfterRun=jsonDecode(bridge.saved().toDart) as List;} return jsonEncode(result).toJS; }catch(e){if(mounted)setState(()=>active='Ошибка: $e');bridge.status(active.toJS);rethrow;}finally{if(mounted)setState((){running=false;reportSummary=previousSummary;saved=savedAfterRun;});} } Future<void> manualRun()async{ Map<String,dynamic> repeats={}; try{ final r=await web.window.fetch('../results/calibration.json'.toJS).toDart; if(r.ok)repeats=jsonDecode((await r.text().toDart).toDart); }catch(_){} await execute({'repeats':repeats,'samples':defaultSamples, if(group!='Все задачи')'only':cases.where((c)=>c.group==group).map((c)=>c.id).toList()}); } Future<num> runScene(String id)async{ setState((){scene=id;tick=0;}); await SchedulerBinding.instance.endOfFrame; num checksum=0; for(var i=0;i<uiFrames;i++){ setState(()=>tick=i+1); if(id=='ui_scroll'&&scroll.hasClients){scroll.jumpTo((i*127.0)%scroll.position.maxScrollExtent);} await SchedulerBinding.instance.endOfFrame; checksum+=i+1; } return checksum; } String get mode { const wasm=bool.fromEnvironment('dart.tool.dart2wasm'); if(!wasm)return 'JavaScript · CanvasKit'; final single=Uri.base.queryParameters.containsKey('single'); return 'WebAssembly · skwasm ${single?'ST':'MT'}'; } @override void dispose(){SchedulerBinding.instance.removeTimingsCallback(recordFrameTimings);scroll.dispose();super.dispose();} Widget sceneWidget(){ switch(scene){ case 'ui_widgets':return Wrap(children:List.generate(600,(i)=>SizedBox(width:34,height:20, child:ColoredBox(color:Color(0xff17304b+((i+tick)%12)*512),child:Text('${(i+tick)%99}',style:const TextStyle(fontSize:10)))))); case 'ui_scroll':return ListView.builder(controller:scroll,itemCount:50000,itemExtent:42, itemBuilder:(context,i)=>ListTile(dense:true,leading:const Icon(Icons.data_object),title:Text('Record $i'),trailing:Text('${i*17}'))); case 'ui_paint':case 'ui_layers':return CustomPaint(painter:StressPainter(tick,scene=='ui_layers'),size:Size.infinite); default:return Center(child:Column(mainAxisSize:MainAxisSize.min,children:[ const Icon(Icons.science_outlined,size:48,color:Color(0xff38bdf8)),const SizedBox(height:12), Text('${cases.length} задач · 3 режима',style:Theme.of(context).textTheme.titleLarge), const SizedBox(height:8),const Text('Вычисления · память · браузер · Flutter'), ])); } } Widget metric(String value,String label)=>Container(padding:const EdgeInsets.all(16), decoration:BoxDecoration(color:const Color(0xff152239),borderRadius:BorderRadius.circular(12)), child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[Text(value,style:const TextStyle(fontSize:25,fontWeight:FontWeight.w600)),Text(label)])); String resultText(Workload c){ final row=rows[c.id];if(row!=null)return row['status']=='passed'?'${(row['medianMs'] as num).toStringAsFixed(3)} ms':'Ошибка'; return '—'; } @override Widget build(BuildContext context){ final selected=cases.where((c)=>group=='Все задачи'||c.group==group).toList(); final narrow=MediaQuery.sizeOf(context).width<700; final summaries=Map<String,dynamic>.from(reportSummary['tasks'] as Map? ?? {}); return Scaffold(appBar:AppBar(title:const Text('WASM versus JavaScript'),actions:[ IconButton(tooltip:'Отчёт исследования',onPressed:()=>web.window.open('../report.html','_blank'),icon:const Icon(Icons.article_outlined)), IconButton(tooltip:'Экспорт измерений',onPressed:current==null?null:()=>bridge.download(jsonEncode(current).toJS),icon:const Icon(Icons.download)), ]),body:SafeArea(child:ListView(padding:EdgeInsets.all(narrow?14:28),children:[ Text('Лаборатория производительности',style:Theme.of(context).textTheme.headlineMedium), const SizedBox(height:8),Text(mode,style:const TextStyle(color:Color(0xff7dd3fc),fontSize:17)), const SizedBox(height:18),Wrap(spacing:12,runSpacing:12,children:[ metric('${cases.length}','задач'),metric('${rows.length}','выполнено'),metric('$defaultSamples','замеров на задачу'), if(reportSummary['sessionsPerMode']!=null)metric('${reportSummary['sessionsPerMode']}','сессий на режим в отчёте'), ]), const SizedBox(height:18),Wrap(spacing:12,runSpacing:10,crossAxisAlignment:WrapCrossAlignment.center,children:[ Semantics(container:true,explicitChildNodes:true,child:SizedBox(width:narrow?double.infinity:280,child:DropdownButtonFormField<String>(initialValue:group, decoration:const InputDecoration(labelText:'Тип задач',border:OutlineInputBorder()), items:['Все задачи',...cases.map((c)=>c.group).toSet()].map((g)=>DropdownMenuItem(value:g,child:Text(g))).toList(), onChanged:running?null:(value)=>setState(()=>group=value!)))), Semantics(identifier:'benchmark.run',child:FilledButton.icon(onPressed:ready&&!running?manualRun:null, icon:const Icon(Icons.play_arrow),label:const Text('Запустить тесты'))), OutlinedButton(onPressed:running?null:()=>web.window.location.href='../js/',child:const Text('JavaScript')), OutlinedButton(onPressed:running?null:()=>web.window.location.href='../wasm/?single=1',child:const Text('Wasm ST')), OutlinedButton(onPressed:running?null:()=>web.window.location.href='../wasm/',child:const Text('Wasm MT')), ]), const SizedBox(height:12),Text(active),if(running)const Padding(padding:EdgeInsets.only(top:8),child:LinearProgressIndicator()), const SizedBox(height:18),Container(height:360,clipBehavior:Clip.hardEdge, decoration:BoxDecoration(color:const Color(0xff101d31),border:Border.all(color:const Color(0xff24364e)),borderRadius:BorderRadius.circular(12)),child:sceneWidget()), const SizedBox(height:24),Text('Измерения и сравнение',style:Theme.of(context).textTheme.titleLarge), const SizedBox(height:8),const Text('Время меньше — лучше. Ускорение = JS / Wasm. Значения отчёта получены в Chrome на компьютере.'), const SizedBox(height:12), for(final c in selected) Card(margin:const EdgeInsets.only(bottom:8),child:Padding(padding:const EdgeInsets.all(14), child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ Row(crossAxisAlignment:CrossAxisAlignment.start,children:[Expanded(child:Text(c.name,style:const TextStyle(fontWeight:FontWeight.w600))),Text(resultText(c),style:const TextStyle(color:Color(0xff7dd3fc)))]), const SizedBox(height:5),Text('${c.group} · ${c.size}',style:const TextStyle(color:Color(0xffa4b5cb),fontSize:12)), if(summaries[c.id]!=null)...[ const SizedBox(height:8),Text(summaries[c.id]['display'] as String), ]else if(saved.isNotEmpty)...[ const SizedBox(height:8),Wrap(spacing:16,children:[for(final run in saved) if((run['results'] as List).any((r)=>r['id']==c.id&&r['status']=='passed')) Text('${run['mode']}: ${((run['results'] as List).firstWhere((r)=>r['id']==c.id)['medianMs'] as num).toStringAsFixed(3)} ms')]), ], ]))), ]))); }}class StressPainter extends CustomPainter { StressPainter(this.tick,this.layers);final int tick;final bool layers; @override void paint(Canvas canvas,Size size){ final paint=Paint(); if(layers){ for(var i=0;i<100;i++){ final x=(i*31+tick*2)%size.width,y=(i*17)%size.height; final r=Rect.fromLTWH(x,y,72,60);canvas.saveLayer(r,Paint()..color=const Color(0xaaeeeeee)); canvas.clipRRect(RRect.fromRectAndRadius(r,const Radius.circular(14))); canvas.drawRect(r,paint..color=const Color(0xff38bdf8)); canvas.drawCircle(Offset(x+24,y+24),22,paint..color=const Color(0xffa78bfa));canvas.restore(); } }else{ for(var i=0;i<2000;i++){ paint.color=Color.fromARGB(160,(i*17)%256,(i*31)%256,190); canvas.drawCircle(Offset((i*37+tick*3)%size.width,(i*19)%size.height),3+(i%7).toDouble(),paint); } } } @override bool shouldRepaint(StressPainter old)=>old.tick!=tick||old.layers!=layers;}
Взаимодействие Dart и JavaScript — bridge.dart
import 'dart:js_interop';@JS('bench.now') external double clockNow();@JS('bench.metadata') external JSString metadata();@JS('bench.install') external void install(JSFunction callback);@JS('bench.prepare') external JSPromise<JSNumber> prepare();@JS('bench.publish') external void publish(JSString json);@JS('bench.status') external void status(JSString text);@JS('bench.markReady') external void markReady();@JS('bench.start') external void startRun();@JS('bench.visible') external bool validVisibility();@JS('bench.save') external void save(JSString json);@JS('bench.saved') external JSString saved();@JS('bench.download') external void download(JSString json);@JS('bench.firstFrameMs') external double get firstFrameMs;@JS('bench.scalar') external double scalar(double x);@JS('bench.sum') external double sum(JSUint32Array array);@JS('bench.nativeSync') external double nativeSync(JSString id);@JS('bench.nativeAsync') external JSPromise<JSNumber> nativeAsync(JSString id);
Проверки вычислений — kernels_test.dart
import 'dart:typed_data';import 'package:flutter_test/flutter_test.dart';import 'package:wasm_versus_javascript/kernels.dart';void main(){ test('Independent known answers and boundary cases',(){expect(validateKernels()['passed'],15);}); test('Matrix product matches independent dot-product reference',(){ for(final n in [1,2,3,5,9]){ final a=List.generate(n*n,(i)=>(i%7-3).toDouble()); final b=List.generate(n*n,(i)=>(i%11-5).toDouble()); final got=multiply(a,b,n); for(var row=0;row<n;row++){for(var col=0;col<n;col++){ final expected=List.generate(n,(k)=>a[row*n+k]*b[k*n+col]).fold<double>(0,(x,y)=>x+y); expect(got[row*n+col],expected); }} } }); test('Stencil matches an independent neighborhood implementation',(){ for(final w in [2,3,4,7,16]){ final values=Uint32List.fromList(List.generate(w*w,(i)=>(i*17)%256)); var expected=0; for(var y=1;y<w-1;y++){for(var x=1;x<w-1;x++){ var pixel=0;for(final dy in [-1,0,1]){for(final dx in [-1,0,1]){pixel+=values[(y+dy)*w+x+dx];}} expected+=pixel~/9; }}expect(stencil(values,w),expected); } }); test('Seeded kernels return stable finite observable outputs',(){ final kernels=makeKernels(DataSet(20261009)); expect(kernels.map((k)=>k.id).toSet().length,kernels.length); for(final k in kernels){final a=k.run(),b=k.run();expect(a.isFinite,true,reason:k.id);expect(a,b,reason:k.id);} });}
Браузерные обработчики — bench.js
'use strict';window.bench = { version:'1.0', ready:false, result:null, progress:'loading', error:null, hiddenDuringRun:false, scalar:x=>x+1, sum:a=>{let s=0;for(let i=0;i<a.length;i++)s+=a[i];return s;}, now:()=>performance.now(), publish:text=>{bench.result=JSON.parse(text);}, status:text=>{bench.progress=text;}, install:fn=>{bench.run=fn;}, metadata:()=>JSON.stringify({userAgent:navigator.userAgent,hardwareConcurrency:navigator.hardwareConcurrency, deviceMemoryGiB:navigator.deviceMemory??null,isolated:crossOriginIsolated,secure:isSecureContext, width:innerWidth,height:innerHeight,dpr:devicePixelRatio,visibility:document.visibilityState, single:new URLSearchParams(location.search).has('single'), rendererResources:performance.getEntriesByType('resource').map(x=>x.name).filter(x=>/skwasm|canvaskit/.test(x))}), markReady:()=>{bench.firstFrameMs=performance.now();bench.ready=true;document.getElementById('loading')?.remove();}, start:()=>{bench.hiddenDuringRun=document.hidden;bench.result=null;}, visible:()=>!bench.hiddenDuringRun&&!document.hidden, save:text=>{const r=JSON.parse(text);localStorage.setItem(`wasm-vs-js:${r.mode}`,text);}, saved:()=>JSON.stringify(Object.keys(localStorage).filter(k=>k.startsWith('wasm-vs-js:')).map(k=>JSON.parse(localStorage.getItem(k)))), download:text=>{const url=URL.createObjectURL(new Blob([text],{type:'application/json'})); const a=document.createElement('a');a.href=url;a.download=`wasm-versus-javascript-${Date.now()}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);},};document.addEventListener('visibilitychange',()=>{if(document.hidden)bench.hiddenDuringRun=true;});bench.prepare=async()=>{ const {instance}=await WebAssembly.instantiateStreaming(fetch('kernels.wasm'));bench.wasm=instance.exports; bench.ints=new Uint32Array(bench.wasm.memory.buffer,0,262144); for(let i=0;i<bench.ints.length;i++)bench.ints[i]=(i*17+11)&4095; for(const n of [0,4,8,262144]){const expected=bench.sum(bench.ints.subarray(0,n)); if(bench.wasm.sum(n)!==expected||bench.wasm.simd(n)!==expected)throw Error('Wasm sum validation failed');} bench.i64js=n=>{let x=1234567n;for(let i=0;i<n;i++){x=BigInt.asUintN(64,x*6364136223846793005n^BigInt(i));x^=x>>33n;}return Number(x&2147483647n);}; for(const [n,expected] of [[0, 1234567], [1, 1812802702], [2, 1046528860], [10, 591709396], [10000, 1507571436]])if(bench.i64js(n)!==expected||bench.wasm.i64mix(n)!==expected)throw Error('Wasm i64 validation failed'); bench.bytes=new Uint8Array(65536);for(let i=0;i<bench.bytes.length;i++)bench.bytes[i]=i&255; const abc=await crypto.subtle.digest('SHA-256',new TextEncoder().encode('abc')); if([...new Uint8Array(abc)].map(x=>x.toString(16).padStart(2,'0')).join('')!=='ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad')throw Error('SHA reference failed'); bench.worker=new Worker('worker.js'); bench.db=await new Promise((resolve,reject)=>{const r=indexedDB.open('wasm-versus-javascript-benchmark-v1',1); r.onupgradeneeded=()=>r.result.createObjectStore('scratch');r.onsuccess=()=>resolve(r.result);r.>reject(r.error);}); bench.dom=document.createElement('div');bench.dom.style.cssText='position:absolute;left:-10000px;top:0;width:300px;contain:strict;height:100px;'; bench.dom.textContent='benchmark';document.body.append(bench.dom);return 1;};bench.nativeSync=id=>{switch(id){ case 'external_js_sum':return bench.sum(bench.ints); case 'external_wasm_sum':return bench.wasm.sum(bench.ints.length); case 'external_simd_sum':return bench.wasm.simd(bench.ints.length); case 'external_js_i64':return bench.i64js(10000); case 'external_wasm_i64':return bench.wasm.i64mix(10000); case 'dom_layout':{let sum=0;for(let i=0;i<100;i++){bench.dom.style.width=`${200+(i%17)}px`;sum+=bench.dom.offsetWidth;}return sum;} default:throw Error('Unknown synchronous case '+id);}};bench.nativeAsync=async id=>{switch(id){ case 'webcrypto':return [...new Uint8Array(await crypto.subtle.digest('SHA-256',bench.bytes))].reduce((a,b)=>a+b,0); case 'timer_wait':await new Promise(r=>setTimeout(r,15));return 15; case 'local_fetch':{const r=await fetch('payload.bin',{cache:'no-store'});if(!r.ok)throw Error('fetch '+r.status);return bench.sum(new Uint8Array(await r.arrayBuffer()));} case 'indexeddb':return await new Promise((resolve,reject)=>{const tx=bench.db.transaction('scratch','readwrite');const store=tx.objectStore('scratch'); store.put(bench.bytes,'sample');let sum;store.get('sample').onsuccess=e=>{sum=bench.sum(e.target.result);};store.delete('sample'); tx.oncomplete=()=>resolve(sum);tx.>reject(tx.error);tx.onabort=()=>reject(tx.error);}); case 'worker_transfer':return await new Promise((resolve,reject)=>{const copy=bench.bytes.slice().buffer;bench.worker.onmessage=e=>resolve(e.data);bench.worker. default:throw Error('Unknown async case '+id);}};
Фоновый обработчик — worker.js
onmessage = ({data}) => { let sum=0; for(const v of new Uint32Array(data)) sum+=v; postMessage(sum);};
Вычислительные модули WebAssembly — kernels.wat
(module (memory (export "memory") 64) (func (export "sum") (param $n i32) (result i32) (local $p i32) (local $s i32) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32.add (local.get $s) (i32.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 1))) (br $loop))) (local.get $s)) ;; n must be a multiple of four. Tests cover n=0,4,8 and full input. (func (export "simd") (param $n i32) (result i32) (local $p i32) (local $s v128) (local.set $s (v128.const i32x4 0 0 0 0)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $p) (local.get $n))) (local.set $s (i32x4.add (local.get $s) (v128.load (i32.shl (local.get $p) (i32.const 2))))) (local.set $p (i32.add (local.get $p) (i32.const 4))) (br $loop))) (i32.add (i32.add (i32x4.extract_lane 0 (local.get $s)) (i32x4.extract_lane 1 (local.get $s))) (i32.add (i32x4.extract_lane 2 (local.get $s)) (i32x4.extract_lane 3 (local.get $s))))) (func (export "i64mix") (param $n i32) (result i32) (local $i i32) (local $x i64) (local.set $x (i64.const 1234567)) (block $end (loop $loop (br_if $end (i32.ge_u (local.get $i) (local.get $n))) (local.set $x (i64.xor (i64.mul (local.get $x) (i64.const 6364136223846793005)) (i64.extend_i32_u (local.get $i)))) (local.set $x (i64.xor (local.get $x) (i64.shr_u (local.get $x) (i64.const 33)))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br $loop))) (i32.and (i32.wrap_i64 (local.get $x)) (i32.const 2147483647))))
Страница запуска — index.html
<!DOCTYPE html><html lang="ru"><head><base href="$FLUTTER_BASE_HREF"><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>WASM versus JavaScript</title><style>html,body{margin:0;background:#0b1220;color:#e2e8f0;font:16px system-ui}#loading{padding:32px}</style></head><body><div id="loading">WASM versus JavaScript · загрузка</div><script src="bench.js"></script><script src="flutter_bootstrap.js" async></script></body></html>
Загрузчик Flutter — flutter_bootstrap.js
{{flutter_js}}{{flutter_build_config}}const config = { canvasKitBaseUrl: 'canvaskit/', forceSingleThreadedSkwasm: new URLSearchParams(location.search).has('single'),};_flutter.loader.load({config});
Локальный сервер — serve.cjs
const http=require('node:http'),fs=require('node:fs'),path=require('node:path');const root=path.resolve(__dirname,'..');const port=Number(process.env.PORT||8096);const types={'.html':'text/html; charset=utf-8','.js':'text/javascript','.mjs':'text/javascript','.wasm':'application/wasm','.json':'application/json','.ttf':'font/ttf','.otf':'font/otf','.png':'image/png','.bin':'application/octet-stream'};http.createServer((req,res)=>{ if(req.url==='/favicon.ico'){res.writeHead(204);res.end();return;} const url=new URL(req.url,'http://localhost');let name=decodeURIComponent(url.pathname); if(name==='/'){res.writeHead(302,{Location:'/js/'});res.end();return;} if(name.endsWith('/'))name+='index.html'; const rel=/^\/(js|wasm)\//.test(name)?'site'+name:name; const file=path.resolve(root,'.'+(rel.startsWith('/')?rel:'/'+rel)); if(!file.startsWith(root+path.sep)){res.writeHead(403);res.end();return;} fs.stat(file,(err,s)=>{ if(err||!s.isFile()){res.writeHead(404);res.end('Not found');return;} res.writeHead(200,{'Content-Type':types[path.extname(file)]||'application/octet-stream','Content-Length':s.size, 'Cross-Origin-Opener-Policy':'same-origin','Cross-Origin-Embedder-Policy':'require-corp', 'Cross-Origin-Resource-Policy':'same-origin','Cache-Control':/index.html|results|report.html/.test(file)?'no-store':'public, max-age=3600'}); fs.createReadStream(file).pipe(res); });}).listen(port,'127.0.0.1',()=>console.log(`Benchmark: http://127.0.0.1:${port}/js/`));
Зависимости Flutter — pubspec.yaml
name: wasm_versus_javascriptpublish_to: nonedescription: Independent controlled Flutter compiler and browser workload benchmark.version: 1.0.0environment: sdk: '>=3.13.0 <4.0.0'dependencies: flutter: sdk: flutter web: 1.1.1dev_dependencies: flutter_test: sdk: flutterflutter: uses-material-design: true
Все Dart-файлы приложения, браузерная часть, Wasm-модуль и конфигурация приведены выше целиком. Входные массивы формируются из фиксированного начального значения, поэтому при повторении тестов сохраняется одинаковая нагрузка.
В квадратных скобках показан 95%-й доверительный интервал. Для практического вывода выбран порог 10%: преимущество засчитывается, когда весь интервал выходит за соответствующую границу. Интервал внутри полосы означает близкую скорость. Пограничный результат означает пересечение порога; широкий интервал показывает изменчивость между запусками.
Что получилось в целом
В основном сравнении JavaScript и Wasm ST из 41 задачи Wasm выиграл 10, JavaScript — 12, ещё 12 дали близкую скорость. Семь случаев я проверил расширенной серией, выше. Пять дополнительных задач исследуют отдельные JavaScript- и Wasm-модули.
Самый наглядный результат относится к интерфейсу с 600 ячейками. Подготовка кадра сократилась с 15,31 до 7,92 мс. При этом длительность всей серии обновлений уменьшилась с 427,88 до 410,51 мс, то есть на 4,1%. Время между кадрами продолжает задавать темп серии, а более быстрая подготовка освобождает запас внутри кадра.
В вычислениях результаты разнообразнее: рекурсивные вызовы ускорились у Wasm примерно в 3,89 раза, преобразование текста в UTF-8 — в 3,08 раза. JavaScript быстрее вычислил сумму произведений дробных чисел и восстановил текст из UTF-8. Поэтому полезно рассматривать конкретный путь обработки данных.
Итоги расширенной проверки
Дополнительная серия завершена для всех семи выбранных задач. В итоговый набор вошли 36 сессий Chrome, 5 292 учитываемых пакета и 3 780 предварительных пакетов. Один блок из трёх режимов повторён после сбоя проверки ответа; его первоначальные значения сохранены в архиве. Таблица фиксирует полученный итог, включая случаи с сохраняющимся разбросом. Изменчивость измерений — часть наблюдаемого поведения данной нагрузки.
В расширенной серии обмен с Worker, раскладка текста и чтение малого массива показали близкую скорость. JavaScript выиграл зависимое чтение памяти и создание цепочки объектов. Сортировка дала результат около порога 10%, а тест условных переходов сохранил широкий разброс между запусками.
|
Задача |
Итог расширенной серии |
JS / Wasm ST · 95% |
|---|---|---|
|
Скорость практически одинакова. |
0,96× [0,93; 0,97] |
|
|
Скорость практически одинакова. |
1,06× [1,05; 1,07] |
|
|
Скорость заметно меняется между запусками. |
0,81× [0,71; 1,00] |
|
|
JavaScript быстрее в 1,17 раза. |
0,85× [0,72; 0,91] |
|
|
Разница находится около принятого порога 10%. |
0,91× [0,89; 0,93] |
|
|
JavaScript быстрее в 1,13 раза. |
0,88× [0,88; 0,90] |
|
|
Скорость практически одинакова. |
0,97× [0,96; 1,00] |
В общем по производительности Flutter-Web приложений: в усредненном приближении WASM не дает выигрыша во всём, поэтому если нет приоритетов конкретных кейсов использования, то компиляция в JavaScript вполне отличный вариант на текущий момент.
ссылка на оригинал статьи https://habr.com/ru/articles/1092606/