Профилирование и бенчмарки

Интернационализация редко становится главным источником деградации производительности, однако в крупных приложениях FormatJS способен создавать заметную нагрузку:

  • при массовом форматировании дат и чисел;
  • при повторном создании экземпляров форматтеров;
  • при интенсивном рендеринге React-компонентов;
  • при работе с большими словарями переводов;
  • при серверном рендеринге;
  • при частом переключении локалей.

Профилирование позволяет определить:

  • стоимость форматирования сообщений;
  • влияние Intl.* API на CPU;
  • объём создаваемых объектов;
  • количество лишних рендеров;
  • эффективность кэширования;
  • влияние ICU MessageFormat на время исполнения.

Архитектура производительности FormatJS

FormatJS состоит из нескольких уровней:

  1. Intl.* API браузера или Node.js.
  2. Парсер ICU MessageFormat.
  3. Кэширование форматтеров.
  4. React-обвязка (react-intl).
  5. Система хранения переводов.

Каждый уровень влияет на производительность по-разному.

Наиболее дорогие операции

На практике самыми затратными являются:

Операция Стоимость
Создание Intl.DateTimeFormat Высокая
Создание Intl.NumberFormat Высокая
Парсинг ICU-сообщений Средняя
React rerender Средняя
Форматирование простых строк Низкая
Чтение сообщений из памяти Низкая

Базовые принципы измерений

Проблема ложных бенчмарков

Неправильные измерения встречаются чаще корректных.

Типичные ошибки:

  • измерение в dev-режиме;
  • отсутствие прогрева JIT;
  • слишком маленькое число итераций;
  • измерение вместе с логированием;
  • смешивание разных задач в одном тесте;
  • отсутствие контроля GC.

Плохой пример:

console.time('format')

for (let i = 0; i < 10; i++) {
  intl.formatMessage({
    id: 'welcome'
  })
}

console.timeEnd('format')

Такой тест практически бесполезен:

  • слишком мало итераций;
  • JIT не успевает оптимизировать код;
  • время может зависеть от фоновых процессов.

Корректный подход

Использование большого количества итераций

const iterations = 100000

console.time('format')

for (let i = 0; i < iterations; i++) {
  intl.formatMessage({
    id: 'welcome'
  })
}

console.timeEnd('format')

Прогрев JIT

Перед измерениями движок JavaScript должен оптимизировать код.

for (let i = 0; i < 10000; i++) {
  intl.formatMessage({
    id: 'welcome'
  })
}

После прогрева запускается основной тест.


Изоляция тестируемой операции

Измеряться должна только одна операция.

Плохо:

intl.formatMessage(...)
console.log(...)
updateState(...)

Хорошо:

intl.formatMessage(...)

Измерение производительности formatMessage

Базовый бенчмарк

import {createIntl, createIntlCache} fr om '@formatjs/intl'

const cache = createIntlCache()

const intl = createIntl({
  locale: 'en',
  messages: {
    hello: 'Hello {name}'
  }
}, cache)

const iterations = 100000

console.time('formatMessage')

for (let i = 0; i < iterations; i++) {
  intl.formatMessage(
    {id: 'hello'},
    {name: 'John'}
  )
}

console.timeEnd('formatMessage')

Что измеряется

Внутри formatMessage происходят:

  1. поиск сообщения;
  2. парсинг ICU-шаблона;
  3. создание AST;
  4. интерполяция переменных;
  5. сборка строки.

Если AST уже закэширован — часть шагов пропускается.


Профилирование ICU MessageFormat

Простые сообщения

'Hello world'

Форматируются крайне быстро.


Сообщения с параметрами

'Hello {name}'

Требуют интерполяции значений.


Plural Rules

'{count, plural,
  one {# item}
  other {# items}
}'

Стоимость увеличивается:

  • выполняются plural rules;
  • используется ICU AST;
  • добавляется логика ветвления.

Sel ect Rules

'{gender, select,
  male {He}
  female {She}
  other {They}
}'

select обычно дешевле plural, но всё равно требует дополнительных операций.


Вложенные конструкции

'{count, plural,
  one {{gender, select,
    male {He has one item}
    female {She has one item}
    other {They have one item}
  }}
  other {{gender, select,
    male {He has # items}
    female {She has # items}
    other {They have # items}
  }}
}'

Такие шаблоны становятся существенно тяжелее.


Сравнение сложности сообщений

Тестирование разных шаблонов

const messages = {
  simple: 'Hello',
  variable: 'Hello {name}',
  plural: `{count, plural,
    one {# item}
    other {# items}
  }`
}

Бенчмарк:

function benchmark(id, values) {
  const iterations = 100000

  console.time(id)

  for (let i = 0; i < iterations; i++) {
    intl.formatMessage({id}, values)
  }

  console.timeEnd(id)
}

benchmark('simple')
benchmark('variable', {name: 'John'})
benchmark('plural', {count: 5})

Кэширование как главный фактор производительности

Проблема постоянного создания форматтеров

Очень дорого:

function formatDate(date) {
  const formatter = new Intl.DateTimeFormat('en')

  return formatter.format(date)
}

Создание Intl.DateTimeFormat — одна из самых затратных операций в интернационализации.


Эффективное кэширование

const formatter = new Intl.DateTimeFormat('en')

function formatDate(date) {
  return formatter.format(date)
}

Встроенный кэш FormatJS

createIntlCache

import {createIntlCache} fr om '@formatjs/intl'

const cache = createIntlCache()

Кэш хранит:

  • Intl.NumberFormat;
  • Intl.DateTimeFormat;
  • Intl.RelativeTimeFormat;
  • Intl.PluralRules;
  • AST сообщений.

Профилирование без кэша

const intl = createIntl({
  locale: 'en',
  messages
})

Профилирование с кэшем

const cache = createIntlCache()

const intl = createIntl({
  locale: 'en',
  messages
}, cache)

Разница может достигать десятков раз при интенсивном форматировании.


Использование Performance API

performance.now

const start = performance.now()

intl.formatMessage({
  id: 'hello'
})

const end = performance.now()

console.log(end - start)

Высокоточные измерения

performance.now():

  • имеет высокую точность;
  • не зависит от системного времени;
  • подходит для микробенчмарков.

User Timing API

Метки производительности

performance.mark('start')

intl.formatMessage({
  id: 'hello'
})

performance.mark('end')

performance.measure(
  'message-format',
  'start',
  'end'
)

Анализ в Chrome DevTools

После использования performance.measure данные появляются:

  • в разделе Performance;
  • в User Timing timeline;
  • в trace-событиях.

Профилирование в Chrome DevTools

Performance Panel

Основной инструмент браузерного анализа.

Позволяет определить:

  • горячие функции;
  • длительные задачи;
  • лишние рендеры;
  • блокировки main thread;
  • частоту вызовов форматирования.

Запись профиля

Типичный сценарий:

  1. открыть вкладку Performance;
  2. начать запись;
  3. выполнить пользовательский сценарий;
  4. остановить запись;
  5. проанализировать flame graph.

Поиск FormatJS в flame graph

Обычно видны:

  • formatMessage;
  • IntlMessageFormat;
  • Intl.DateTimeFormat;
  • React render phase.

React Profiler и react-intl

Основная проблема

Чрезмерные рендеры компонентов:

<FormattedMessage id="hello" />

Если компонент рендерится слишком часто — форматирование повторяется.


React Profiler

Позволяет определить:

  • какие компоненты рендерятся;
  • сколько времени занимает рендер;
  • почему произошёл rerender.

Профилирование компонентов

<Profiler
  id="App"
  onRen der={callback}
>
  <App />
</Profiler>

Анализ callback

function callback(
  id,
  phase,
  actualDuration
) {
  console.log(id)
  console.log(phase)
  console.log(actualDuration)
}

Избыточные рендеры

Антипаттерн

<IntlProvider
  locale={locale}
  messages={{...messages}}
>

Каждый рендер создаёт новый объект сообщений.


Оптимизация

const memoizedMessages = useMemo(
  () => messages,
  [locale]
)

Профилирование IntlProvider

Проблема пересоздания контекста

При изменении props:

<IntlProvider
  locale={locale}
  messages={messages}
>

React пересоздаёт контекст.

Это приводит к:

  • каскадным rerender;
  • повторному форматированию;
  • обновлению дочерних компонентов.

Стабилизация ссылок

const intlConfig = useMemo(() => ({
  locale,
  messages
}), [locale])

Измерение памяти

Почему память важна

FormatJS активно создаёт:

  • AST;
  • formatter instances;
  • строки;
  • временные объекты.

При большом количестве локалей это становится заметным.


Memory Profiling в Chrome

Heap Snapshot

Позволяет определить:

  • утечки;
  • избыточные кэши;
  • количество formatter objects;
  • удерживаемые AST.

Allocation Timeline

Показывает:

  • частоту аллокаций;
  • источники создания объектов;
  • GC pressure.

Утечки памяти

Типичная ошибка

const cache = new Map()

function getIntl(locale) {
  if (!cache.has(locale)) {
    cache.set(locale, createIntl(...))
  }

  return cache.get(locale)
}

Если локалей много — кэш растёт бесконечно.


Ограничение размера кэша

class LRUCache {
  constructor(lim it = 10) {
    this.limit = limit
    this.map = new Map()
  }
}

Профилирование SSR

Особенности серверного рендеринга

На сервере:

  • отсутствует DOM;
  • форматирование выполняется массово;
  • важна latency каждого запроса.

Узкие места SSR

Особенно дорогими становятся:

  • создание intl;
  • загрузка сообщений;
  • парсинг ICU;
  • сериализация.

Бенчмарк SSR

import {renderToString} fr om 'react-dom/server'

console.time('ssr')

renderToString(<App />)

console.timeEnd('ssr')

Пул intl-объектов

Оптимизация SSR

Создание intl на каждый запрос дорого.

Возможен пул объектов:

const intlPool = new Map()

Переиспользование

function getIntl(locale) {
  if (!intlPool.has(locale)) {
    intlPool.set(
      locale,
      createIntl(...)
    )
  }

  return intlPool.get(locale)
}

Предкомпиляция сообщений

Проблема runtime-парсинга

Если ICU-парсинг выполняется в runtime:

intl.formatMessage(...)

часть времени уходит на разбор шаблонов.


Предкомпиляция AST

FormatJS CLI умеет компилировать сообщения заранее.

Это уменьшает:

  • startup time;
  • runtime parsing;
  • CPU usage.

Использование AST

{
  "hello": [
    {
      "type": 0,
      "value": "Hello"
    }
  ]
}

Измерение эффекта предкомпиляции

Сравнительный тест

Runtime parsing

createIntl({
  messages: rawMessages
})

Precompiled AST

createIntl({
  messages: compiledMessages
})

Node.js Profiling

node --prof

node --prof app.js

Создаёт V8 profile log.


Обработка результатов

node --prof-process isolate.log

Clinic.js

Комплексное профилирование

Инструмент:

  • CPU profiling;
  • event loop analysis;
  • flame graphs;
  • memory diagnostics.

Особенно полезен для SSR-приложений.


Автоматизированные бенчмарки

Benchmark.js

Стандарт де-факто для JavaScript-бенчмарков.

import Benchmark from 'benchmark'

const suite = new Benchmark.Suite()

Добавление тестов

suite
  .add('format simple', () => {
    intl.formatMessage({
      id: 'simple'
    })
  })
  .add('format plural', () => {
    intl.formatMessage(
      {id: 'plural'},
      {count: 5}
    )
  })

Запуск

suite
  .on('cycle', event => {
    console.log(String(event.target))
  })
  .run()

Сравнение локалей

Разные локали имеют разную стоимость

Например:

  • en обычно дешевле;
  • ar сложнее;
  • zh может использовать специфические правила;
  • plural logic различается между языками.

Бенчмарк локалей

const locales = ['en', 'ar', 'ru', 'zh']

Производительность plural rules

Английский язык

one / other

Минимальная сложность.


Русский язык

one / few / many / other

Больше условий.


Арабский язык

Содержит ещё больше plural categories.

Это влияет на стоимость Intl.PluralRules.


Оптимизация React-компонентов

Использование memo

export default React.memo(Component)

Минимизация inline-объектов

Плохо:

<FormattedMessage
  values={{
    count
  }}
/>

Стабилизация значений

const values = useMemo(() => ({
  count
}), [count])

Lazy Loading переводов

Проблема больших бандлов

Все локали в одном bundle:

import en from './en.json'
import ru from './ru.json'
import fr from './fr.json'

Увеличивают:

  • startup time;
  • memory usage;
  • parse time.

Динамический импорт

const messages = await import(
  `./locales/${locale}.json`
)

Анализ размера бандла

Влияние polyfills

FormatJS polyfills могут быть тяжёлыми.

Особенно:

  • Intl.RelativeTimeFormat;
  • Intl.PluralRules;
  • Intl.DisplayNames.

Bundle Analyzer

Инструменты:

  • webpack-bundle-analyzer;
  • Vite visualizer;
  • Rollup visualizer.

Профилирование polyfills

Проверка необходимости

Современные браузеры уже поддерживают большую часть Intl.

Лишние polyfills увеличивают:

  • размер JS;
  • parse time;
  • compile time.

Условная загрузка

if (!Intl.RelativeTimeFormat) {
  await import(
    '@formatjs/intl-relativetimeformat/polyfill'
  )
}

Метрики производительности

Важные показатели

CPU time

Время выполнения форматирования.


Memory usage

Потребление памяти.


Throughput

Количество операций в секунду.


Startup latency

Время инициализации i18n.


Render cost

Стоимость React-рендера.


Реальные сценарии нагрузочного тестирования

Массовое форматирование таблиц

Типичный тяжёлый кейс:

10000 строк
×
5 колонок
×
форматирование дат

Виртуализация

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

  • react-window;
  • react-virtualized.

Существенно уменьшает нагрузку.


Производительность FormattedMessage

JSX-компонент против imperative API

Компонентный стиль

<FormattedMessage id="hello" />

Imperative API

intl.formatMessage({
  id: 'hello'
})

Imperative API обычно быстрее:

  • меньше React overhead;
  • меньше reconciliation;
  • меньше component lifecycle.

Flame Graph Analysis

Как читать flame graph

Ширина блока:

  • показывает время выполнения.

Высота:

  • глубину стека вызовов.

Поиск bottleneck

Если значительную ширину занимает:

  • IntlMessageFormat;
  • formatToParts;
  • PluralRules;

значит проблема связана с i18n.


Микрооптимизации

Избегание formatToParts

intl.formatDate(date)

обычно быстрее:

intl.formatDateToParts(date)

Снижение количества plural-сообщений

Простые сообщения дешевле сложных ICU-шаблонов.


Уменьшение количества provider’ов

Плохо:

<IntlProvider>
  <Page />
</IntlProvider>

внутри большого числа поддеревьев.


Регрессии производительности

Автоматические performance tests

Performance-тесты должны входить в CI.


Проверка деградации

expect(duration).toBeLessThan(50)

Стабильность результатов

Медиана вместо единичного измерения

Правильно:

const samples = []

Собираются десятки измерений.


Исключение выбросов

GC и фоновые процессы могут создавать аномальные значения.


Сравнение браузеров

Различия движков

Производительность Intl различается:

Движок Особенности
V8 Очень быстрый JIT
SpiderMonkey Хорошая работа с ICU
JavaScriptCore Отличается поведением GC

Практический чеклист оптимизации

Наиболее эффективные меры

  1. Использование createIntlCache.
  2. Предкомпиляция сообщений.
  3. Lazy loading локалей.
  4. Минимизация rerender.
  5. Стабилизация props.
  6. Использование memoization.
  7. Условная загрузка polyfills.
  8. Профилирование production build.
  9. Ограничение размера кэшей.
  10. Использование imperative API в hot paths.

Когда оптимизация действительно нужна

FormatJS редко становится узким местом в небольших приложениях.

Оптимизация особенно важна при:

  • enterprise UI;
  • аналитических dashboard;
  • SSR под высокой нагрузкой;
  • больших таблицах;
  • сложных ICU-шаблонах;
  • множестве локалей;
  • мобильных устройствах;
  • low-end CPU.