354. Кэш-оптимизация маски непрозрачности фона

Начать главу 14 с кэширования повторного построения маски непрозрачности фона.

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

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

emulator/rendering/nametable_renderer.py

Зачем нужна эта небольшая оптимизация

Viewport-рендерер и подготовка sprite-zero-hit могут запрашивать маску непрозрачности для одних и тех же байтов таблицы паттернов и nametable. Повторное построение этой маски декодирует 256 тайлов CHR и обходит каждый пиксель фона 256×240.

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 возвращает именно хранимый объект; он не делает копию. Кэширование публичного изменяемого списка напрямую позволило бы одному вызывающему модифицировать результат, наблюдаемый будущими вызывающими. Приватный кортеж делает кэшированное состояние неизменяемым, тогда как публичная копия списка сохраняет историческое API BackgroundOpaqueMask и независимое владение.

Этот тест проверяет один полный контракт

  • идентичное содержимое выполняет ресурсоёмкое декодирование только один раз
  • публичные результаты равны, но являются разными объектами-списками
  • мутация одного результата не может отравить последующее попадание в кэш
  • изменённое содержимое nametable вызывает промах кэша
  • кэш ограничен восемью записями

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

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

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

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