Для Playdate описали приёмы оптимизации C-кода: кэш, TCM и «лотерея»
На форуме разработчиков Playdate опубликован разбор «грязных» приёмов оптимизации на C. Автор (имя в доступном тексте не указано) нашёл их, пока вместе с @stonerl делал эмулятор Game Boy, работающий на полной скорости. Он предупреждает, что это не типовые советы по оптимизации игр и пригодятся они в основном там, где нужен очень быстрый код: эмуляторы, крупные симуляции, 3D-рендер, кодеки.\n\nБазовая модель такая: процессор Playdate очень быстрый, но медленно обращается к памяти, особенно к той, что не лежит в кэше. Автор ссылается на исследование @StiNKz, результаты которого выложены в Discord. Если код работает в основном с регистрами и мало читает данных, он летит, даже когда в нём много циклов и неверных предсказаний ветвлений.\n\nКэш инструкций. Флаг -O3 не обязательно самый быстрый, -Os может оказаться заметно лучше. Кэш инструкций у Playdate маленький: у ревизии Rev A всего 4 килобайта (у Rev B, по словам автора, «по-видимому, 16 КБ?»). Если ядро (цикл интерпретатора эмулятора, рендер фрагментов, физика) в него не помещается, оно, скорее всего, будет работать хуже более компактного кода, даже если тому нужно намного больше операций. Первым шагом для эмулятора Game Boy автор сжал его ядро с 20 КБ до 2 КБ: убрал гигантскую таблицу switch и выразил её малым числом ветвлений кода (плюс -Os). Поведение осталось тем же, но скорость заметно выросла. Редкие операции (почти не встречающиеся опкоды, маловероятные граничные случаи) можно вынести из ядра. Функции ядра стоит размещать подряд: для этого каждой задают attribute((section(".text.<имя>"))).\n\nСвой файл линкера. Файл link_map.ld копируют из папки C_API/buildsupport в проект и в makefile до подключения common.mk добавляют override LDSCRIPT=./link_map.ld. В нём можно указать, какой код и из каких файлов где лежит, и задать выравнивание по 32 байта через . = ALIGN(32). Команда nm Source/pdex.elf | sort > syms.txt даёт список символов с адресами, по которому видно, сколько занимает ядро и помещается ли оно в кэш.\n\nБыстрая память TCM. Это небольшая область памяти, более быстрая, чем обычная. Стек находится в ней, поэтому объект на стеке обычно быстрее объекта в куче или статической переменной. Если долго работать с одной структурой, её выгодно скопировать на стек через memcpy, поработать и скопировать обратно: @RPDev сделал так в PlayGB и получил большой прирост. Лучше вообще держать данные на стеке постоянно, но управления main нет, поэтому предлагается хранить их на другом конце стека, у младших адресов. Верхний адрес находят через __builtin_frame_address(0) в обработчике события kEventInit, затем вычитают меньше 10 КБ (автору хватило безопасного значения 0x2180). Область (dtcm_mempool) надо охранять «канарейками» в начале и конце и проверять их хотя бы раз за обновление. В TCM лежит и буфер кадра Playdate, но данные там будут видны на экране.\n\nКод в ITCM. Туда можно копировать и код. Для Rev B неясно, даёт ли это ускорение, нужны исследования, а на Rev A это «секретный соус» эффективной эмуляции Game Boy. Функции помечают макросом _itcm (section(".itcm") и short_call), в линкере окружают их символами __itcm_start и __itcm_end и копируют memcpy. Адрес назначения должен совпадать с исходным по чётности, потому что младший бит указателя на функцию отмечает режим Thumb, а копировать с адреса самой функции нельзя: он на байт больше её начала. Условия: не включать -fPIC (таблица перемещений ломает перенесённый код), использовать short_call внутри ITCM, помечать attribute((longcall)) все внешние функции, которые вызывает ITCM-код, и сбрасывать кэш инструкций после копирования (в API Playdate есть функция). Автор предупреждает: с первого раза скорее всего упадёт, поэтому начинать надо с одной функции, возвращающей число.\n\n«Лотерея производительности». При правках кода одни сборки необъяснимо оказываются быстрее других, иногда значительно (пример автора, на 50%). Он выделяет две причины. Первая: размер строки кэша инструкций 32 байта; добавленная в начале функция сдвигает остальные, и они могут начать пересекать границы строк кэша. Лекарство: выравнивать куски кода по 32 байта (aligned(32), ALIGN(32) в линкере, при необходимости со смещением, подобранным по последней удачной сборке; флаг -falign-loops=32 для циклов). Вторая: предсказание ветвлений. Как оно устроено в Cortex M7, не публиковалось; по оценке автора, которая может быть ошибочной, в таблице предсказаний 1024 уникальных адреса инструкций, и ветвления на расстоянии, кратном 1024 байтам, путаются. Поэтому в линкере рассыпают несколько . = ALIGN(1024); . += n с разными n от 0 до 1024 (это больше 4 КБ потерь, так что много не нужно). Автор оговаривается, что причина может быть и другой, но приём помог, и после всех этих мер лотерея почти исчезла.\n\nПредзагрузка (prefetching). Улучшений от неё автор не заметил, хотя, возможно, применяет её недостаточно умело. @FReDs72 описал случай, где она помогла (в Discord-обсуждении). Совет: измерять время кадра таймером высокой точности. Дальше доступный текст обрывается.
Ключевые факты
- Процессор Playdate (Cortex M7) очень быстрый, но медленно обращается к памяти вне кэша; кэш инструкций у Rev A всего 4 КБ (у Rev B, по словам автора, «по-видимому, 16 КБ?»).
- Сжатие ядра эмулятора Game Boy с 20 КБ до 2 КБ (убрана большая таблица switch, использован -Os) дало заметное ускорение при тождественном поведении.
- Стек и буфер кадра находятся в быстрой памяти TCM: копирование структуры на стек или хранение данных у младшего конца стека (смещение 0x2180 оказалось безопасным) ускоряет доступ; код можно запускать из ITCM, но легко получить сбой.
- «Лотерея производительности» (разброс скорости сборок, например на 50%) гасится выравниванием кода по 32 байта (размер строки кэша) и вставками ALIGN(1024) с разными смещениями; после этого разброс почти исчез.
- От предзагрузки (prefetching) автор улучшений не получил; все выводы основаны на его собственных наблюдениях, а не на опубликованных замерах.
Почему это важно
Для небольшой консоли Playdate привычные советы вроде «компилируйте с -O3» могут не работать: из-за крошечного кэша инструкций более компактный код способен обгонять быстрый на вид. Материал собирает в одном месте практические наблюдения, полученные при создании эмулятора Game Boy, работающего на полной скорости. Он показывает конкретные размеры (ядро с 20 КБ до 2 КБ), адреса и опции линкера, а не абстрактные рекомендации.
Кому это важно
Тем, кто пишет на C для Playdate ресурсоёмкий код: эмуляторы, крупные симуляции вроде игр в духе Factorio, 3D-рендер, кодеки. Автор сам оговаривает, что для обычных игр эти приёмы могут быть избыточны, а с базовыми техниками оптимизации лучше сначала познакомиться по другим источникам. Для остальных читателей материал интересен как пример низкоуровневой настройки встраиваемого процессора.
Как это применить
Порядок, который описывает автор: 1) попробовать -Os вместо -O3 и уместить горячее ядро в кэш инструкций (4 КБ на Rev A), разместив его функции подряд через атрибут section; 2) подключить собственный link_map.ld и командой nm Source/pdex.elf | sort > syms.txt проверять размер и адреса; 3) переносить часто используемые данные в стек или в область у младшего конца стека, ставя «канарейки» для контроля переполнения; 4) осторожно пробовать запуск кода из ITCM, начиная с одной тривиальной функции, без -fPIC, с short_call внутри и longcall для внешних функций и со сбросом кэша инструкций; 5) для борьбы с разбросом скорости выравнивать код по 32 байта и добавлять несколько вставок ALIGN(1024) с разными смещениями. Какую-либо плату или лицензию материал не упоминает.
Можно ли доверять
Это сообщение на форуме разработчиков, написанное практиком: выводы опираются на собственный опыт автора и на исследование @StiNKz в Discord, а не на опубликованные замеры. Число кадров или прирост для эмулятора в тексте не приводятся (есть лишь общие слова «намного быстрее», «большой прирост» и пример разброса «на 50%»). Автор сам помечает неуверенность: размер кэша Rev B со знаком вопроса, таблица на 1024 адреса в предсказателе ветвлений названа оценкой, где он «может быть совершенно неправ», а польза ITCM для Rev B неясна. Последний раздел (предзагрузка) пересказан по доступной части текста.
Риски и подводные камни
Запуск кода из ITCM легко приводит к сбоям: ошибка в выравнивании по чётности, неверный тип вызова (short_call или longcall) или забытый сброс кэша ломают программу. Хранение данных в области у младшего конца стека рискует переполнением стека, поэтому нужны «канарейки», а подобранное смещение 0x2180, это значение, которое оказалось безопасным у автора, а не гарантия. Данные в буфере кадра видны на экране. Вставки ALIGN(1024) тратят больше 4 КБ места, а выравнивание подбирается по последней удачной сборке, то есть остаётся эмпирической настройкой, зависящей от каждой правки кода.
«Модель процессора Playdate в вашей голове должна быть такой: это очень быстрый процессор, но медленно обращающийся к памяти, особенно к памяти вне кэша.»
— автор сообщения на форуме разработчиков Playdate