Написание высокопроизводительного кода Javascript без деоптимизации

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

  1. Мы всегда хотим, чтобы наши массивы были упакованы SMI (небольшие целые числа) или упакованы Doubles, в зависимости от того, выполняем ли мы вычисления с целыми числами или с плавающей запятой.
  2. Мы всегда хотим передавать функции одного и того же типа, чтобы их не помечали как «мегаморфные» и не оптимизировали. Например, мы всегда хотим вызывать vec.add(x, y), когда и x, и y представляют собой упакованные массивы SMI или оба упакованных массива Double.
  3. Мы хотим, чтобы функции были встроены как можно чаще.

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

  1. Вы можете превратить упакованный массив SMI в упакованный массив Double с помощью, казалось бы, безобидной операции, такой как эквивалент myArray.map(x => -x). На самом деле это «лучший» плохой случай, поскольку упакованные массивы Double по-прежнему очень быстры.
  2. Вы можете превратить упакованный массив в универсальный упакованный массив, например, сопоставив массив с функцией, которая (неожиданно) вернула null или undefined. Этого неприятного случая довольно легко избежать.
  3. Вы можете деоптимизировать целую функцию, такую ​​как vec.add(), передав слишком много типов вещей и превратив ее в мегаморфную. Это может случиться, если вы хотите выполнить «общее программирование», где vec.add() используется как в случаях, когда вы не заботитесь о типах (поэтому он видит много типов), так и в случаях, когда вы хотите получить максимальную отдачу. производительность (например, он должен когда-либо получать только дубли в штучной упаковке).

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

  • Есть ли где-нибудь набор рекомендаций о том, как программировать, оставаясь в мире упакованных массивов SMI (например)?
  • Можно ли выполнять общее высокопроизводительное программирование на Javascript без использования чего-то вроде системы макросов для встраивания таких вещей, как vec.add(), в сайты вызовов?
  • Как можно объединить высокопроизводительный код в библиотеки в свете таких вещей, как мегаморфные сайты вызовов и деоптимизация? Например, если я с удовольствием использую пакет линейной алгебры A на высокой скорости, а затем я импортирую пакет B, который зависит от A, но B вызывает его с другими типами и деоптимизирует его, внезапно (без изменения моего кода) мой код работает медленнее. .
  • Существуют ли хорошие простые в использовании инструменты измерения для проверки того, что движок Javascript делает внутри с типами?

person Joppy    schedule 09.03.2020    source источник
comment
Это очень интересная тема и очень хорошо написанный пост, который показывает, что вы правильно выполнили свою часть исследования. Однако я боюсь, что вопрос (вопросы) слишком широк для формата SO, а также что он неизбежно привлечет больше мнений, чем фактов. Оптимизация кода — это очень сложная задача, и две версии движка могут вести себя по-разному. Я думаю, что есть один человек, ответственный за V8 JIT, который иногда зависает поблизости, так что, возможно, они могли бы дать правильный ответ для своего движка, но даже для них, я думаю, это будет слишком широкая тема для одного вопроса/ответа. .   -  person Kaiido    schedule 11.03.2020
comment
Мой вопрос скорее мягок, о том, как писать высокопроизводительный код Javascript... Кстати, обратите внимание, что javascript обеспечивает создание фоновых процессов (веб-работников), а также существуют библиотеки, которые подключаются к графическому процессору. (tensorflow.js и gpu.js) предлагают средства, отличные от того, чтобы полагаться исключительно на компиляцию, чтобы повысить вычислительную пропускную способность приложения на основе javascript...   -  person Trentium    schedule 12.03.2020
comment
@JonTrent На самом деле я немного солгал в своем посте, меня не столько интересуют приложения классической линейной алгебры, сколько компьютерная алгебра над целыми числами. Это означает, что многие существующие числовые пакеты сразу исключаются, поскольку (например) при уменьшении строки матрицы они могут делить на 2, что недопустимо в мире, в котором я работаю, поскольку (1/2) не целое число. Я рассматривал веб-воркеры (особенно для нескольких длительных вычислений, которые я хочу отменить), но проблема, которую я решаю здесь, заключается в снижении задержки, достаточной для того, чтобы реагировать на взаимодействие.   -  person Joppy    schedule 12.03.2020
comment
Что касается целочисленной арифметики в JavaScript, вы, вероятно, смотрите на код в стиле asm.js, примерно ставя |0 за каждой операцией. Это некрасиво, но это лучшее, что вы можете сделать на языке, в котором нет правильных целых чисел. Вы также можете использовать BigInts, но на сегодняшний день они не очень быстры ни в одном из распространенных движков (в основном из-за отсутствия спроса).   -  person jmrk    schedule 12.03.2020


Ответы (1)


Разработчик V8 здесь. Учитывая интерес к этому вопросу и отсутствие других ответов, я могу попробовать; Боюсь, это будет не тот ответ, на который вы надеялись.

Есть ли где-нибудь набор рекомендаций о том, как программировать, оставаясь в мире упакованных массивов SMI (например)?

Краткий ответ: это прямо здесь: const guidelines = ["keep your integers small enough"].

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

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

Возвращаясь к рассматриваемому примеру: предполагается, что внутреннее использование Smis является деталью реализации, о которой код пользователя не должен знать. В одних случаях это сделает работу более эффективной, а в других не повредит. Не все движки используют Smis (например, AFAIK Firefox/Spidermonkey исторически не использовал его; я слышал, что в некоторых случаях они используют Smis в наши дни; но я не знаю никаких подробностей и не могу говорить с кем-либо об этом. причина). В V8 размер Smis является внутренней деталью и фактически менялся со временем и в различных версиях. На 32-битных платформах, которые раньше использовались в большинстве случаев, Smi всегда были 31-битными целыми числами со знаком; на 64-битных платформах они были 32-битными целыми числами со знаком, что недавно казалось наиболее распространенным случаем, пока в Chrome 80 мы не добавили «сжатие указателя» для 64-битных архитектур, что потребовало снижения размера Smi до известного 31 бита. с 32-битных платформ. Если бы вы построили реализацию на предположении, что Smis обычно 32-битные, вы бы столкнулись с такими неприятными ситуациями, как это.

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

Можно ли выполнять общее высокопроизводительное программирование на Javascript без использования чего-то вроде системы макросов для встраивания таких вещей, как vec.add() в callsites?

«общий» обычно расходится с «высокопроизводительным». Это не связано с JavaScript или конкретными реализациями движка.

«Универсальный» код означает, что решения должны приниматься во время выполнения. Каждый раз, когда вы выполняете функцию, должен запускаться код, чтобы определить, скажем, «является ли x целым числом? Если да, выберите этот путь кода. Является ли x строкой? Затем перепрыгните сюда. Это объект? Нет? Тогда, может быть, .toString()? Может быть, в его цепочке прототипов? Вызовите это и перезапустите с самого начала с его результатом». «Высокопроизводительный» оптимизированный код, по сути, построен на идее отказа от всех этих динамических проверок; это возможно только тогда, когда у движка/компилятора есть какой-то способ вывести типы заранее: если он может доказать (или предположить с достаточно высокой вероятностью), что x всегда будет целым числом, то ему нужно только сгенерировать код для этого случая (охраняется проверкой типа, если были задействованы недоказанные предположения).

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

(Для сравнения: C++, будучи статически компилируемым языком, имеет шаблоны для решения связанной с этим проблемы. Короче говоря, они позволяют программисту явно указывать компилятору создавать специализированные копии функций (или целых классов), параметризованных для заданных типов. хорошее решение для некоторых случаев, но не без собственного набора недостатков, например, долгое время компиляции и большие двоичные файлы. JavaScript, конечно, не имеет такого понятия, как шаблоны. Вы могли бы использовать eval для создания системы, которая чем-то похожа, но тогда вы столкнетесь с аналогичными недостатками: вам придется выполнять работу, эквивалентную компилятору C++, во время выполнения, и вам придется беспокоиться об огромном количестве кода, который вы создаете.)

Как можно объединить высокопроизводительный код в библиотеки в свете таких вещей, как мегаморфные сайты вызовов и деоптимизация? Например, если я с удовольствием использую пакет A линейной алгебры на высокой скорости, а затем я импортирую пакет B, который зависит от A, но B вызывает его с другими типами и деоптимизирует его, внезапно (без изменения моего кода) мой код работает медленнее. .

Да, это общая проблема с JavaScript. V8 использовался для внутренней реализации некоторых встроенных функций (таких как Array.sort) в JavaScript, и эта проблема (которую мы называем «загрязнение обратной связи по типу») была одной из основных причин, по которой мы полностью отказались от этой техники.

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

Кроме того, внутри движка существует гораздо больше ситуаций, чем «один тип == быстро» и «более одного типа == медленно». Если данная операция видела и Smis, и двойников, это совершенно нормально. Загрузка элементов из двух видов массивов тоже подходит. Мы используем термин «мегаморфный» для ситуации, когда нагрузка имеет так много разных типов, что отказывается от отслеживания их по отдельности и вместо этого использует более общий механизм, который лучше масштабируется для большого количества типов — функция, содержащая такие нагрузки, может еще оптимизировать. «Деоптимизация» — это очень специфическое действие, когда приходится отбрасывать оптимизированный код для функции, потому что виден новый тип, которого раньше не было, и поэтому оптимизированный код не приспособлен для обработки. Но даже это нормально: просто вернитесь к неоптимизированному коду, чтобы собрать больше отзывов о типах, и снова оптимизируйте позже. Если это произойдет пару раз, то беспокоиться не о чем; это становится проблемой только в патологически тяжелых случаях.

Итак, резюме всего, что есть: не беспокойтесь об этом. Просто напишите разумный код, пусть с этим справится движок. И под «разумным» я подразумеваю: то, что имеет смысл для вашего варианта использования, удобочитаемо, удобно в сопровождении, использует эффективные алгоритмы, не содержит ошибок, таких как чтение за пределами длины массивов. В идеале это все, что нужно, и вам не нужно ничего делать. Если вы чувствуете себя лучше, делая что-то, и/или если вы действительно наблюдаете проблемы с производительностью, я могу предложить две идеи:

Использование TypeScript может помочь. Большое жирное предупреждение: типы TypeScript нацелены на производительность разработчиков, а не на производительность выполнения (и, как выясняется, эти две точки зрения имеют очень разные требования к системе типов). Тем не менее, есть некоторое совпадение: например. если вы постоянно аннотируете вещи как number, то компилятор TS предупредит вас, если вы случайно поместите null в массив или функцию, которая должна содержать/работать только с числами. Дисциплина, конечно, все равно нужна: один-единственный number_func(random_object as number) аварийный люк может бесшумно подорвать все, ведь правильность аннотаций типов нигде не соблюдается.

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

Существуют ли какие-либо хорошие простые в использовании инструменты измерения для проверки того, что движок Javascript делает внутри с типами?

Нет, и это намеренно. Как объяснялось выше, мы не хотим, чтобы вы специально адаптировали свой код к шаблонам, которые V8 сегодня может особенно хорошо оптимизировать, и мы не верим, что вы действительно хотите это делать. Этот набор вещей может измениться в любом направлении: если есть шаблон, который вы хотели бы использовать, мы можем оптимизировать его в будущей версии (ранее мы играли с идеей хранения неупакованных 32-битных целых чисел в качестве элементов массива). .. но работа над этим еще не началась, так что ничего не обещаю); и иногда, если есть шаблон, который мы использовали для оптимизации в прошлом, мы можем решить отказаться от него, если он мешает другим, более важным/эффективным оптимизациям. Кроме того, общеизвестно, что такие вещи, как эвристики встраивания, трудно реализовать правильно, поэтому принятие правильного решения о встраивании в нужное время является областью постоянных исследований и соответствующих изменений в поведении движка/компилятора; что делает это еще одним случаем, когда для всех (вас и нас) было бы неудачно, если бы вы потратили много времени на настройку своего кода до тех пор, пока какой-то набор текущих версий браузера не примет решения о встраивании, которые вы думаете (или знаете?) лучше всего, чтобы вернуться через полгода и понять, что современные браузеры изменили свою эвристику.

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

person jmrk    schedule 11.03.2020
comment
Спасибо за этот отличный ответ, он подтверждает многие из моих подозрений о том, как все работает, и, что важно, как они предназначены для работы. Кстати, есть ли какие-нибудь сообщения в блогах и т. д. о проблеме обратной связи, которую вы упомянули с Array.sort()? Я хотел бы прочитать немного больше об этом. - person Joppy; 13.03.2020
comment
Я не думаю, что мы писали в блоге об этом конкретном аспекте. По сути, это то, что вы сами описали в своем вопросе: когда встроенные функции реализованы в JavaScript, они похожи на библиотеку в том смысле, что если разные части кода вызывают их с разными типами, производительность может пострадать - иногда немного, иногда больше . Это была не единственная и, возможно, даже не самая большая проблема с этой техникой; Я в основном просто хотел сказать, что я знаком с общей проблемой. - person jmrk; 13.03.2020