356. 绝对截止时间的帧节奏控制

用绝对的 60 FPS 截止时间取代相对的 sleep 节奏控制。

356 / 356 · tests/chapter_14_optimizations/test_356_absolute_deadline_frame_pacing.py

待更新文件

main.py

此步骤之后预期的手动行为

使用 PyPy 并搭配一份合法的本地超级马里奥兄弟 ROM 启动时,在 PyPy 对其频繁执行的路径进行预热的过程中,启动初期的运行速度可能会低于目标值。大约 30 秒后,游戏画面应该变得流畅,并在处理能力充裕的机器上长期保持接近 60 FPS 的目标水平。

为什么需要这一步

前端循环的每次迭代都会推进一个完整的模拟帧。如果循环运行速度快于预定的显示速率,游戏、动画和输入的推进就会过快;如果节奏控制在每一帧之后延迟过多,整台被模拟的机器运行就会过慢。因此,前端需要将已完成的帧与一条稳定的挂钟时间线对齐,而不是仅仅在每一帧之后添加一个近似的延迟。

相对节奏控制根据当前帧的耗时来计算每一次延迟。计时器超调和调度延迟会因此移动下一帧的起始点,使得微小的误差可能累积成时序漂移。而绝对截止时间则会为每一帧在一条持续的 60 Hz 时间表上分配一个位置。提前完成的帧会等待;略微迟到的帧会保持既定的时间线;而至少落后一个完整帧的延迟则会重置该时间表,从而避免前端尝试进行无界限的追赶式冲刺。

在 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 节奏控制

权衡

忙等待避免了操作系统短时 sleep 那种粗粒度的唤醒行为,但在帧提前完成时会消耗更多的 CPU 和能耗。它在通用系统上提高了时序精度,但并不会把前端变成硬实时系统,也不会让超出预算的帧运行得更快。

常见误解

帧节奏控制不是一种性能优化。它无法把缓慢的执行提升到 60 FPS;它只能防止足够快的执行让模拟时间推进得过快,并限制长期的时序漂移。

运行本课

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