Files
skate-3-rust-engine/docs/hails-additions/08-setup-performance.md
T
Hails 8327e94090 Setup performance: skip duplicate stream copies, parallel customiser
- Skip decoding identical streamed asset copies (DownTown streams 107.8 -> 24.8 s)
- Run the character customiser beside the map stage (memory-gated)
- Build.ps1 builds the native RefPack DLL for dev checkouts
- Clothing library texture warm-up and pro roster run in worker processes
  (library 126.5 -> 51.2 s, roster 89.6 -> 15.9 s); shared caches are
  published first-writer-wins to avoid Windows rename races
- pipeline-equivalence.json: combined pairs (committed -> PR F+G)

Outputs proven identical (8,288 customiser files; all maps/spawns).
One-time customiser rebuild. See docs/hails-additions/08-setup-performance.md (PR G).
2026-10-01 00:13:55 -06:00

206 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 8. Setup performance: duplicate stream copies and customiser overlap (PR G)
Measured on a full setup (Windows 11, i7-14700KF, 28 threads, 3 map workers):
466 s total before this change — stock data 20 s, maps 183 s (DownTown alone
182 s), character customiser 226 s (run only after all maps), publish 24 s —
with total CPU use around 10%.
## 1. Skip decoding identical copies of streamed assets
### Problem
City districts are stored as a grid of streaming cells (`cPres_*`, `cTex_*`,
`cSim_*` `.xsf` files). The same asset (texture, model) is copied into every
cell that uses it. `skate3_streams.load_district_stream` called `read_sfil` on
every cell file, which **fully decoded every copy** (RefPack), and only then
compared it with the first copy and discarded it.
| District | Copies / unique (Pres) | Copies / unique (Tex) | RefPack data decoded / unique |
|---|---|---|---|
| DownTown | 13,991 / 3,627 | 10,510 / 985 | 1,548 MB / 347 MB |
| University | 7,736 / 2,665 | 6,427 / 689 | 1,332 MB / 285 MB |
| Industrial | 6,915 / 2,163 | 5,088 / 578 | 806 MB / 188 MB |
In source checkouts the native RefPack DLL is not built (only the release
script builds `target/native/refpack.dll`), so decoding runs in pure Python.
### Change
`tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py`:
`read_sfil` takes an optional `known_copies` (asset id → BLAKE2b digest of the
first stored copy: asset header + stored payload). In a district load, a later
copy whose stored bytes have the same digest is not decoded and not returned.
A copy whose stored bytes differ is decoded and returned as before, so the
existing conflict check (`conflicting SFIL copies`) still sees it. Record
lookup, in-file duplicate detection and stride validation still run for every
copy. Without `known_copies` (all other callers) behaviour is unchanged.
Why identical: identical stored bytes (same section table, sizes and
compressed payload) decode to identical data, and the loader already kept the
first copy; the returned list is still built in ATOC order from the first
copies.
### Verification
- All 10 districts × Pres/Sim/Tex loaded with the original and the new module:
every asset's record fields, source path, source offset, stored size and
decoded bytes are identical.
| District | Stream load before | After |
|---|---|---|
| DownTown | 107.8 s | 24.8 s |
| University | 83.4 s | 19.1 s |
| Industrial | 49.3 s | 12.9 s |
| all 10 | 247.9 s | 64.1 s |
- Unit tests (`test_streams.py`): identical copies from another cell are
skipped, differing copies are still decoded and returned, in-file duplicates
are still rejected. (The fixture uses the uncompressed storage path; the
compressed path is covered by the full-district comparison above.)
## 2. Run the character customiser beside the map stage
### Problem
`customiser_setup.install` passes the customiser to `install._install` as
`finalize`, which ran after the whole map stage. It reads only stock data
(core group), default-skater textures (character group) and the disc — all
prepared before maps — and writes only `assets/private/customisation`. It
launches no processes (so it cannot race `spawn()`'s process-wide DLL setting).
### Change
`tools/asset_pipeline/install.py`: when the map stage runs and memory allows
(`overlap_customiser`: available memory ≥ 2 GiB desktop reserve + 3 GiB per
map worker + one more 3 GiB slot), the customiser starts on a background thread
(`Background`) just before the map jobs and is joined where it used to run;
its exception is re-raised there, so failures behave as before. Otherwise it
runs at the end exactly as before. `map_workers` and the new gate share
`available_memory()`.
### Verification
- Unit tests: `Background.join` waits and re-raises; the memory gate needs one
slot more than the map workers.
- Full setup: see below.
## 3. Faster clothing library and pro roster
### Problem
After section 2 the customiser was the critical path. Its two big stages were
single-threaded loops: the clothing library (126.5 s alone) decodes ~2,900
textures one at a time, and the pro roster (89.6 s) prepares 41 characters
one at a time. In a dev checkout both also used the pure-Python RefPack
decoder, because only `Build-Release.ps1` built `target/native/refpack.dll`.
### Change
- `scripts/Build.ps1`: after the cargo build, compiles
`tools/asset_pipeline/refpack_native.rs` into `target/native/refpack.dll`
with the same rustc flags as the release script, when the DLL is missing or
older than its source. `fast_refpack.py` already prefers it.
- New `tools/asset_pipeline/customisation_workers.py`: worker entry
(`--warm-textures`, `--roster`) plus `run_parallel`, which runs workers through
`install.run(task(...))` so frozen builds re-enter correctly. The worker count
is `customiser_workers()`: a quarter of the logical CPUs, from 1 to 8. If a
worker fails, `run_parallel` prints the tail of that worker's log.
- Library (`customisation_library.py`): `requested_textures(catalog)` lists
exactly the textures `prepare()` will decode, using the same skip rules. Before
the serial loop, `warm_up()` decodes them into the `decoded/` cache, spread
across worker processes in disjoint chunks. The loop itself and its
first-writer-wins ordering are unchanged; it now finds the decoded files
already present. Decoded files that the loop doesn't use, and source files
the warm-up created for them, are deleted before `prune_unavailable`. That
leaves the cache exactly as a serial run would.
- Roster (`native_roster.py`): `prepare_parallel()` splits the characters
round-robin over worker processes (`prepare(only=..., report_path=...)`).
Each character writes to its own outputs (`library/entries/<id>`,
`work/<key>`). The reports are merged back into roster order and
`work/report.json` is written as before. It falls back to a serial
`prepare()` with one worker, or if any worker fails or a report is missing.
- Shared roster caches (`work/source`, `work/decoded`) are published by
`_publish()`: write a pid-unique temporary file, then rename it into place
without overwriting. On Windows, renaming over a file that another process
is opening fails on one side: WinError 5 for the renamer, or Errno 13 for the
reader's `open()`. The first version used `os.replace`, which overwrites. In
the first real run, 4 of 7 workers died this way and the stage quietly fell
back to serial. Now `os.rename` is used (it doesn't overwrite on Windows). An
existing destination, or `FileExistsError`, means another worker already
published identical bytes, so that file is kept. A `PermissionError` is
retried briefly.
### Verification
- RefPack: the native and Python decoders give identical bytes for every
compressed entry in `createacharacter.big` (4,229 entries; 34.0 s → 1.4 s)
and `marquee.big` (11.2 s → 0.5 s), and for every district stream
(DownTown 23.9 s → 1.8 s).
- Library warm-up, 7 workers: all 2,921 requested textures decoded in 25.6 s,
byte-identical to the installed `decoded/` cache.
- Roster, 7 workers, no fallback: 18.5 s. All 41 characters are ready and each
entry's `character.glb`, `recipe.json`, `manifest.json` and `preview.png` is
byte-identical to the installed set. The report equals the installed one and
no temporary files are left behind.
- Unit tests (`test_customisation_workers.py`):
- merge order;
- fallback to serial on a failed worker;
- workers never exceed the number of characters;
- `_publish` is atomic and keeps an existing file;
- rename contention is tolerated and retried;
- `requested_textures` follows the skip rules.
- Refresh setup (customiser only, rebuilt once):
- library 126.5 → **51.2 s**, roster 89.6 → **15.9 s**;
- customiser stage total 73 s, with no worker failures;
- `check_customiser.py`: IDENTICAL, all 8,288 receipted files;
- `check_maps.py` and `check_spawns.py`: all OK, unchanged.
### Cost
The customiser fingerprint covers `customis*.py` and `native_roster.py`, so an
existing install rebuilds its customiser once after this change (about 75 s on
this machine). The outputs are identical, so nothing the user can see changes.
There is no equivalence mechanism for the customiser fingerprint, and the
rebuild is cheap, so none was added.
## Fingerprints
The stream change touches a file in the shared parser set, changing the
`character`, `environment` and `maps` fingerprints without changing outputs
(proven above); old→new pairs are in `pipeline-equivalence.json`. The
customiser fingerprint and stage versions are unchanged. An installation built
from the committed code rebuilds nothing.
## Full setup result
Full fresh setup, same machine and source, before → after:
| Stage | Before (466 s total) | After (331 s total) |
|---|---|---|
| Stock data, HUD, skater, environment | 0–20 s | 0–19 s |
| Map stage (10 maps, 3 workers, validated) | 21–204 s (183 s) | 19–138 s (**119 s**) |
| Character customiser | 216–442 s (226 s, after maps) | 19–311 s (292 s, beside maps) |
| Publish | 442–466 s | 311–331 s |
**Total: 466 s → 331 s (−29%), 0 warnings.**
- Identical outputs: all 8,288 receipted customiser files match a snapshot of
the previous (sequential) installation, with only the random set id
differing in 3 JSON paths; all maps pass validation with unchanged
collision counts and no warnings; all baked spawns unchanged.
- Contention: overlapped, the customiser's library stage took 193.9 s instead
of 126.5 s alone (three map workers compete for disk and memory bandwidth,
boost clocks drop, and the customiser thread shares the GIL with the setup
main thread). It is now the critical path; the roster (89.6 s) and library
are single-threaded loops and the next candidates.
## Files
- `tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py`
- `tools/asset_pipeline/install.py` (`available_memory`, `overlap_customiser`,
`Background`, map stage), `pipeline-equivalence.json`
- `tools/asset_pipeline/test_streams.py`, `test_map_validator.py`
- Section 3: `scripts/Build.ps1`, `tools/asset_pipeline/customisation_workers.py` (new),
`customisation_library.py`, `native_roster.py`, `customiser_setup.py`,
`test_customisation_workers.py` (new), `test_customiser_setup.py`