Pull to refresh

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

Reading time8 min
Views6.1K

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



Поэтому я решил, что хватит это терпеть!… и выстругал свою с нанометровыми делениями, поддержкой прохладного и разгорячённого измерения, тестами, шарингом и прочими вольностями. Так что приглашаю и вас присоединиться к этой спец олимпиаде по измерению скорости своего 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/mol_time_all@1.1.12/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 библиотеки, то пишите в личку, если хотите присоединиться к нашему полузакрытому чату, где мы в кругу мейнтейнеров обсуждаем всякие такие, не интересные обычным разработчикам, темы.


Only registered users can participate in poll. Log in, please.
Какая у вас длина?
5.88% > 50 Начисление одного приглашения (однократно), позволяющего сделать полноправным аккаунт любого пользователя, кроме заблокированного за нарушение правил сообщества2
8.82% ≥ 30 Размещать публикации в хабе «Я пиарюсь»3
14.71% ≥ 5 Голосовать за публикации, комментарии и карму других пользователей5
23.53% ≥ 1 Отключать показ рекламных блоков8
23.53% ≥ 0 Размещать публикации в профильные хабы8
14.71% От −1 до −10 Возможность размещать комментарии лишь 1 раз в 5 минут5
0% От −11 до −30 1 комментарий в час0
2.94% От −31 до −100 1 комментарий в день1
5.88% От −100 и ниже 1 комментарий в неделю и значок «Тролль»2
34 users voted. 23 users abstained.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+18
Comments5

Articles