Files
RustNotMC/docs/animated-sprite-mips-benchmark.md
T
SashegDev 874b6f20c2 Клиент: начальный импорт кодовой базы
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
2026-10-29 22:04:28 +03:00

8.2 KiB

Animated sprite mip cache

animate_sprites retains full mip chains for previously displayed source frames. The renderer owns an immutable snapshot of animations, size and mip settings; resource reload replaces that snapshot and drops its cache. Mipmap options only change the sampled view, so the full chains stay valid. Frame selection, skipped unchanged frames, upload order and mip bytes are preserved.

Admission is lazy, with an 8 MiB limit on retained Vec capacities and a separate 4,096-entry limit on map metadata. The existing fixed integer hasher avoids consuming random seeds for cache bookkeeping. An admitted frame stays until resource reload; a frame that does not fit uses the original generation path. There is no eviction churn. These limits apply to the additional cache, excluding the existing source frames and the original generator's temporary allocations. Map/allocator overhead is additional and bounded by the entry limit.

Reproduce

Use prepared 26.3 resources at .local/horizon-client/26.3. All block/item textures are loaded, including static layers that set the shared array size (the 64px straw-bed texture). The Python driver compiles the production gfx.rs functions and actual sprite/mipmap loaders into a private Cargo workspace under .local/sprite-mips-bench; it does not build the rest of the client or expose a benchmark API in the renderer. --ref selects the baseline's actual gfx code; the sprite/mipmap loaders are unchanged by this optimization. Both use release optimization, LTO off, 16 codegen units.

Build first, then release the lock before the paired measurement session. Keep all measurement pairs in one lock acquisition so other heavy work cannot overlap:

flock /tmp/horizon-optimizations-20261015-heavy.lock bash -c '
  set -e
  export CARGO_BUILD_JOBS=2
  python3 tools/bench_sprite_mips.py --ref 89b58991bbc02eb79b6965fc4ca56357fe2297d9 --label baseline --build-only
  python3 tools/bench_sprite_mips.py --label candidate --build-only
'
flock /tmp/horizon-optimizations-20261015-heavy.lock bash -c '
  set -e
  export CARGO_BUILD_JOBS=2
  for round in 1 2 3; do
    variants="baseline candidate"
    if [ "$round" = 2 ]; then variants="candidate baseline"; fi
    for variant in $variants; do
      python3 -c "import resource, subprocess, sys; subprocess.run(sys.argv[1:], check=True); print(\"max_rss_kib=\" + str(resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss), file=sys.stderr)" \
        .local/sprite-mips-bench/target/release/sprite-mips-$variant \
        .local/horizon-client/26.3 .local/sprite-mips-bench/$variant/names.txt
    done
  done
'

Each process reports resource loading, its first frame (including one-time mip tables), nine warmed-code samples of fresh-state setup/first upload/teardown, and setup plus the first 4,096 ticks. It then samples 1,024 warmed animation ticks and 3,072 rendered frames at three renders per tick, nine times each. Another 1,024 ticks report CPU latency p50/p95/max. The warmed state is dropped before the unique workloads, keeping one live renderer cache at a time. A black-box upload callback consumes each slice without GPU calls. The unchanged initial atlas generation, GPU staging, draw calls and presentation are outside this CPU measurement; these numbers do not establish an FPS improvement.

The first-seen workloads request each distinct source frame found over 4,096 ticks once, and separately simulate 8,192 distinct array layers sharing real immutable inputs. The latter deliberately exceeds the cache budget and practical atlas sizes to exercise admission and fallback. Fresh-state samples include teardown. Source-frame samples report peak retained bytes for one animation; other samples report the state's retained pixel-buffer capacity.

Every process also compares complete upload streams with the original function at 517 tick positions, including duplicate ticks, jumps, rewinds and changing mip limits, using all real animations (including interpolation metadata). This preserves the existing frame-based path; it does not add or validate GPU blending of interpolated frames.

Validation

  • pr_ready.py --base 89b58991bbc02eb79b6965fc4ca56357fe2297d9 --quick: public tree, formatting, workspace clippy, 196 Python tests and 635 client tests passed.
  • Native animation parity: 907 fixtures (63 resource animations), including 258,996 tick states and 203 intentionally rejected inputs, match the pinned client.
  • Native mipmap parity: 1,319 cases / 7,914 output lines match exactly.
  • Native breaking-model parity: 2,740 cases / 155,928 vertices match within the harness tolerance; maximum error 1.40395e-07.

The full game parity suite was not run. This checkout has no recorded harness index; pr_ready --quick lists that deferred selection, and the relevant native harnesses above were run explicitly. GPU interpolation pixels and end-to-end FPS remain outside this evaluation.

Measured outcome

Kept. Ryzen 5 5600X (12 logical CPUs), rustc 1.99.0 (b940084d7, 2026-09-28), Linux; 2,131 prepared block/item textures, 54 animations and 594 distinct source frames. Three paired process runs, reversing baseline/candidate order in round two, each with one warmup and nine samples per workload (27 samples per variant). Both binaries used the same private Cargo dependency resolution and optimized profile. Baseline gfx is 89b58991bbc02eb79b6965fc4ca56357fe2297d9.

CPU times below are median [minimum, maximum], in milliseconds. Each repeated sample averages 1,024 ticks or 3,072 render calls; unique/cold samples include state setup and teardown.

Workload Baseline Cache Median change
Fresh state + first tick + drop 7.532 [7.193, 7.567] 7.545 [7.128, 7.791] +0.18%
Repeated tick 3.627 [3.491, 3.720] 1.692 [1.646, 1.739] -53.34%
Repeated render, three renders/tick 1.203 [1.181, 1.244] 0.562 [0.546, 0.585] -53.33%
Each actual source frame once 98.168 [96.647, 102.813] 97.093 [96.674, 104.441] -1.09%
8,192 distinct layers, first frame 1123.475 [1088.996, 1156.957] 1121.300 [1087.745, 1154.184] -0.19%

Repeated-tick medians for the three processes were respectively 3.647 / 3.646 / 3.540 ms baseline and 1.702 / 1.674 / 1.692 ms cached. The corresponding render-call medians were 1.233 / 1.195 / 1.192 ms and 0.562 / 0.556 / 0.562 ms. The improvement repeats in every pair; cold and unique-frame differences are within the observed variation.

Additional per-process samples, baseline → cache:

  • Resource decoding: 69.829 / 69.350 / 67.308 → 68.632 / 66.235 / 67.252 ms.
  • Process-first setup/upload/drop, including mip table initialization: 7.255 / 7.979 / 7.610 → 8.796 / 7.706 / 7.688 ms. The first cached process had a cold outlier; no cold-start speedup is claimed.
  • Setup plus first 4,096 ticks, including cache fill: 14.835 / 15.001 / 14.746 → 7.053 / 6.997 / 6.955 seconds.
  • Individual warmed-tick p50: 4.311 / 4.313 / 4.318 → 1.639 / 1.543 / 1.550 ms; p95: 6.613 / 6.617 / 6.885 → 4.139 / 3.960 / 3.972 ms; maximum: 7.621 / 7.702 / 7.569 → 5.285 / 6.448 / 5.213 ms.
  • Whole-process peak RSS: 84412 / 83388 / 85416 → 84480 / 83820 / 84524 KiB, using a fresh Python parent's resource.getrusage(RUSAGE_CHILDREN).ru_maxrss per invocation. RSS includes resource loading and the synthetic workload, and allocator reuse/other peaks hide the retained cache cost; it does not establish zero added memory.

At 64px, each generated chain has 32,768 bytes of Vec capacity. The cache retains 256 entries / exactly 8,388,608 bytes, with map capacity 448, and bypasses the remaining frames. Retaining all 594 frames would require 18.56 MiB of buffer capacity. The first tick retains 1,769,472 bytes; the one-animation-at-a-time unique-source workload peaks at 1 MiB. The separate 4,096-entry cap bounds map metadata for tiny textures. No GPU memory or upload bytes are saved.

All six measured processes passed the 517-position upload-stream comparison. The 196 Python tests were rerun after adding the build-only driver mode and passed. The first RSS wrapper attempt failed before collecting samples because /usr/bin/time was unavailable; the complete paired session above uses Python resource counters. Private raw output remains under .local/sprite-mips-*.