355. 缓存名称表帧缓冲区像素

按照确切的图形输入,缓存重复出现的名称表帧缓冲区像素。

355 / 356 · tests/chapter_14_optimizations/test_355_cache_nametable_framebuffer_pixels.py

待更新文件

emulator/rendering/nametable_renderer.py

为什么需要这一步

前端在每个显示的帧都会向模拟器请求一幅完整的背景图像。生成一幅逻辑名称表图像需要解码图案数据、从属性表中选择图块调色板,并写入 256x240 个 RGB 像素。滚动可以改变相邻名称表中可见的部分,却不改变任一名称表内部的源图形数据,因此连续的多个帧常常会请求完全相同的开销较大的源图像。

在 60 FPS 下,一个完整帧仅有大约 16.67 毫秒的时间,用于 CPU/PPU 步进、背景与精灵渲染、帧缓冲区上传以及呈现。从未发生变化的字节重新构建 61,440 个像素,会消耗这段有限的预算却不产生任何新信息。一个有界的、以内容寻址的缓存,可以把重复的源渲染转变为一次不可变像素查找加一次列表复制,从而为真正发生变化的工作留出更多帧时间。

名称与职责

- No existing function is renamed.
- nametable_with_attributes_to_framebuffer() remains unchanged. It is still the
  lower-level operation that performs the expensive render on a cache miss.
- nametable_with_palette_ram_to_framebuffer() keeps its public name and signature,
  but its body changes into an ownership-preserving wrapper around cached pixels.
- _cached_nametable_with_palette_ram_pixels() is a new private helper containing
  the old public function's palette-building and rendering work.

完整实现

# functools.lru_cache is already imported for Test 354. Add this import if the
# previous step is being reproduced independently:
from functools import lru_cache


@lru_cache(maxsize=8)
def _cached_nametable_with_palette_ram_pixels(
    nametable_bytes: bytes,
    attribute_table: bytes,
    pattern_table_bytes: bytes,
    palette_ram: bytes,
) -> tuple[RGBColor, ...]:
    background_palettes = build_background_palettes_from_palette_ram(
        palette_ram
    )

    framebuffer = nametable_with_attributes_to_framebuffer(
        nametable_bytes,
        attribute_table,
        pattern_table_bytes,
        background_palettes,
    )

    # The cache must own immutable data so callers cannot poison later hits.
    return tuple(framebuffer.pixels)


def nametable_with_palette_ram_to_framebuffer(
    nametable_bytes: bytes,
    attribute_table: bytes,
    pattern_table_bytes: bytes,
    palette_ram: bytes,
) -> Framebuffer:
    cached_pixels = _cached_nametable_with_palette_ram_pixels(
        nametable_bytes,
        attribute_table,
        pattern_table_bytes,
        palette_ram,
    )

    # Preserve the historical mutable ownership contract.
    return Framebuffer(
        width=BACKGROUND_WIDTH,
        height=BACKGROUND_HEIGHT,
        pixels=list(cached_pixels),
    )

编辑位置

用上面展示的包装函数替换 nametable_with_palette_ram_to_framebuffer() 现有的函数体,并在其旁边添加新的私有缓存辅助函数。不要在公共包装函数中保留原有的调色板构建/渲染语句,因为那样会在查询缓存之前就先执行开销较大的工作。

类型检查边界

nametable_with_attributes_to_framebuffer() 返回的是 Framebuffer,因此在 _cached_nametable_with_palette_ram_pixels() 中直接返回该调用结果,会违反其声明的 tuple[RGBColor, ...] 返回类型。应先像上面那样将 Framebuffer 绑定到局部变量,然后返回 tuple(framebuffer.pixels)。不要把这个缓存辅助函数的注解改成 Framebuffer,因为那样会缓存可变的公共状态。

重要不变量

  • 全部四个确切的不可变字节输入都参与缓存键的构成
  • 即使对象身份不同,内容相同的字节也会复用已有的工作结果
  • 改变任意一个图形输入都会导致缓存未命中
  • 缓存的像素是不可变的
  • 公共的 Framebuffer 对象与像素列表拥有各自独立的所有权
  • 缓存被限制在八个条目以内

性能影响

缓存命中可以避免图案解码、调色板重建、图块遍历以及 RGB 像素生成。缓存未命中会保留原始的渲染路径,因此改变任意视觉输入仍然会产生全新且正确的像素。大小上限限制了内存占用,同时保留了最近被复用过的名称表图像。

常见误解

不要缓存并返回同一个可变的 Framebuffer 实例。这里的加速机制在于复用不可变的像素内容,而不是共享可变帧的所有权。

运行本课

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