Подтвердить что ты не робот

Пулы объектов в высокопроизводительном javascript?

Я пишу код javascript, который нужно быстро запускать, и использует много короткоживущих объектов. Мне лучше использовать пул объектов или просто создавать объекты по мере необходимости?

Я написал тест JSPerf, который говорит о том, что нет никакой пользы от использования пула объектов, однако я не уверен, что тесты jsperf запустите достаточно долго, чтобы сборщик мусора браузера заработал.

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

4b9b3361

Ответ 1

Позвольте мне начать с высказывания: я бы советовал пулам, если вы не разрабатываете визуализацию, игры или другой вычислительно дорогой код, который на самом деле выполняет большую работу. Ваше среднее веб-приложение связано с привязкой ввода-вывода, и ваш процессор и оперативная память в большинстве случаев будут простаивать. В этом случае вы получаете гораздо больше за счет оптимизации ввода-вывода, а не скорости выполнения; т.е. убедитесь, что ваши файлы загружаются быстро, и вы используете клиентскую сторону, а не серверную визуализацию + шаблонизацию. Однако, если вы играете с играми, научными вычислениями или другим кодом Javascript, связанным с CPU, эта статья может быть вам интересна.

Краткая версия:

В критическом для производительности коде:

  • Начните с оптимизации общего назначения [1] [2] [3] [4] (и многое другое). Не прыгайте в бассейны сразу (вы знаете, что я имею в виду!).
  • Будьте осторожны с синтаксическим сахаром и внешними библиотеками, так как даже Promises и многие встроенные модули (такие как Array.concat и т.д.) делают много злых вещей под капотом, включая выделения.
  • Избегайте неизменяемых (например, String), поскольку они будут создавать новые объекты во время операций изменения состояния, которые вы выполняете на них.
  • Знайте свои распределения. Используйте инкапсуляцию для создания объекта, поэтому вы можете легко найти все распределения и быстро изменить свою стратегию распределения во время профилирования.
  • Если вы беспокоитесь о производительности, всегда просматривайте и сравнивайте разные подходы. В идеале вы не должны случайно верить кому-то в intarwebz (включая меня). Помните, что наши определения слов типа "быстрый", "долгоживущий" и т.д. Могут сильно различаться.
  • Если вы решили использовать пул:
    • Возможно, вам придется использовать разные пулы для долгоживущих и короткоживущих объектов, чтобы избежать фрагментации короткоживущего пула.
    • Вы хотите сравнить различные алгоритмы и различную детализацию объединения (объединить целые объекты или объединить только некоторые свойства объекта?) для разных сценариев.
    • Объединение повышает сложность кода и, следовательно, затрудняет работу оптимизатора, что потенциально снижает производительность.

Длинная версия:

Сначала рассмотрим, что системная куча по сути такая же, как и большой пул объектов. Это означает, что всякий раз, когда вы создаете новый объект (используя new, [], {}, (), вложенные функции, конкатенация строк и т.д.), система будет использовать алгоритм (очень сложный, быстрый и низкоуровневый), чтобы дать вам некоторое неиспользуемое пространство (т.е. объект), убедитесь, что его байты обнулены и возвращают. Это очень похоже на то, что должен делать пул объектов. Тем не менее, менеджер кучи времени Javascript использует GC для извлечения "заимствованных объектов", где пул возвращает ему объекты с почти нулевой стоимостью, но требует, чтобы разработчик сам следил за всеми такими объектами.

Современные среды выполнения Javascript, такие как V8, имеют профилировщик времени выполнения и оптимизатор времени выполнения, который идеально может (но не обязательно (пока)) оптимизировать агрессивно, когда он идентифицирует критические разделы, зависящие от производительности. Он также может использовать эту информацию для определения подходящего времени для сбора мусора. Если он понимает, что вы запускаете игровой цикл, он может просто запустить GC после каждых нескольких циклов (возможно, даже уменьшить коллекцию старшего поколения до минимума и т.д.), Тем самым фактически не позволяя вам почувствовать работу, которую она выполняет (однако она все равно будет разрядите батарею быстрее, если это дорогостоящая операция). Иногда оптимизатор может даже переместить выделение в стек, и такое распределение в основном бесплатное и гораздо более безопасное для кеша. При этом такие методы оптимизации не идеальны (и на самом деле их не может быть, так как идеальная оптимизация кода NP-hard, но эта другая тема).

Возьмем, например, игры: Этот разговор о быстрой векторной математике в JS объясняет, как повторяющееся распределение векторов (и вам нужно много векторная математика в большинстве игр) замедлила то, что должно быть очень быстро: векторная математика с Float32Array. В этом случае вы можете извлечь выгоду из пула, если правильно используете правильный пул.

Это мои уроки, извлеченные из написания игр в Javascript:

  • Инкапсулировать создание всех часто используемых объектов в функциях. Пусть сначала он возвращает новый объект, а затем сравнивает его с версией пула:

Вместо

var x = new X(...);

использование:

var x = X.create(...);

или даже:

// this keeps all your allocation in the control of `Allocator`:
var x = Allocator.createX(...);      // or:
var y = Allocator.create('Y', ...);

Таким образом, вы можете реализовать X.create или Allocator.createX с помощью return new X();, а затем заменить его пулом позже, чтобы легко сравнить скорость. Еще лучше, это позволяет быстро найти все распределения в вашем коде, чтобы вы могли просматривать их по очереди, когда придет время. Не беспокойтесь о дополнительном вызове функции, так как это будет встроено любым приличным инструментом оптимизатора и, возможно, даже оптимизатором времени выполнения.

  • Попытайтесь свести к минимуму создание объекта. Если вы можете повторно использовать существующие объекты, просто сделайте это. Возьмите 2D-векторную математику в качестве примера: не делайте векторы (или другие часто используемые объекты) неизменяемыми. Несмотря на то, что неизменяемость создает более красивый и более устойчивый к ошибкам код, он имеет тенденцию быть чрезвычайно дорогостоящим (потому что внезапно для каждой векторной операции требуется либо создание нового вектора, либо получение одного из пула, а не просто добавление или умножение нескольких чисел). Причина, по которой на других языках вы можете сделать векторы неизменными, заключается в том, что часто эти распределения могут выполняться в стеке, что снижает стоимость распределения до практически нулевого уровня. Однако в Javascript -

Вместо:

function add(a, b) { return new Vector(a.x + b.x, a.y + a.y); }
// ...
var z = add(x, y);

попробовать:

function add(out, a, b) { out.set(a.x + b.x, a.y + a.y); return out; }
// ...
var z = add(x, x, y);   // you can do that here, if you don't need x anymore (Note: z = x)
  • Не создавать временные переменные. Это делает параллельные оптимизации практически невозможными.

Избегайте:

var tmp = new X(...);
for (var x ...) {
    tmp.set(x);
    use(tmp);       // use() will modify tmp instead of x now, and x remains unchanged.
}
  • Точно так же, как временные переменные перед вашими циклами, простое объединение будет препятствовать оптимизации параллелизации простых циклов. Оптимизатору будет непросто доказать, что операции с пулом не требуют определенного порядка, и, по крайней мере, это будет нужна дополнительная синхронизация, которая может не понадобиться для new (поскольку время выполнения имеет полный контроль над тем, как распределять вещи). В случае жестких вычислительных циклов вам может потребоваться выполнить несколько вычислений на итерацию, а не только одну (которая также известна как частично развернутый цикл).
  • Если вам не нравится возиться, не пишите свой собственный пул. Их уже много. В этой статье, например, перечислены целая группа.
  • Попробуйте только объединение, если вы обнаружите, что memory churn разрушает ваш день. В этом случае убедитесь, что вы правильно профилируете свое приложение, выясняете узкие места и реагируете. Как всегда: не оптимизируйте слепо.
  • В зависимости от типа алгоритма запроса пула вы можете использовать разные пулы для долгоживущих и недолговечных объектов, чтобы избежать фрагментации короткоживущего пула. Запрос короткоживущих объектов гораздо более критичен для производительности, чем запрос на долгоживущие объекты (поскольку первое может происходить сотни, тысячи или даже миллионы раз в секунду).

Алгоритмы пула

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

  • Связанный список: сохраняйте только пустые объекты в списке. Всякий раз, когда требуется объект, удалите его из списка за небольшую плату. Верните его, когда объект больше не нужен.
  • Массив: Храните все объекты в массиве. Всякий раз, когда требуется объект, итерации по всем пустым объектам, верните первый, который является бесплатным, и установите для него флаг inUse в значение true. Отсоедините его, когда объект больше не нужен.

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

Заключительные слова

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

Ответ 2

Вообще говоря (в моем личном опыте), объединение объектов не улучшит скорость. Создание объектов обычно очень дешево. Скорее, целью пула объектов является сокращение периодического запаздывания, вызванного сборками мусора.

В качестве конкретного примера (не обязательно для JavaScript, но в качестве общей иллюстрации), подумайте о играх с продвинутой 3D-графикой. Если в одной игре средняя частота кадров составляет 60 кадров в секунду, это быстрее, чем в другой игре со средней частотой кадров 40 кадров в секунду. Но если вторая частота fps равна 40, графика выглядит гладко, тогда как если частота часто намного выше 60 кадров в секунду, но время от времени падает до 10 кадров в секунду, графика выглядит нестабильной.

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

Это не полная заявка, охватывающая все случаи, конечно. Один сценарий, в котором объединение может улучшить не только изменчивость, но и необработанную производительность, - это когда вы часто распределяете большие массивы: просто устанавливая arr.length = 0 и повторно используя arr, вы можете повысить производительность, избегая будущих повторных вычислений. Аналогичным образом, если вы часто создаете очень большие объекты, все из которых имеют общую схему (т.е. Они имеют четко определенный набор свойств, поэтому вам не нужно "чистить" каждый объект при возврате его в пул) вы также можете увидеть улучшение производительности от объединения в этом случае.

Как я уже сказал, вообще говоря, это не основная цель пулов объектов.

Ответ 3

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

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

Ответ 4

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

http://buildnewgames.com/garbage-collector-friendly-code/

Ответ 5

Я думаю, что это зависит от сложности ваших объектов. Недавно я оптимизировал текстовый процессор JavaScript, который использовал объекты JS в паре с объектами DOM для каждого элемента документа. Перед внедрением пула объектов время загрузки для моего тестового документа составляло около 480 мс. Метод объединения уменьшил это до 220 мс.

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