Как мы ускорили 9-мегабайтный WebAssembly-модуль, не убрав ни байта

11 мин

Recraft Studio — это веб-приложение, куда люди обычно приходят сгенерировать пачку картинок (и не только) и удобно поработать с ними на канвасе. Сам канвас живёт на самой важной и загруженной странице — странице проекта (а-ля редактор, эдитор и т. д.). Это огромная страница с кучей разных тулов, и в какой-то момент мы упёрлись в проблему: редактор долго грузится.

Четыре пул-реквеста спустя редактор грузится на 15–19% быстрее, причём одинаково на p50, p75 и p90.

На это ушло около 300 строк кода!

Самое интересное для меня — то, чего эти строки не сделали. Страница качает ровно те же байты, что и раньше: 16,75 МБ тогда, 16,71 МБ сейчас.

Ничего не удалили и ничего не сжали. Изменилось только одно: момент, когда начинает грузиться самый большой файл. Этот файл — наш движок рендеринга: собственный форк Skia, скомпилированный в WebAssembly, 9,2 МБ по сети.

И ускорение я взял не с графика «до/после».

Изменения прошли как полноценный A/B-тест с контрольной группой: около 18 тысяч пользователей в каждой группе, одни и те же пять дней.

Воронка показала 8,5 секунды на p75

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

Открыл Amplitude, собрал несколько графиков и удивился (неприятно, к сожалению). Около 8,5 секунды на p75 до момента, когда пользователь сможет сделать что-то полезное в редакторе. Никто не жаловался, инцидентов не было, просто часть людей привыкла, а часть отвалилась из-за «тормозов».

Вот что я измеряю:

  • loading-state-shown — время до показа лоадера
  • websocket-connected — время до соединения с нашим realtime-хранилищем
  • room-data-synced — время до получения данных проекта
  • skia-loaded — время до загрузки WASM-модуля
  • engine-initialized — колбэк, который срабатывает, когда всё на месте: JS-чанки, WASM-модуль, всё остальное, и движок доступен в React
  • canvas-rendered — время до первой отрисованной сцены на канвасе
  • images-loaded — время до загрузки всех изображений проекта

Одна оговорка, чтобы правильно читать цифры: canvas-rendered и engine-initialized иногда идут как будто не в том порядке. Первая сцена рисуется вне React, а engine-initialized отправляется из useEffect. То есть событие срабатывает, когда до него доходит React, а не когда движок на самом деле готов.

Данные из Amplitude

Это была катастрофа.

Самый большой файл начинаем загружать позже, чем хотелось бы

Расскажу, как редактор грузился раньше.

Сначала, конечно, HTML и пачка JS-чанков. Когда они распарсены, происходит авторизация, подключение к realtime-хранилищу и ожидание, пока придут данные проекта. Потом запрашиваем базовые стили: без них редактор не работает. И только в самом конце отправляем запрос за WASM-модулем.

А как вы догадываетесь, он весьма огромный.

Но как мы тут оказались? Всё просто: импорт WASM-модуля лежит в ленивом чанке. Значит, браузер ничего не знает о его существовании, пока не загрузит и не распарсит кучу JS и не дождётся ответа со стилями.

Выглядело это, честно говоря, ужасно:

Профиль до оптимизации

Сразу отвечу на очевидные вопросы вида:

  • 9,2 МБ — это слишком дорого компилировать.
  • 9,2 МБ — это просто слишком много.

Первое не проблема. Браузер начинает компилировать файл, как только получает первый байт: компиляция идёт потоково, параллельно со скачиванием, а повторная инициализация из кэша почти бесплатная. Это проблема не процессора, а сети.

Второе — реальная проблема, и о том, как мы её решили, я расскажу в следующей статье.

9 МБ, о которых браузер узнаёт слишком поздно

WASM-модуль — самый тяжёлый ресурс на странице, и его URL известен ещё до того, как выполнится первая строка нашего JS. Его можно было бы запросить сразу вместе с HTML. Но вместо этого запрос уходит только после трёх последовательных обращений к сети и обхода дерева React. Сейчас покажу, почему так получилось.

export const EngineProvider: FC<PropsWithChildren<Props>> = ({...}) => {
  ...
  if (room.getStorageSnapshot() === null) {
    room.connect()
    throw room.getStorage()
  }
  ...
}

Это первая часть проблемы. Здесь мы подключаемся к realtime-хранилищу и уходим в Suspense, пока не придут данные проекта. Всё, что находится ниже этого компонента, для браузера пока не существует.

Если копнуть глубже, найдётся ещё одна странность:

const Content: FC<Props> = ({ viewOnly = false }) => {
  ...
  useConnectionDetect()
  useLoadBasicStyleSuspense()
  useGetCurrentUserStatistics()
  ...
}

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

А ещё ниже находится вот это:

export const Stage = ({ children, ...props }: Props) => {
  useKitInit()
  useFontInit()
  useInitSkiaWorker()

  useProjectLoadTracker()(ProjectLoadStep.SkiaLoaded)

  return <StageComponent {...props}>{children}</StageComponent>
}

Два хука подряд. Первый уходит в Suspense, поэтому второй просто не запускается. Получается, что мы ждём, пока загрузится 9,2-мегабайтный WASM-модуль, и только после этого начинаем запрашивать шрифт.

Теперь картина целиком:

  1. Парсинг стартовых JS-чанков
  2. Ожидание подключения к хранилищу проекта
  3. Ожидание базовых стилей
  4. Загрузка и инициализация CanvasKit
  5. Ожидание шрифтов
  6. Отрисовка первой сцены на канвасе

Шесть шагов, хотя URL был известен с самого начала.

Preload-подсказка и один вызов на уровне модуля

Теперь расскажу, как я решил это исправить.

Я начал с прелоада, это первое, что пришло мне в голову. Если мы знаем URL всех наших ресурсов, почему бы не вставить preload-ссылки в head страницы?

export const SkiaPreloadLinks = () => (
  <NextHead>
    {PRELOAD_HREFS.map(({ href, as, type, crossOrigin }) => (
      <link
        key={href}
        rel="preload"
        href={href}
        as={as}
        type={type}
        crossOrigin={crossOrigin}
      />
    ))}
  </NextHead>
)

Где PRELOAD_HREFS — это:

const PRELOAD_HREFS: PreloadHint[] = [
  {
    href: `${canvaskitUrl}/canvaskit.wasm`,
    as: 'fetch',
    crossOrigin: canvaskitUrl !== '' ? 'anonymous' : undefined,
  },
  {
    href: DEFAULT_FONT_PRELOAD_URL,
    as: 'fetch',
    crossOrigin: 'anonymous',
  }
]

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

Здесь есть одна деталь. DEFAULT_FONT_PRELOAD_URL — захардкоженная строка, и выглядит это некрасиво. Но если импортировать FontCache, чтобы получить URL по-нормальному, в бандл страницы проекта приехал бы fonts.json на 652 КБ. То есть прелоад съел бы собственный выигрыш.

Дальше я вынес вызов инициализации Skia на уровень модуля:

import { getInitialPresence, getInitialStorage } from '@utils/engine'
import { useProjectLoadTracker } from './hooks/useProjectLoadTracker'

initSkia()

type Props = {
  viewOnly?: boolean
}

initSkia() запускает initKit() и initFont() параллельно и не дожидается их. Помните два хука в Stage, где первый уходил в Suspense, а второй так и не запускался? Теперь никто никого не ждёт.

Из-за этого пришлось переписать FontCache.initFont(). Раньше функция бросала промис для Suspense, а теперь это идемпотентный мемоизированный промис. Иначе ранний прогрев и путь через Suspense дрались бы за один и тот же fetch.

И последнее: запрос стилей я поднял как можно выше. Не в режиме Suspense: просто отправляем запрос, а обрабатываем его ниже по дереву React.

const Project = () => {
  useLoadBasicStyle()

  const t = useTranslations()
  ...
}

Четыре строки. К моменту, когда мы доходим до хука с Suspense, ответ либо уже в кэше, либо вот-вот придёт. В локальном трейсе этот запрос сдвигается примерно с 890 мс до 15 мс. Это примерные цифры с моей машины, а не с продакшена.

После оптимизации

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

Где я ошибся: preload vs prefetch

Сначала я упустил одну важную вещь: изменения выкатились сразу на две страницы.

  • /project/* — страница редактора;
  • /projects — список проектов, точка входа на страницу проекта.

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

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

К сожалению, в первой версии я юзал preload, и на странице списка это привело к драке за ресурсы. -_- Справедливости ради, исправление оказалось достаточно тривиальным: prefetch на странице проектов и preload на странице редактора.

const Links = ({ rel }: { rel: 'preload' | 'prefetch' }) => {
  ...
  return (
    <NextHead>
      {resourceHints.map(({ href, as, type, crossOrigin }) => (
        <link
          key={href}
          rel={rel}
          href={href}
          as={as}
          crossOrigin={crossOrigin}
          type={type}
       />
      ))}
    </NextHead>
  )
}

export const SkiaPreloadLinks = () => <Links rel="preload" />
export const SkiaPrefetchLinks = () => <Links rel="prefetch" />

Эти изменения натолкнули меня на мысль о прогреве кэша JS-чанков при наведении на карточку проекта…

Три гипотезы из четырёх сработали

Что ж, карты на стол. Я запустил эксперимент с двумя группами: default и on.

Смотрим на цифры.

Шаги загрузки проекта, default → on:

Шагp50p75p90
Skia Loaded−19%−18%−19%
Engine Initialized−17%−16%−15%
Canvas Rendered−18%−17%−17%
Images Loaded−16%−16%−18%

Чем пришлось заплатить — loading-state-shown, мс:

defaultonΔ
p50409429+20 (+5%)
p7512501373+123 (+10%)
p9037984132+335 (+9%)

Как видно по таблице, мы улучшили несколько ключевых метрик. Для нас самые важные из них — skia-loaded, engine-initialized и canvas-rendered: именно в эти моменты пользователь может начать что-то делать на канвасе. Больше всего полегчало самым медленным ребятам: выигрыш растёт примерно с 670 мс на медиане до 1900 мс на p90. Быстрее стали все, конечно. Но медленные тут выиграли в лотерею.

Например, вот так выглядит skia-loaded, когда я включил эксперимент на всех:

Skia loaded

Но на loading-state-shown мы немного просели, потому что на старте страницы стали грузить больше ресурсов. Это около 120 мс на p75, и просадка коснулась всех пользователей, независимо от устройства.

Три гипотезы из четырёх сработали, одна — нет. Я предполагал, что если поднимать сокет раньше, тайминги станут лучше. Что ж, я ошибся: боттлнеком было не соединение. Позже мой коллега Леонид решил это по-другому, и у него сработало, но это уже другая история.

Новый боттлнек: авторизация, подключение и данные проекта

Что ж, посмотрим, что именно происходит теперь, после оптимизации.

Боттлнек переехал

Видно, что CanvasKit начинает грузиться первым. То есть он загружается и просто лежит, пока не придёт код редактора и не начнёт его использовать.

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

Ещё видно, что критичные для отрисовки данные тоже начинают грузиться гораздо раньше. Теперь мы не ждём просто так WASM-чанк!

Причём большая часть этого ожидания — вообще не работа процессора: главный поток почти всё время простаивает.

Самый тяжёлый ресурс страницы больше не находится на критическом пути.

В итоге боттлнек переехал, и теперь это сеть.

Префетч по ховеру: 631 → 193 мс до лоадера

Йоло! Вернёмся к прогреву кэша, о котором я говорил выше.

Раз уж мы просели на loading-state-shown, я решил это отыграть.

Во-первых, code splitting. Некоторые компоненты редактора в принципе не могут быть видны на первом кадре. У нас куча разных сайдбаров. Например, панель мокапов появляется только тогда, когда выделен мокап-слой. При инициализации выделение пустое, значит, этой панели там быть не может. Незачем тащить её в первый чанк.

Во-вторых, префетч. Можно начинать грузить чанк редактора, когда пользователь наводит курсор на карточку проекта: пока он дойдёт до клика, файл уже будет в пути. Стоит сказать, что Next.js и так это делает, но не до конца. В их логике есть нюанс: префетчатся только чанки, известные из самого роута, а вложенные динамические импорты внутри модуля не учитываются. Это как раз наш случай: чанк редактора подтягивает ещё один динамический чанк. Поэтому я сделал это сам, примерно так:

export const usePrefetchEditorOnHover = () => {
  const called = useRef(false)

  return useCallback(() => {
    if (called.current) {
      return
    }

    called.current = true
    void import('@/components/pages/Editor')
  }, [])
}

const handlePrefetchEditor = usePrefetchEditorOnHover()

const handleEnter = useCallback(() => {
  setHover(true)
  handlePrefetchEditor()
}, [handlePrefetchEditor])

onMouseEnter={handleEnter}

Code splitting выкатился на всех, а под флагом был только префетч. Так что эксперимент измеряет только прогрев по ховеру и ничего больше.

В результате получили огромный прирост:

loading-state-shown, default → on, мс:

defaultonΔ
p5023936−85%
p75631193−69%
p901680708−58%

Улучшения loading-state-shown

В марте мы пожертвовали 123 мс на этой метрике, чтобы ускорить канвас. В апреле вернули 438 мс — справедливость восстановлена.

Что я из этого вынес

Что ж, пора заканчивать. Пара полезных выводов.

Собирать метрики капец как важно. Если хотите понимать свой продукт, как он работает и как на самом деле ведёт себя у пользователей, подумайте, какие сценарии и страницы наиболее необходимы. Это поможет найти слабые места в перформансе.

Это хорошо сработало для нас. Простенькая задачка про метрики натолкнула меня на мысль, что мы где-то что-то делаем не так.

В итоге после нескольких фиксов мы доставляем тот же объём данных на 17% быстрее.

Неплохо для 300 строк, которые не удалили ни байта.

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

Что касается размера WASM-модуля: там я тоже много чего улучшил и с радостью расскажу, как это было, в следующей статье.