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