[Перевод] Мой Javascript быстрее вашего Rust'а

ee6a2f61ab5448296b0ed338129381ed

Предисловие

Я, как истинный почитатель javascript и typescript, просто не мог пройти мимо шикарной статьи на медиум, в которой можно найти много интересных особенностей структур памяти, оптимизации производительности процессора и как с ними связан javascript? В данной статье идет речь о пари сеньора с его подопечными и как этот вызов привел автора к глубокому разбору соотношений javascript’a и Rust’a на поле сражение под названием производительность и память.

Давайте поговорим о производительности

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

Одна из самых приятных (по крайней мере, для меня) частей работы разработчика программного обеспечения — наставничество младших разработчиков и помощь им в ознакомлении с новыми концепциями и более широкими последствиями технических решений. Также весело создавать учебную среду, время от времени позволяя дерзкому разработчику упасть лицом в грязь, что-то вроде «плати вперед», когда я был молодым, дерзким разработчиком.

Прекрасным примером является случай, когда зеленый разработчик оспаривает ваши рекомендации (в реальности, будучи старшим разработчиком, вы всегда делаете неправильный выбор в глазах других) и делает ставку на то, что его подход — лучший подход. Я знаю далеко не все, но я достаточно долго в том деле, чтобы увидеть лопуха. Как я мог сопротивляться? Я приму это пари. И спустя годы я напишу об этом пост.

Пари

Я, честно говоря, не помню особенностей (прошло несколько лет), но помню, что рекомендовал использовать Node.js в первую очередь исходя из набора знаний существующей команды разработчиков, доступных библиотек и прочих технических нужд. Младшие разработчики хотели показать «безумные» навыки модных бакалавров компьютерных наук. Может быть, они знали, что я не очень хорошо разбираюсь в компьютерных науках, и предположили, что я просто не догадываюсь, как на самом деле работают компьютеры (честно говоря, спустя ~ 20 лет я пришел к выводу, что это просто магия).

Утверждение было чем-то вроде стандартного «C++ быстрее, чем Javascript», которому противостоял мой (стереотипно сеньорский) ответ: смотря по обстоятельствам. Или, возможно, более конкретно, «оптимизированный C++ будет работать лучше, чем оптимизированный Javascript», поскольку запуск Javascript сопряжен с неизбежными накладными нагрузками (ну, вы, вероятно, могли бы скомпилировать его в статическую программу и получить аналогичную производительность, если бы очень-очень постарались). Излишне говорить, что мне нравится хорошие испытания и вызовы.

И в результате…

«Сюрпризом» было то, что решение на javascript оказалось немного быстрее, чем программа на C++, и (что более важно с архитектурной точки зрения) имело то преимущество, что оно было полностью совместимо с техническими особенностями проекта. Пусть бакалавры молчат и чешут затылки. Честно говоря, я не был на 100% уверен, что javascript выиграет, но, основываясь на вероятной зависимости этого конкретного варианта использования от объектов памяти с динамическим размером и неопытности разработчика, я сделал обоснованное предположение.

Подожди-ка, но как?

Если вы не можете догадаться «почему?», не волнуйтесь. По моему опыту, большинство разработчиков тоже не знают почему. Результат противоречит общему правилу, согласно которому скомпилированные языки работают быстрее, чем интерпретируемые, а статические программы быстрее, чем динамические. Но это всего лишь эмпирическое правило.

«Оптимизированный» — ключевое слово в моем ответе выше, поскольку наивная программа на C++ может быстро сойти с рельсов. С другой стороны, Node.js (использующий V8 и libuv на основе C++/C) добился больших успехов в оптимизации глупого JS для быстрой работы, а это означает, что есть случаи, когда наивный JS может превзойти наивный C++. Но это явно сложнее.

Ах да, память…

Большинство разработчиков должно быть знакомо с идеями стеков и куч, но многие не углубляются в поверхностные характеристики, такие как линейность стека и куча с указателями (или что-то в этом роде). Они также, вероятно, упустили из виду, что это всего лишь концепции (а есть и другие подходы) с несколькими реализациями. Аппаратное обеспечение низкого уровня обычно не знает, что такое, черт возьми, «куча», поскольку программное обеспечение определяет, как управляется память*, и сделанный выбор может иметь огромное влияние на характеристики производительности конечной программы.

* Есть целая кроличья нора, в которую можно (и, возможно, нужно) спуститься. Ядра могут быть сложными, а современное оборудование далеко не глупым и часто может включать в себя ряд оптимизаций специального назначения, которые могут использовать высокоуровневые схемы памяти в своих оптимизациях. Это может означать, что программное обеспечение может (или вынуждено) делегировать функции управления памятью, предоставляемые оборудованием. И это даже не касается виртуализации…

Наши дни: вход в Rust

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

Один из проектов, над которым я сейчас работаю, — это хост FaaS (функция как услуга), написанный на Rust, который выполняет функции WASM (WebAssembly). Он предназначен для очень быстрого безопасного выполнения изолированных функций, минимизируя накладные расходы на использование FaaS. И это довольно быстро, способно получить 90 тысяч чистых запросов в секунду на ядро. Более того, он может сделать это с общим объемом эталонной памяти ~ 20 МБ.

Какое это имеет отношение к Node.js и C++? Ну, я использую Node.js в качестве эталона для «разумной» производительности (Go используется как цель «мечты», его трудно сравнивать с языком, разработанным для веб-сервисов, добавляя накладные расходы производительности на FaaS), и ранние версии программы не были многообещающими (хотя они использовали менее 10% памяти Node.js).

Однако сдерживающий фактор был довольно очевидным с самого начала. Это было управление памятью. Для каждой функции был выделен массив памяти, но было много накладных расходов производительности между выделением внутри функции, а также копированием данных в и из памяти функции и хоста. Из-за перебрасывания динамических данных аллокатор забивался со всех сторон. Решение: считерить (ну типа).

Я люблю «кучи», я возьму две (или три)!

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

Проблемы начинают возникать при выделении множества единиц памяти разного размера с разным временем жизни, вы в конечном итоге получите большую фрагментацию, которая увеличивает стоимость выделения новой памяти. Именно здесь вы начинаете замечать снижение производительности, поскольку это, по сути, собственная программа, просто чтобы выяснить, где хранить вещи. Очевидно, что у этой проблемы нет единого решения, существует множество различных алгоритмов распределения от buddy system до slabs и блоков. У каждого подхода есть компромиссы, то есть вы можете выбрать, какой из них лучше всего подходит для вашего варианта использования (или просто выбрать вариант по умолчанию, как это делает большинство людей).

Теперь для читерства вам не нужно выбирать только один подход. А для FaaS вы можете отказаться от выделения ресурсов для каждого прогона и просто очищать всю кучу после каждого прогона. И вы можете использовать разные аллокаторы для разных частей жизненного цикла функции, например. инициализация против запуска. Это позволяет использовать либо чистую функцию (сбрасывать одно и то же состояние памяти при каждом запуске), либо функцию с отслеживанием состояния (сохранение состояния между запусками) и оптимизировать каждый случай с использованием другой стратегии памяти.

Для моего проекта FaaS мы создали динамический аллокатор, который выбирает алгоритм распределения на основе использования, и этот выбор сохраняется между запусками. Для «малоиспользуемых» функций (по-видимому, большинства функций на данный момент) используется наивный аллокатор стека, который просто поддерживает один указатель на следующий свободный слот. При вызове Dealloc, если модуль является последним в стеке, он просто откатывает указатель, в противном случае это noop. Когда функция завершена, указатель устанавливается на 0 (например, выход Node.js перед сборкой мусора). Если функция достигает определенного количества неудачных операций освобождения памяти и определенного порога использования, для остальных вызовов используется другой алгоритм распределения. Результатом является очень быстрое выделение памяти в большинстве случаев.

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

Node.js vs Rust

После оптимизации среда выполнения Rust FaaS стала на > 70% быстрее при использовании на > 90% меньше памяти, чем наша эталонная реализация Node.js. Но ключ в том, что «после оптимизации» первоначальная реализация была медленнее. И это потребовало наложения некоторых ограничений на работу функций WASM, хотя они прозрачно применяются во время компиляции с редкими несовместимостями.

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

У нас запланированы дополнительные оптимизации, но в основном они связаны с изменениями на уровне хоста, которые имеют серьезные последствия для безопасности. Они также не имеют прямого отношения к производительности управления памятью, но дают много пищи для лагеря «Rust быстрее, чем Node».

Заключение

Не уверен. Я предполагаю пару моментов:

  • Управление памятью интересно, и у каждого подхода есть компромиссы. Играя с различными стратегиями, можно получить огромный прирост производительности.

  • Я до сих пор использую (и рекомендую) и Node.js, и Rust для разных целей, так что тут тоже нет никакой победы. JavaScript замечательно переносим и отлично работает для множества облачных сценариев, но Rust — отличный выбор, когда действительно важна производительность.

  • И всякий раз, когда я говорю JavaScript, я на самом деле имею в виду TypeScript. Я ведь не дикарь.

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

Благодарю за внимание!

© Habrahabr.ru