Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript нанобенчмарки и преждевременные тормоза #42

Open
nin-jin opened this issue Feb 23, 2021 · 10 comments

Comments

@nin-jin
Copy link
Owner

nin-jin commented Feb 23, 2021

https://page.hyoo.ru/#!=btunlj_fp1tum

Здравствуйте, меня зовут Дмитрий Карловский и раньше я.. ежедневно измерял свою пипирку, но у распространённых линеек никак не хватало точности для измерения столь малых размеров.

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

Для начала разберём этот кейс:

Наследственный кейс

Тут у нас 3 варианта исполнения:

  • мономорфный - это когда одной и той же функции на вход каждый раз передаются одни и те же типы аргументов. JIT создаёт оптимизированный код, в начале которого идёт лишь проверка, что тип передан тот же.
  • полиморфный - это когда одной и той же функции на вход передаются разные типы аргументов, но их вариативность незначительна (до 4 в Chrome 88, и до 7 в Firefox 85). Тут JIT создаёт несколько версий оптимизированного кода и переключается между ними в зависимости от типов аргументов.
  • мегаморфный - это когда одной и той же функции на вход передаются разные типы аргументов с большой вариативностью. JIT в этом случае сдаётся и выпиливает свои бесполезные оптимизации напрочь.

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

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

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

class Base {

	p0 = 0
	p1 = 1
	p2 = 2
	p3 = 3
	p4 = 4
	p5 = 5
	p6 = 6
	p7 = 7
	p8 = 8
	p9 = 9

	get s() {
		return (
			+ this.p0
			+ this.p1
			+ this.p2
			+ this.p3
			+ this.p4
			+ this.p5
			+ this.p6
			+ this.p7
			+ this.p8
			+ this.p9
		)
	}

}

Теперь добавим пяток наследников:

const Derived = [
	class extends Base { n0 = 0 },
	class extends Base { n1 = 1 },
	class extends Base { n2 = 2 },
	class extends Base { n3 = 3 },
	class extends Base { n4 = 4 },
]

И создадим 3 массива с разной степенью вариативности типов элементов:

const mono = Array.from( {length}, ()=> new Derived[0] )
const poly = Array.from( {length}, (_,i)=> new Derived[i%4] )
const mega = Array.from( {length}, (_,i)=> new Derived[i%5] )

При прогоне редьюсера по первому массиву, у нас всё хорошо - JIT оптимизировал его по самое неболуйся, заинлайнив всё, что возможно. При прогоне по второму массиву из-за полиморфности производительность как прохладного, так и разгорячённого кода падает в 2 раза. А на третьем массиве все оптимизации теряются и разгорячённый код исполняется почти с той же скоростью, что и прохладный.

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

И единственный способ это побороть - это создавать по отдельной функции для каждого наследника. Тогда каждый из них будет мономорфным и JIT будет оптимизировать их независимо. Это даст высокую производительность после прогрева, но ценой огромного потребления памяти.

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

Полевой кейс

Есть и ещё менее очевидные способы всё испортить. Например, воспользоваться инициализаторами полей класса:

class A {
    p0 = 0
    p1 = 0
    p2 = 0
    p3 = 0
    p4 = 0
    p5 = 0
    p6 = 0
    p7 = 0
    p8 = 0
    p9 = 0
}

class B {
    constructor() {
        this.p0 = 0
        this.p1 = 0
        this.p2 = 0
        this.p3 = 0
        this.p4 = 0
        this.p5 = 0
        this.p6 = 0
        this.p7 = 0
        this.p8 = 0
        this.p9 = 0
    }
}

function C() {
    this.p0 = 0
    this.p1 = 0
    this.p2 = 0
    this.p3 = 0
    this.p4 = 0
    this.p5 = 0
    this.p6 = 0
    this.p7 = 0
    this.p8 = 0
    this.p9 = 0
}

Chrome 88 выдаёт следующую картину:

Видимо эту фичу прикручивали в V8 противники преждевременной оптимизации, которые всё ещё ждут подходящего багрепорта, чтобы героически ускорить создание классов в 10 раз. А вот над Firefox 85 работают сторонники своевременной оптимизации:

С другой стороны, как видно из графиков, V8 умеет оптимизировать создание объектов сразу при прохладном старте, а вот Firefox дожидается разгорячённого, но делает это чуть-чуть получше.

Фабричный кейс

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

Принимать в универсальном конструкторе словарь с переопределениями методов:

class Dictionary_base {
	constructor( dict ) {
		Object.assign( this , dict )
	}
}

Принимать функцию инициализации вместо словаря:

class Initializer_base {
	constructor( init ) {
		if( init ) init( this )
	}
}

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

Результаты в Chrome 88 несколько противоречивы:

Словарь хуже оптимизируется, зато даже прохладный он работает довольно быстро. А вот в Firefox 85 ситуация существенно иная:

Последний вариант тут оказывается самым быстрым при любом варианте исполнения. А так как Chrome в любом варианте оказывается быстрее, чем Firefox в самом быстром, то ориентироваться лучше именно на Firefox, чтобы показывать приемлемую производительность даже в худшем для нас случае. Поэтому мы остановились варианте c переопределениями после создания экземпляра.

Солидный кейс

Возможно вас смутила копипаста в некоторых примерах. Ведь её же можно заменить на цикл с добавлением свойств. Однако, при пипиркомерстве важно знать, что цикл вовсе не эквивалентен копипасте. Порой он выходит быстрее за счёт оптимизаций. А порой - драматически медленнее. Создадим, для примера, два одинаковых объекта. Первый наполним полями итеративно:

const chain = {}
const uid = ()=> Math.random().toString(16).slice(2)

for( let i = 0; i < 100; i++ ){
	chain[ uid() ] = i
}

А второй создадим сразу с нужными полями. Чтобы не копипастить, воспользуемся хаком:

const solid = JSON.parse( JSON.stringify( chain ) )

Ну, и для полной картины, добавим ещё и словарик:

const map = new Map
for( let key in chain ) map.set( key, chain[key] )

В Chrome 88 мы видим просто реактивную работу цельносозданного объекта.

Видимо цепочка скрытых классов, которая в этом случае не создаётся, - довольно дорогое удовольствие. А вот в Firefox 85 это не так - тут всё одинаково медленно:

Асинхронный кейс

Наконец, давайте замерим, насколько асинхронный код медленнее синхронного, на примере рекурсивной версии функции Фибоначчи:

function fib_sync(n) {
	if( n < 2 ) return 1
	return fib_sync( n - 1 ) + fib_sync( n - 2 )
}

function* fib_gen(n) {
	if( n < 2 ) return 1
	return ( yield* fib_gen( n - 1 ) ) + ( yield* fib_gen( n - 2 ) )
}

async function fib_async(n) {
	if( n < 2 ) return 1
	return ( await fib_async( n - 1 ) ) + ( await fib_async( n - 2 ) )
}

В Chrome 88 выглядит это примерно так:

Да, так любимый всеми асинхронный код - это крайне медленно. И пусть вас не обольщают относительно хорошие показатели разгорячённого асинхронного варианта, ибо моя пипиркомерка замеряет лишь синхронную часть асинхронного вызова. Но большая часть работы идёт асинхронно, уже после того, как замер завершён. Firefox 85 же вообще после замера подвисает на десятки минут и выедает всю память.

Натуральный кейс

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

И поехали измерять. Начнём с базовой задачи - парсинг строки в формате ISO8601:

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

Штош, замерим и обратную операцию - сериализацию в ISO8601:

Примечательно, что сериализатор JSJoda оказался тут сильно быстрее всех. А вот dateFns тут нет, так как она работает с экземплярами нативного Date, а его мы померили самым первым.

Для пользователя, однако, обычно используется не ISO8601 представление, а более удобное для человека. Так что замерим и кастомное форматирование:

Тут уже отрыв JSJoda не такой существенный. Всё дело в том, что производительность $mol_time сильнее деградирует по мере усложнения паттерна форматирования. Так что его ещё есть куда улучшать.

А в Firefox 85 JSJoda и вовсе сливает:

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

Как это работает

Интерфейс моей пипиркомерки состоит из следующих блоков:

  • общий код
  • вариативный код
  • результаты

Общий код

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

Время работы общего кода в результаты, конечно же не входит.

Для инициализации и тестов порой важно знать сколько итераций будет прогоняться измеряемый вариант. Для получения этого числа доступен специальный макрос {#}.

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

const list = Array.from(
    { length: {#} },
    (_,i)=> new Date( `2015-07-20T07:48:28.${ i % 1000 }Z` ),
})

Вариативный код

Располагается посередине. Каждый вариант состоит из двух частей:

  • Подготовительная, которая исполняется один раз и не замеряется.
  • Замеряемая, которая исполняется множество раз.

В замеряемом коде так же доступен макрос {#}, но тут он означает уже не число итераций, а номер текущей итерации. Например, мы можем взять из ранее объявленного массива уникальный объект и что-то с ним сделать:

res = list[{#}].toString()

Результаты

Появляется справа после запуска тестов и исчезает при изменении кода. Для каждого варианта выводит два результата:

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

Код разгорячённой функции измерения получается примерно таким:

function measure() {
    
    // common setup code
    
    // case setup code
    
    let accum_$qwerty
    const case_$qwerty = iter_$qwerty => {
        // measured code where {#} replaced by iter_$qwerty
        accum_$qwerty = iter_$qwerty
    }
    let time_$qwerty = -performance.now()
    
    case_$qwerty(0);
    case_$qwerty(1);
    case_$qwerty(2);
    
    // ...
    
    time_$qwerty += performance.now()
    // teardown code
    return time_$qwerty
)

А код прохладной чутка другим:

function measure() {
    
    // common setup code
    
    // case setup code
    
    let accum_$qwerty
    const case_$qwerty = iter_$qwerty => {
        accum_$qwerty = iter_$qwerty
    }
    let time_$qwerty = -performance.now()
    
    case_$qwerty(0);
    // measured code where {#} replaced by 0
    case_$qwerty(1);
    // measured code where {#} replaced by 1
    case_$qwerty(2);
    // measured code where {#} replaced by 2
    
    // ...
    
    time_$qwerty += performance.now()
    // teardown code
    return time_$qwerty
)

Как видно, они практически не отличаются по выполняемой работе. Однако, важно отметить, что в замер попадает не только замеряемый код, но и один вызов функции. Это даёт дополнительно примерно 20 наносекунд. Стоит учитывать это при замере экстремально быстрого кода.

Подключение библиотек

Если нужно подключить какую-либо библиотеку, то можно воспользоваться модулем $mol_import, чтобы загрузить её из CDN:

const {
    $mol_time_moment: Moment,
    $mol_time_interval: Interval,
    $mol_time_duration: Duration,
} = $mol_import.script('https://unpkg.com/[email protected]/web.js')

Тесты

Для тестов можно воспользоваться функциями $mol_assert:

$mol_assert_like( [ 1 ], [ 1 ], [ 2 ] ) // Not like [1] --- [2]

Послесловие

perf.js.hyoo.ru - собственно, мой инструмент для нанобенчмаркинга. Примеряйте его к своему коду, не стесняйтесь. Выкладывайте скриншоты с интересными сравнениями - обсудим почему всё именно так.

bench.hyoo.ru - ещё один инструмент, но уже не для нанобенчмаркинга кода, а для бенчмаркинга целых приложений. Я рассказывал о нём в статье: bench.hyoo.ru: готовим JS бенчмарки быстро и просто.

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

@nin-jin nin-jin self-assigned this Feb 23, 2021
@nin-jin nin-jin changed the title JS нанобенчмарки здорового человека JavaScript нанобенчмарки и преждевременные тормоза Feb 24, 2021
@nin-jin nin-jin added HabHub and removed Draft labels Feb 24, 2021
@victor-homyakov
Copy link

victor-homyakov commented Nov 10, 2021

Солидный кейс

Здесь важно знать, что как минимум в V8 результаты бенчмарка зависят от количества полей в объекте.

В V8 для полей объектов есть несколько внутренних представлений: in-object properties, fast properties, slow/dictionary properties (более подробно: https://v8.dev/blog/fast-properties), различающихся по стоимости добавления поля и чтения значения поля. Поэтому сравнивать скорости работы с полями при разных способах создания объекта надо в зависимости от количества операций добавления полей в него: https://jsfiddle.net/osf3rgL6/

В моём экземпляре Chrome 95 хорошо виден переход к slow properties на двадцатой операции добавления поля в объект:
image

Ну и на 128 полях даже solid-объект (в терминах оригинального бенчмарка) всё равно переходит к slow properties:
image

В Firefox 93 такого различия нет, но зато видны ступенчатые изменения скорости после 8, 16 и 32 полей в объекте:
image

@victor-homyakov
Copy link

А вот ещё вариант с использованием https://github.com/sindresorhus/to-fast-properties. Скорее всего, здесь мы видим скорость работы Object.keys() для всех трёх представлений (in-object properties, fast properties, slow/dictionary properties) в V8:

https://jsfiddle.net/dxjsnq27/3/
image

@demimurych
Copy link

demimurych commented Feb 8, 2022

https://habhub.hyoo.ru/#!author=nin-jin/repo=HabHub/article=42/

Вы решительно все перепутали.

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

  2. JIT берется за оптимизации сразу. Есть целый ряд оптимизаций, которые выполняются вне зависимости от собранной статистики. И работа Array.prototype.reduce очень показательный тому пример.

3.1) При прогоне по первому массиву, вы получили: a) самую низкопроизводительную форму массива, b) оптимизированный вариант функции get s, и c) инлайн оптимизированного варината в тело функции reduce.

3.2) По второму массиву вы получили тоже самое с той лишь разницей, что прошли через три дополнительных цикла оптимизации геттера.

3.3) По третьему массиву тоже самое что и по второму, но на 5 варинате hiddenclass была исключена оптимзация которая делала inline кода геттера в тело редьюсера. При этом само тело геттера все так же оптимизировано.

  1. Полевой кейс. Class A у вас при своем создании, генерирует 10 hidden class ов против одного в случае класа B и в случае С.
    Очевидно пояснять почему это медленно ненужно.

  2. Фабричный кейс у вас явно содержит какую то ошибку. Самым быстрам должен быть Initializer. Empty должен генерировать три лишних хидден класса и быть вторым. Ну а с Object.assign и так все ясно. Он может быть оптимизирован только в случае если такие оптимизиации для него специально написаны.

  3. Солидный кейс. Chaine вы создали в режиме словаря, самого медленного представления обьектов в V8.
    JSON.parse всегда создает обьекты в режиме fatsproperty самого быстрого представления обьектов в v8

@nin-jin
Copy link
Owner Author

nin-jin commented Feb 8, 2022

4 - инициализаторы уже починили в v8.
5 - Object.assign в v8 тоже здорово ускорили.

@victor-homyakov
Copy link

@demimurych

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

Первый раз вижу такую трактовку. Например, в https://mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html это описано так:

If we continue calling f with objects of different shapes it’s degree of polymorphism will continue to grow until it reaches a predefined threshold - maximum possible capacity for the inline cache (e.g. 4 for property loads in V8) - at that point cache will transition to a megamorphic state.

То есть мономорфность/полиморфность/мегаморфность кода функции - именно зависимость от того, что передаётся в функцию.

@victor-homyakov
Copy link

JIT за свои оптимизации берётся не сразу, а только после некоторого числа запусков одной и той же функции

JIT берется за оптимизации сразу

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

@demimurych
Copy link

demimurych commented Mar 10, 2022 via email

@nin-jin
Copy link
Owner Author

nin-jin commented Mar 11, 2022

Зачем функции принимать параметр, который в ней не используется, остаётся загадкой.

@demimurych
Copy link

demimurych commented Apr 4, 2022 via email

@victor-homyakov
Copy link

Зачем функции принимать параметр, который в ней не используется, остаётся загадкой.

Например, есть несколько реализаций API, и в одной из них параметр не нужен, но сохранён для читабельности кода https://www.typescriptlang.org/play?#code/C4TwDgpgBAkgYgVwHYGMoF4oAoCGAuKJBAWwCMIAnAGilIKLMppXpPIoEoMA+QtygNwAoISgD2SAM7AoAM2QoAjAXgKM2HDVLMu6XjigBqWkagph4qTPmoATCsSp1uLTQD6u-adLCgA

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants