354. 缓存优化背景不透明度掩码

以缓存重复的背景不透明度掩码构建作为第 14 章的开篇。

354 / 356 · tests/chapter_14_optimizations/test_354_cache_optimization_background_opaque_mask.py

待更新文件

emulator/rendering/nametable_renderer.py

为什么需要这一小项优化

视口渲染器和 sprite-zero-hit 的准备工作可能会针对相同的图案表和名称表字节请求不透明度掩码。反复构建该掩码需要解码 256 个 CHR 图块,并遍历 256x240 背景中的每一个像素。

LRU 缓存会存储最近计算出的结果,并在达到大小上限时淘汰最久未使用的条目。这里,确切的不可变输入字节就是缓存键:

same pattern bytes + same nametable bytes -> cache hit
changed pattern bytes or nametable bytes  -> cache miss

简便的实现方式是对边界做一次小改动,而不是重写渲染逻辑:

from functools import lru_cache

# This is our old build_background_opaque_mask implementation under a private
# name. It now returns a tuple so callers cannot mutate the cached value. A
# public wrapper below will preserve the historical mutable list API.
@lru_cache(maxsize=8)
def _cached_background_opaque_mask(
    pattern_table: bytes,
    nametable: bytes,
) -> tuple[bool, ...]:
    if len(nametable) != NAMETABLE_SIZE:
        raise ValueError("Nametable must be 960 bytes")

    decoded_tiles = decode_pattern_table(pattern_table)
    opaque_mask: BackgroundOpaqueMask = [False] * (
        BACKGROUND_WIDTH * BACKGROUND_HEIGHT
    )

    for tile_y in range(NAMETABLE_ROWS):
        for tile_x in range(NAMETABLE_TILES_PER_ROW):
            nametable_index = (
                tile_y * NAMETABLE_TILES_PER_ROW + tile_x
            )
            tile_index = nametable[nametable_index]
            tile = decoded_tiles[tile_index]

            for pixel_y in range(CHR_TILE_HEIGHT):
                for pixel_x in range(CHR_TILE_WIDTH):
                    color_index = tile[pixel_y][pixel_x]

                    screen_x = tile_x * CHR_TILE_WIDTH + pixel_x
                    screen_y = tile_y * CHR_TILE_HEIGHT + pixel_y
                    mask_index = (
                        screen_y * BACKGROUND_WIDTH + screen_x
                    )

                    opaque_mask[mask_index] = color_index != 0

    return tuple(opaque_mask)


def build_background_opaque_mask(
    pattern_table: bytes,
    nametable: bytes,
) -> BackgroundOpaqueMask:
    return list(_cached_background_opaque_mask(pattern_table, nametable))

为什么要缓存元组却返回列表?functools.lru_cache 返回的正是所存储的那个对象本身,而不会进行复制。如果直接缓存那个公共的可变列表,某个调用方修改结果后,后续调用方观察到的结果也会被修改。私有元组使缓存状态保持不可变,而公共的列表副本则保留了历史上的 BackgroundOpaqueMask API 以及独立的所有权。

这个测试检查了一个完整的契约

  • 内容相同时,昂贵的解码只执行一次
  • 公共结果内容相等,但是不同的列表对象
  • 修改一个结果不会污染之后的缓存命中
  • 名称表内容变化会导致缓存未命中
  • 缓存被限制在八个条目以内

常见误解

该优化并非基于对象身份或帧编号。两个内容相同但对象不同的 bytes 对象会被视为相同的缓存键,而变化后的图形数据自然会形成不同的键,无需 PPU 发出显式的失效信号。

运行本课

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