356. Управление темпом кадров по абсолютным дедлайнам

Заменить относительное управление темпом через sleep на абсолютные дедлайны 60 FPS.

Урок 356 из 356 · tests/chapter_14_optimizations/test_356_absolute_deadline_frame_pacing.py

Файл для обновления

main.py

Ожидаемое поведение при ручной проверке после этого шага

При запуске с PyPy и легальным локальным ROM Super Mario Bros. начальная скорость может быть ниже целевой, пока PyPy прогревает часто выполняемые пути. Примерно через 30 секунд геймплей должен стать плавным и оставаться близким к долгосрочной цели 60 FPS на машине с достаточным запасом производительности.

Зачем нужен этот шаг

Каждая итерация цикла фронтенда продвигает один полный эмулируемый кадр. Если цикл работает быстрее предполагаемой частоты отображения, геймплей, анимация и обработка ввода идут слишком быстро; если задержки управления темпом слишком велики на каждом кадре, вся эмулируемая машина работает слишком медленно. Поэтому фронтенд должен выравнивать завершённые кадры по стабильной временной шкале реального времени, а не просто добавлять приблизительную задержку после каждого кадра.

Относительное управление темпом вычисляет каждую задержку на основе длительности текущего кадра. Превышение таймера и задержка планировщика смещают начальную точку следующего кадра, поэтому малые ошибки могут накапливаться как временной дрейф. Абсолютный дедлайн, напротив, закрепляет за каждым кадром позицию в непрерывном расписании 60 Гц. Кадр, завершившийся рано, ожидает; слегка запоздавший кадр сохраняет установленную временную шкалу; задержка на один полный кадр или более сбрасывает расписание, чтобы фронтенд не пытался выполнить неограниченный пакет наверстывания.

При 60 FPS последовательные запланированные кадры разделены примерно 16,67 мс. Эта политика управляет реальной скоростью вывода, оставляя детерминированные переходы CPU, PPU и рендеринга независимыми от часов хоста.

Имена и обязанности

- No function is renamed.
- NES_NTSC_FPS is replaced by TARGET_DISPLAY_FPS because this frontend policy now
  targets an integer 60 Hz display schedule.
- TARGET_FRAME_SECONDS keeps its name, but is derived from TARGET_DISPLAY_FPS.
- frame_start_time, frame_end_time, frame_elapsed_time, and wait_time are removed;
  they belong to the superseded relative-delay implementation.
- next_frame_deadline is new mutable frontend scheduling state. It stores one
  absolute timestamp and advances by exactly one target duration per frame.

Полные изменения main.py:

# --- DELETED BLOCK: APPROXIMATE NTSC FRAME TARGET ---
# NES_NTSC_FPS = 60.0988
# TARGET_FRAME_SECONDS = 1.0 / NES_NTSC_FPS
# --- END DELETED BLOCK ---

# --- NEW BLOCK: 60 FPS ABSOLUTE FRAME TARGET ---
TARGET_DISPLAY_FPS = 60
TARGET_FRAME_SECONDS = 1.0 / TARGET_DISPLAY_FPS
# --- END NEW BLOCK ---

...

running = True
# --- NEW LINE: FIRST ABSOLUTE FRAME DEADLINE ---
next_frame_deadline = time.perf_counter() + TARGET_FRAME_SECONDS
# --- END NEW LINE ---
last_fps_report_time = time.perf_counter()
frames_since_last_report = 0

while running:
    # --- DELETED LINE: RELATIVE FRAME START ---
    # frame_start_time = time.perf_counter()
    # --- END DELETED LINE ---

    ...

    executed = console.step_until_next_frame()
    framebuffer = console.render_framebuffer()
    draw_framebuffer(window, framebuffer, SCALE)
    pygame.display.flip()

    # --- DELETED BLOCK: RELATIVE REMAINING-TIME SLEEP ---
    # frame_end_time = time.perf_counter()
    # frame_elapsed_time = frame_end_time - frame_start_time
    # wait_time = TARGET_FRAME_SECONDS - frame_elapsed_time
    #
    # if wait_time > 0:
    #     time.sleep(wait_time)
    # --- END DELETED BLOCK ---

    # --- NEW BLOCK: WAIT FOR AND ADVANCE THE ABSOLUTE DEADLINE ---
    while time.perf_counter() < next_frame_deadline:
        pass

    now = time.perf_counter()
    next_frame_deadline += TARGET_FRAME_SECONDS

    # Abandon a large backlog instead of creating a catch-up spiral.
    if now - next_frame_deadline >= TARGET_FRAME_SECONDS:
        next_frame_deadline = now + TARGET_FRAME_SECONDS
    # --- END NEW BLOCK ---

    ...

Как абсолютный дедлайн стабилизирует тайминг кадров

Пусть длительность одного целевого кадра T = 16,67 мс.

Fast frame:
    current deadline = 16.67 ms
    work finishes    = 14.00 ms
    wait             =  2.67 ms
    next deadline    = 16.67 + T = 33.34 ms

Slightly late frame:
    current deadline = 33.34 ms
    work finishes    = 34.67 ms
    lateness         =  1.33 ms
    next deadline    = 33.34 + T = 50.01 ms

Запоздавший кадр не сдвигает установленную временную шкалу. Следующий кадр начинается без дополнительного ожидания и имеет до 50,01 мс, чтобы компенсировать предыдущую задержку в 1,33 мс. Если его работа завершается в 48,67 мс, он ждёт до 50,01 мс и снова синхронизирован.

Именно это добавление сохраняет временную шкалу

next_frame_deadline += TARGET_FRAME_SECONDS

Оно продвигается от предыдущего запланированного дедлайна. Замена на next_frame_deadline = now + TARGET_FRAME_SECONDS после каждого кадра сохраняла бы каждую малую задержку и вызывала долгосрочный временной дрейф.

Severely late frame:
    current deadline           = 50.00 ms
    work finishes              = 90.00 ms
    normally advanced deadline = 66.67 ms
    remaining backlog         = 23.33 ms

Новый запланированный дедлайн теперь отстаёт от текущего времени более чем на один полный кадр. Его сохранение приведёт к повторным немедленным кадрам наверстывания, поэтому сброс отказывается от этого устаревшего задела:

next_frame_deadline = now + TARGET_FRAME_SECONDS

Новый дедлайн становится 106,67 мс. Таким образом, небольшие опоздания компенсируются в рамках существующего расписания, тогда как значительные опоздания запускают новое расписание вместо неограниченной спирали наверстывания.

Абсолютный дедлайн предотвращает относительный временной дрейф. Небольшие опоздания кадров могут быть компенсированы в рамках существующего расписания; отставание на один полный кадр или более сбрасывает дедлайн и предотвращает неограниченную спираль наверстывания.

Важные инварианты

  • управление темпом по реальному времени остаётся политикой фронтенда
  • эмуляция, рендеринг и вывод происходят до ожидания
  • подсчёт FPS происходит после ожидания
  • дедлайны продвигаются от предыдущего дедлайна, а не от момента завершения каждого быстрого кадра
  • задержка на один полный кадр или более сбрасывает расписание
  • относительное управление темпом через time.sleep не остаётся

Компромисс

Активное ожидание позволяет избежать грубого поведения пробуждения при коротких системных задержках, но потребляет больше процессорного времени и энергии, пока кадр завершён раньше срока. Оно улучшает точность тайминга на системе общего назначения; оно не превращает фронтенд в систему жёсткого реального времени и не заставляет кадр, превысивший бюджет, завершиться быстрее.

Распространённое заблуждение

Управление темпом кадров — это не оптимизация производительности. Оно не может поднять медленное выполнение до 60 FPS; оно лишь предотвращает продвижение эмулируемого времени слишком быстро при достаточно быстром выполнении и ограничивает долгосрочный временной дрейф.

Запустить этот урок

uv run pytest tests/chapter_14_optimizations/test_356_absolute_deadline_frame_pacing.py -v