Профилирование по времени¶
В этой главе мы улучшим производительность нашей реализации «Игры жизни». Будем опираться на замеры времени (time profiling), чтобы не действовать вслепую.
Перед продолжением ознакомьтесь с доступными инструментами для профилирования по времени кода на Rust и WebAssembly.
Счётчик FPS на основе window.performance.now¶
Такой счётчик кадров в секунду пригодится, когда будем разбираться, как ускорить отрисовку «Игры жизни».
Начнём с добавления объекта fps в wasm-game-of-life/www/index.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | |
Далее вызываем fps.render() на каждой итерации renderLoop:
1 2 3 4 5 6 7 8 9 | |
Не забудьте добавить элемент fps в wasm-game-of-life/www/index.html чуть выше <canvas>:
1 | |
И CSS, чтобы вывод выглядел аккуратно:
1 2 3 4 | |
Готово! Обновите http://localhost:8080 — на странице появится счётчик FPS.
Замер каждого Universe::tick через console.time и console.timeEnd¶
Чтобы измерить длительность каждого вызова Universe::tick, можно использовать console.time и console.timeEnd из крейта web-sys.
Сначала добавьте web-sys в зависимости wasm-game-of-life/Cargo.toml:
1 2 3 4 5 | |
На каждый вызов console.time должен приходиться соответствующий console.timeEnd, поэтому удобно обернуть оба вызова в тип с паттерном RAII:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
Затем можно замерить Universe::tick, добавив в начало метода:
1 | |
Длительность каждого вызова Universe::tick теперь попадает в консоль:
Снимок экрана: логи console.time
Кроме того, пары console.time / console.timeEnd отображаются во «временной шкале» или «водопаде» в профилировщике браузера:
Снимок экрана: console.time в профилировщике
Увеличиваем вселенную «Игры жизни»¶
⚠️ В этом разделе для примеров используются скриншоты из Firefox. В других браузерах инструменты похожи, но названия и вид панелей могут чуть отличаться. Суть профиля будет той же, а вот удобство отдельных представлений может различаться.
Что будет, если сделать вселенную больше? Если заменить сетку 64×64 на 128×128 (изменив Universe::new в wasm-game-of-life/src/lib.rs), на моей машине FPS падает с ровных 60 до неровных ~40.
Если записать профиль и открыть вид «водопада», видно, что кадр анимации занимает больше 20 миллисекунд. При 60 FPS на весь кадр остаётся около шестнадцати миллисекунд — и это не только наш JavaScript и WebAssembly, но и остальная работа браузера, в том числе отрисовка.
Снимок экрана: водопад отрисовки кадра
Если разобрать один кадр анимации, оказывается, что сеттер CanvasRenderingContext2D.fillStyle очень дорогой!
⚠️ В Firefox, если вместо
CanvasRenderingContext2D.fillStyleвы видите строку просто «DOM», включите в настройках инструментов производительности опцию «Show Gecko Platform Data»: Включение Show Gecko Platform Data
Снимок экрана: flame graph отрисовки кадра
На агрегированном дереве вызовов по многим кадрам видно, что это не случайность:
Снимок экрана: call tree отрисовки кадра
Почти 40% времени уходит на этот сеттер!
⚡ Можно было ожидать, что узким местом окажется что-то в методе
tick, но это не так. Всегда опирайтесь на профилирование: время часто тратится не там, где вы его ждёте.
В функции drawCells в wasm-game-of-life/www/index.js свойство fillStyle задаётся для каждой клетки вселенной на каждом кадре анимации:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Раз смена fillStyle так дорога, как реже её вызывать? Значение должно зависеть от того, жива клетка или мёртва. Если выставить fillStyle = ALIVE_COLOR и в одном проходе нарисовать все живые клетки, затем fillStyle = DEAD_COLOR и во втором проходе — все мёртвые, fillStyle меняется только дважды, а не на каждую клетку.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | |
После сохранения и обновления http://localhost:8080/ отрисовка снова держит ровные 60 FPS.
По новому профилю видно, что кадр теперь занимает около десяти миллисекунд.
Снимок экрана: водопад после изменений в drawCells
В разборе одного кадра стоимость fillStyle исчезла, большая часть времени уходит на fillRect — прямоугольники клеток.
Снимок экрана: flame graph после изменений в drawCells
Больше тиков за кадр¶
Кому-то не хочется ждать: хочется не один тик вселенной за кадр анимации, а девять. Функцию renderLoop в wasm-game-of-life/www/index.js можно изменить так:
1 2 3 | |
На моей машине FPS снова падает примерно до 35 — плохо, хочется плавные 60.
Теперь ясно, что время уходит в Universe::tick: добавим несколько Timer с вызовами console.time / console.timeEnd в разных местах и посмотрим. Гипотеза: выделение нового вектора клеток и освобождение старого на каждом тике дороги и съедают заметную долю бюджета.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | |
По замерам видно, что гипотеза неверна: подавляющее время уходит на вычисление следующего поколения клеток. Выделение и освобождение вектора на каждом тике, к удивлению, почти ничего не стоят. Ещё один повод всегда сверяться с профилем.
Снимок экрана: таймеры внутри Universe::tick
Для следующего фрагмента нужен компилятор nightly из‑за встроенного бенчмарка. Ещё понадобится утилита cargo benchcmp — небольшой инструмент для сравнения микробенчмарков из cargo bench.
Напишем нативный #[bench], делающий то же, что и WebAssembly, но уже со зрелыми средствами профилирования. Новый файл wasm-game-of-life/benches/bench.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Также придётся закомментировать все аннотации #[wasm_bindgen] и секцию "cdylib" в Cargo.toml, иначе сборка нативного кода завершится ошибками линковки.
После этого выполните cargo bench | tee before.txt, чтобы скомпилировать и прогнать бенчмарк. Часть | tee before.txt перенаправит вывод cargo bench в файл before.txt.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Из вывода видно, где лежит бинарник; тот же бенчмарк можно запустить под профилировщиком ОС. У меня Linux, поэтому использую perf:
1 2 3 4 5 6 7 8 | |
perf report показывает, что время, как и ожидалось, уходит в Universe::tick:
В perf можно разметить инструкции по доле времени, нажав a:
Снимок экрана: аннотация инструкций в perf
Так видно, что 26,67% времени уходит на суммирование значений соседних клеток, 23,41% — на получение индекса столбца соседа и ещё 15,42% — на индекс строки. Среди трёх самых дорогих инструкций две последние — это div. Эти деления реализуют логику индексации по модулю в Universe::live_neighbor_count.
Напомним определение live_neighbor_count в wasm-game-of-life/src/lib.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Модulo выбрали, чтобы не засорять код ветвлениями для первой/последней строки и столбца. Но цена инструкции div платится и в самом частом случае, когда ни строка, ни столбец не на границе и «заворот» по модулю не нужен. Если для краёв использовать if и развернуть цикл, ветки должны хорошо предсказываться блоком предсказания ветлений в CPU.
Перепишем live_neighbor_count так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | |
Снова запустите бенчмарк, на этот раз сохранив вывод в after.txt.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Намного лучше! Насколько именно — покажет benchcmp и два сохранённых файла:
1 2 3 | |
Ускорение примерно в 7,61 раза.
WebAssembly сознательно близок к распространённым архитектурам, но стоит убедиться, что выигрыш нативного кода переносится и на .wasm.
Пересоберите модуль командой wasm-pack build и обновите http://localhost:8080/. У меня снова стабильные 60 FPS, а профиль браузера показывает около десяти миллисекунд на кадр.
Готово!
Снимок экрана: водопад после замены modulo на ветвления
Упражнения¶
-
Следующий очевидный шаг для ускорения
Universe::tick— убрать выделение и освобождение памяти на каждом тике. Реализуйте двойную буферизацию: уUniverseдва вектора, ни один не освобождается, вtickновые буферы не выделяются. -
Реализуйте дельта-подход из главы «Реализация Life», когда Rust возвращает в JavaScript список изменившихся клеток. Ускоряет ли это отрисовку на
<canvas>? Получится ли обойтись без нового списка дельт на каждом тике? -
Профилирование показало, что отрисовка на 2D
<canvas>не самая быстрая. Замените рендер на WebGL. Насколько быстрее вариант на WebGL? До какого размера вселенной узким местом остаётся именно WebGL?