company_banner

Wasm или не Wasm?

Original author: linkurious-dev
  • Translation
Мы, в компании Linkurious, занимаемся работой над Linkurious Enterprise. Это — веб-платформа, которая, используя возможности графов и средства их визуализации, призвана помогать компаниям и органам власти, расположенным по всему миру, бороться с финансовыми преступлениями.

Одна из главных возможностей Linkurious Enterprise — это простой в освоении и использовании интерфейс визуализации графов, рассчитанный на неспециалистов.



В 2015 году, разочарованные возможностями существующих JavaScript-библиотек для визуализации графов, мы приступили к разработке собственной библиотеки — Ogma.

Ogma — это JS-библиотека, отличающаяся высоким уровнем производительности в плане рендеринга и выполнения вычислений, которая нацелена на визуализацию сетевых структур. Возможно, вы видели, как сетевые структуры визуализируются с помощью других JavaScript-инструментов, вроде D3.js или Sigma.js. Нам возможностей этих инструментов не хватало. Нам было важно, чтобы используемое нами решение обладало бы некоторыми специфическими возможностями, чтобы оно соответствовало бы определённым требованиям к производительности. Ни того, ни другого в сторонних библиотеках мы не нашли. Поэтому мы и решили разработать собственную библиотеку с нуля.

Задача



Визуализация сетевой структуры

Библиотека Ogma проектировалась в расчёте на использование самых современных алгоритмов. Все её составные части, от передового движка рендеринга, основанного на WebGL, до используемых в её недрах веб-воркеров, были нацелены на то, чтобы обеспечить наилучшую среди существующих решений производительность в деле визуализации сетевых структур. Мы стремились сделать так, чтобы библиотека отличалась бы хорошей интерактивностью при выполнении длительных задач, и чтобы реализованные в ней ведущие алгоритмы визуализации графов работали бы быстро и стабильно.

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

После того, как технология Wasm немного развилась, мы, прежде чем внедрять её, решили её испытать, и провести некоторые тесты производительности. В общем, прежде чем без оглядки прыгать в быстрый поезд Wasm, мы решили подстраховаться.

Нас в Wasm привлекало то, что эта технология вполне могла решить стоящие перед нами задачи, используя ресурсы памяти и процессора эффективнее, чем JavaScript.

Наше исследование


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

Мы выбрали алгоритм n-body. Он часто используется в качестве базы для сравнения силовых алгоритмов визуализации графов. Выполняемые в соответствии с этим алгоритмом расчёты являются самой ресурсоёмкой частью систем визуализации. Если эту часть работы подобных систем удалось бы выполнить эффективнее, чем прежде, это весьма позитивно сказалось бы на всех силовых алгоритмах визуализации графов, реализованных в Ogma.

Бенчмарк


Есть ложь, грубая ложь и бенчмарки.

Макс Де Марзи

Разработка «честного» бенчмарка — это, часто, задача, которую решить невозможно. Дело в том, что в искусственно созданной среде сложно воспроизвести сценарии, характерные для реального мира. Создание адекватного окружения для обеспечения работы сложных систем — это всегда крайне сложно. Ведь в лабораторной среде легко контролировать внешние факторы, а в реальности на то, какой выглядит «производительность» решения, влияет много неожиданного.

В нашем случае бенчмарк был нацелен на решение единственной, чётко определённой задачи, на исследование производительности реализации алгоритма n-body.

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

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

  • В различных реализациях алгоритма должны использоваться схожие структуры кода.
  • Запрещено использование нескольких процессов или нескольких потоков.
  • Запрещено использование SIMD.
  • Испытанию подвергаются только стабильные версии компиляторов. Запрещено использование релизов наподобие nightly, beta, alpha, pre-alpha.
  • Для каждого языка используются только самые свежие версии компиляторов.

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

Конкуренты JS


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


Алгоритм n-body был реализован на этих трёх языках. Производительность различных реализаций сравнивалась с производительностью базовой реализации на JavaScript.

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

Испытания выполнялись с использованием следующих программных и аппаратных средств:

  • NodeJS 12.9.1
  • Chrome 79.0.3945.130 (Официальная сборка) (64-bit)
  • clang 10.0.0 — для версии алгоритма, реализованного на языке C
  • emcc 1.39.6 — фронтенд для вызова компилятора Emscripten из командной строки, альтернатива gcc/clang, а так же — линковщик
  • cargo 1.40.0
  • wasm-pack 0.8.1
  • AssemblyScript 0.9.0
  • MacOS 10.15.2
  • Macbook Pro 2017 Retina
  • Intel Dual Core i5 2,3 GHz, 8GB DDR3, 256GB SSD

Как видите, для тестов мы выбрали не самый быстрый компьютер, но тестируем мы Wasm, то есть — код, который будет выполняться в контексте браузера. А браузер, всё равно, обычно не имеет доступа ко всем имеющимся в системе процессорным ядрам и ко всей оперативной памяти.

Чтобы было интереснее, мы создали несколько версий каждой реализации алгоритма. В одной точки в системе n-body имели 64-битное числовое представление координат, в другой — 32-битное.

Ещё стоит отметить то, что мы использовали «двойную» реализацию алгоритма на Rust. Сначала, без использования каких-либо Wasm-инструментов, была написана «исходная», «небезопасная» Rust-реализация. Позже, с использованием wasm-pack, была создана дополнительная, «безопасная» Rust-реализация. Ожидалось, что эту реализацию алгоритма будет легче интегрировать с JS, и то, что она сможет лучше управлять памятью в Wasm.

Тестирование


Мы проводили эксперименты в двух основных средах. Это — Node.js и браузер (Chrome).

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

На основе исходного кода, написанного на AssemblyScript, было создано следующее:

  • Базовая JS-реализация алгоритма.
  • Wasm-модуль.
  • Asm.js-модуль.

Интересно отметить тот факт, что asm.js-модуль был не полностью совместим с asm.js. Мы попытались добавить в верхнюю часть модуля директиву «use asm», но браузер не принял эту оптимизацию. Позже мы обнаружили, что использованный нами компилятор binaryen, на самом деле, и не стремился сделать код полностью совместимым с asm.js. Он, вместо этого, был ориентирован на формирование некоей эффективной JS-версии Wasm.

Сначала мы провели испытания в Node.js.


Запуск кода в среде Node.js

Потом измерили производительность того же кода в браузере.


Запуск кода в браузере

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


Отличия других реализаций алгоритма от JS-реализации (вариант бенчмарка с 64-битными координатами точек)


Отличия других реализаций алгоритма от JS-реализации (вариант бенчмарка с 32-битными координатами точек)

Варианты бенчмарка с 64-битными и 32-битными координатами точек заметно различаются. Это может привести нас к мысли о том, что в JS числа могут быть и такими, и такими. Дело в том, что числа в JS, в реализации алгоритма, принятой за базу сравнения, всегда являются 64-битными, а вот компиляторы, преобразующие код с других языков в Wasm, работают с такими числами по-разному.

В частности, огромнейшее влияние это оказывает на asm.js-вариант теста. Его вариант с 32-битными координатами точек очень сильно уступает в производительности как базовой JS-реализации, так и asm.js-варианту, в котором используются 64-битные числа.

На предыдущих диаграммах сложно понять то, как производительность других вариантов кода соотносится с JS-вариантом. Всё дело в том, что показатели AssemblyScript слишком сильно отличаются от остальных. Для того чтобы в этом разобраться, мы построили другие диаграмму, убрав результаты asm.js.


Отличия других реализаций алгоритма от JS-реализации (вариант бенчмарка с 64-битными координатами точек, без asm.js)


Отличия других реализаций алгоритма от JS-реализации (вариант бенчмарка с 32-битными координатами точек, без asm.js)

Разные представления чисел, похоже, повлияли и на другие варианты теста. Но повлияли по-разному. Так, C-вариант, в котором использовались 32-битные числа (float), стал медленнее, чем C-вариант, в котором применялись 64-битные числа (double). А оба Rust-варианта теста с 32-битными числами (f32) стали быстрее их разновидности с 64-битными числами (f64).

Некачественные реализации алгоритма?


Анализ вышеприведённых данных может навести на следующую мысль. Так как все протестированные Wasm-сборки весьма близки по производительности к JavaScript-реализации, возможно ли, что Wasm-реализации лишь отражают особенности производительности нативных реализаций алгоритма?


Сравнение нативных реализаций алгоритма с JavaScript-реализацией

Нативные версии реализаций алгоритма всегда быстрее JavaScript-реализации.

Мы, кроме того, заметили, что Wasm-сборки работают медленнее, чем нативные варианты кода, используемого для создания таких сборок. Разница в производительности составляет 20-50%. Мы выяснили это на сокращённом варианте бенчмарка с 1000 итераций.


C-реализация и соответствующая Wasm-сборка


Rust-реализация и соответствующая Wasm-сборка


Rust-реализация, при создании которой использовался wasm-pack, и соответствующая Wasm-сборка

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

Итоги


В среднем, прирост производительности двух Rust-реализаций алгоритма, в сравнении с его базовой JS-реализацией, составил 20%. Это, вероятно, хорошо для имиджа Rust, но это — слишком маленький прирост производительности в сравнении с затраченными на его получение усилиями.

Какие выводы мы можем сделать из этих испытаний? А вот какие: вдумчивое написание JS-кода позволяет получать достаточно высокую производительность и не требует перехода на другие языки программирования.

Изучение новых языков программирования — это всегда хорошо. Но на то, чтобы учить новые языки, должны быть веские причины. Производительность — это, часто, «неправильная» причина, так как на производительность сильнее влияет высокоуровневая архитектура проекта, а не компиляторы или микрооптимизации.

В порядке эксперимента, для написания реализации силового алгоритма визуализации графа, мы поменяли JavaScript на TypeScript. В результате мы улучшили качество кодовой базы, но не производительность. Мы специально замеряли производительность, и оказалась, что она, после перехода, незначительно, на 5%, выросла. Вероятно, причина тут в рефакторинге кода.

Если вам близки вопросы JavaScript-разработки и производительности, то вам, возможно, будет интересно посмотреть это выступление, в котором озвучены результаты, сходные с теми, которые получили мы.

Как вы подходите к разработке «тяжёлых» частей веб-проектов?

RUVDS.com
RUVDS – хостинг VDS/VPS серверов

Comments 29

    +6
    А вот какие: вдумчивое написание JS-кода позволяет получать достаточно высокую производительность и не требует перехода на другие языки программирования.

    да, да и ещё раз да. Очень это сложно объяснить людям кто судит о техе по хайповости и рекламе. Когда я начал профессионально писать на JS несколько лет назад я был удивлён его реально огромной производительностью. Да 20 лет назад он был тормозым, 10 лет назад его обгонял флеш, сегодня он невероятно быстр. А тормозит обычно дом и прочие внешние относительно программы апи или плохие алгоритмы. А они от другого языка не ускорятся.
      0
      как я понимаю, отставание JS от Си будет сильно зависеть от представления данных и алгоритма. вот алгоритм из статьи хорошо зашел.
        0

        Скорость wasm — второстепенная характеристика, приятный бонус. Основная цель — снизить потребление памяти, сетевого трафика и процессорного времени веб-приложений, чтобы повысить общую энергоэффективность на мобильных устройствах.

          +1
          скорость == процессорного времени веб-приложений. Память может быть, трафик не понятно, сжатые скрипты весят не много
            –2
            процессорного времени веб-приложений

            тогда надо добавить время на всякие repaint, context switch, GC, парсинг и компиляцию, кучку фоновых задач по общению с сетью, системой и прочие мелочи без которых контекст приложения не может жить. Как вы понимаете только небольшая часть из этого имеет непосредственное отношению к работе алгоритма и бенчмаркать весь кусок процессорного времени странно неэффективно и закономерно скатывается к грубой лжи из той поговорки.


            трафик не понятно

            не слишком силен в математике сжатия, но словари для wasm вроде вполне могут побить словари для JS/CSS, и содержать больше функционала на условный килобайт данных. Плюс, имея стриминг выполнения, можно разделять приложения на подгружаемые модули и качать/выполнять их лениво. В случае с JS такое провернуть дороговато. Но на данный момент это пока что моя фантазия, модули для wasm еще не завезли.

            0

            если не ошибаюсь, разработчики wasm говорили, что главная фишка — это предсказуемо высокая скорость работы в любом браузере. ну и плюс разработка на языке отличным от JS.

              0

              Предсказуемая производительнось != высокая производительность. Но для массового пользователя она должна быть не меньше чем имеет тот же JS, у которого уже довольно высокая производительность. Опять же — увеличение производительности — приятный бонус, а не цель. Основная цель сократить затраты на обработку и выполнение и как результат оптимизация энергопотребления. В чем браузеры, кстати, давненько соревнуются. Помню еще во времена 12 оперы они промо выпускали, мол глядите что будет с батареей, если стирать обычным порошком смотреть ютуб 8 часов в конкурентном браузере и сколько еще протянет второй такой же ноутбук если смотреть ютуб через оперу.

                0
                Предсказуемая производительнось != высокая производительность

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

            0
            Все не так.

            Мода и хайп – это у реакт и подобных фреймворков.
            А вот Blazor и C# – это другое

            /sarcasm
            –1

            Ну хорошо, после того, как JIT включился, производительность WASM и JS примерно одинакова. А как на счёт времени парсинга, размера бинарников, производительности без JIT? Ведь WASM по идее должен весьма тривиально транслироваться в нативный код по сравнению с JavaScript, который сначала распарсить надо, как минимум.

              0
              размер wasm значительно больше, чем js, поэтому в современных браузерах wasm сначала компилируется быстрым, но «тупым» компилятором, а потом медленным, но «умным». кроме того, файл начинает компилироваться еще до окончания своей загрузки.
                +1
                размер wasm значительно больше, чем js

                ну и шутки у вас

                  0
                  почему шутки? на практике так и есть.

                  Минимальный hello world на wasm будет около 300Кб, а на JS в этот размер можно запихать полноценный проект
                    0

                    Это если вы его каким-нибудь блазором крафтите или обвязку на JS, которую emscripten генерирует для asm.js в рассчет берете. В обычной ситуации даже при всей избыточности синтаксиса размер бинарного представления wasm заметно меньше чем сопоставимый код на js.

                      0
                      Как это у вас выходит?

                      Высокоуровневый язык по определению более компактный. Там где на Javascript будет написано `c=a+b`, на ассемблере (в том числе и WASM), будет намного более длинный набор низкоуровневых инструкций
                      +1
                      Минимальный hello world на wasm будет около 300Кб

                      Ну нет. У меня получилось 48 KiB на wasm, и около 3-4 KiB на TS обвязку (в основном конвертация строк из JS формата в uint8array для C. И это ДО gzip-а. Но да, на чистом JS это всё весит меньше.

                0
                Мы, кроме того, заметили, что Wasm-сборки работают медленнее, чем нативные варианты кода, используемого для создания таких сборок. Разница в производительности составляет 20-50%.

                конечно будут медленнее. производительность сильно зависит от распределения регистров процессора. на это нужно много времени. у компилятора Си это время есть, у компилятора WASM — нет. если не ошибаюсь, в Firefox один и тот же код распределяет регистры в JS и WASM. Мозила давно пилит новый компилятор WASM на Rust-е, но конца этому не видно.

                кроме того, набор команд WASM не особо богат. например, нет операций над блоками памяти (копирование, сравнение, заполнение). нужно эмулировать их отдельными wasm-инструкциями (медленно если блок большой) или вызывать код JS (медленно если блок маленький).
                  +1

                  По воле случая понадобилось мне в браузере запускать числодробилку. Опущу все детали, остановлюсь на сути — надо перебирать сотни тысяч sha1 хешей в поисках правильного. Реализация на JS с использованием window.crypto оказалась невыносимо медленной. Причём не сама, а её promise обвязка. Из-за того что нет синхронной версии большую часть времени браузер гоняет туда сюда свою promise-обвязку. Ок, думаю, воткну тогда какие-нибудь синхроные JS реализации. Оказалось побыстрее. В итоге остановился на библиотеке rusha. При переиспользовании 1-го объекта скорость оказалась весьма и весьма годной (в десяток разов быстрее). Под капотом там preallocation uintXarrays и asm.js. Везде всё протестил и пустил в бой.


                  Затем выяснилось, что с 73-й версии Firefox у себя что-то так наоптимизировал, что то, что раньше отнимало 1-4 секунды теперь стало отъедать до 60. Поигрался и понял, что нет, asm.js не отвалился. Но явно какие-то JS оптимизации пошли лесом. Другие библиотеки для sha1 тоже нужного результата не дали.


                  Ну думаю попробую в деле webAssembly. Думаю, возьму Go, вроде язык простой — быстро решу проблему. Ан нет. Пишут что минимальный wasm файл 2 MiB. Я пас. Выбирая между C и C++ решил остановиться на C, думая что будет проще. Вспомнив, все какие знаю, непристойные выражения собрал на коленке рабочую версию. Бинарно из терминала она работает гораздо быстрее любых других вариантов. Ок. Собрал WASM, цепляю в браузере… Сколько вы там говорите потеря производительности в WASM? 20-30%? У меня получилось в 10 раз. Да, да, в 10 раз. 700ms vs 7sec. Уж не знаю почему так медленно. Но уже не 40 секунд в 12 потоков (webworker-ы), как стало после обновления Firefox. Ну и запустив всё в параллельных webWorker-ах стало умеренно быстро.


                  Вот думаю, что я сделал не так. Пробовал компилировать C файл с -o3 — на результате никак не отразилось. Ничего не понимаю в системном программировании. Для меня это всё тёмный лес. emscripten собрал 48 KiB файл, что приятно. Но к нему полагается здоровенный JS файл с обвязкой (там тонны всяких костылей). Если её избегать, то, к примеру, возникает проблема с передачей строк в wasm-машину, т.к. нужно руками работать с памятью. Бррр. Причём не с абы какой памятью а с WASM emscripten.

                    0

                    в WASM был перенесен весь JS-код или только расчет SHA? на переходы JS <--> WASM нужно время.

                      0

                      В очень упрощённом виде:


                      • есть основной JS thread который порождает cpuCount webWorker-ов. И каждому worker-у по мере его готовности выдаёт диапазон [start, end]. Где end - start = 10^4. Пробовал и 10^5, 10^6 — суть не меняется.
                      • каждый worker внутри себя запускает WASM
                      • WASM, написанный на C, условно делает вот это:

                      function task(start: number, end: number): number {
                        for (let i = start; i <= end; ++ i) {
                          const sha1 = generateSha1(something + i);
                          if (isCorrect(sha1)) {
                            return i; // task is solved
                          }
                        }
                        return 0;  // solution was not found, request another pair [start, end];
                      }

                      Т.е. переходов между JS <--> WASM минимум. По сути 1 вызов на 200-1000ms в зависимости от того как я подкручу числа.


                      В изначальных тестах я запускал всё без webWorker-ов в основном JS thread-е сразу с 1 диапазоном (покрывающим всё сразу). Т.е. вообще без переходов на JS <--> WASM. Разница с таким же запуском в 1 поток просто c-программы из терминала в 10 раз.


                      По сути мне удалось приблизиться к скорости работы однопоточной c-программы только когда я распараллелил в 12 webWorker-ов тот же самый код в WASM-е.

                        +3

                        на вскидку проблем не вижу. но я писал код wasm руками, не на языке высокого уровня. возможно, есть какая-то особенность emscripten, например с выделением памяти. разницы в 10 раз быть не должно, явно где-то есть косяк.

                        0

                        Не исключаю возможность того, что что-то особенное есть в самой реализации sha1 которую я взял. Что-то, что хорошо работает на реальном железе, и очень плохо на виртуальной машине WASM, ввиду отсутствия каких-нибудь инструкций.

                          +2

                          возьмите исходный код Си и посмотрите на предмет спец. инструкций типа sse, avx, intrinsics и т.д.
                          отключите их в компиляторе, выбрав старую модель процессора.
                          спец. инструкций процессора специально для sha я не знаю (есть для aes, crc, rnd).

                    0
                    А почему Chrome в тестах такой старый? Когда тестирование проводилось?
                      0
                      Так и NodeJS 12.9.1 прошлогодний. Оригинал опубликован 12 июня, а фраза «As Max De Marzi said on his blog last summer in 2019» однозначно указывает, что тестировали они в этом году. Видимо где-то в начале этого года тестировали.
                      0
                      Какие выводы мы можем сделать из этих испытаний? А вот какие: вдумчивое написание JS-кода позволяет получать достаточно высокую производительность и не требует перехода на другие языки программирования.

                      А я не хочу писать вдумчиво, я хочу писать простой, прямолинейный код и сразу получать приемлемую производительность. К тому же в статье приведена числодробительная задача, которая хорошо JIT-ится. Если добавить более сложную логику, то производительность может довольно резко упасть, и, так как у вас нет контроля над виртуальной машиной, на которой JS исполняется, вы не можете сказать, когда этот момент наступит.

                        0
                        Думал речь идет о диалекте ассемблера, пора на свалку истории…

                        Only users with full accounts can post comments. Log in, please.