Work

Performance launcher · runtime investigation · desktop product

Preflight

Heavily modded Starsector went from a recent ordinary 112.17-second launch to 13.69 seconds. The useful story is how that happened: profile the real system, find repeated work at the boundary that owns it, preserve the original path when the proof stops holding, then measure again.

The pattern

Most of the large wins were boundary corrections.

The first implementation was often locally correct and globally misplaced. Five caches pointed to a lower common read boundary. A valid texture cache sat after the expensive queue it was supposed to avoid. Rebuildable texture data was treated as though every intermediate file needed durability. Generated code was cached before anyone noticed that the cached representation itself was massively duplicated.

  1. 01

    39,017 calls · 8,378 paths · 2.172s → 0.300s

    The shared data boundary

    Five loader-specific JSON/CSV caches cut one major loading seam, but profiling showed that the same merge-and-parse work was still repeated below them. One measured launch issued 39,017 JSON calls across 8,378 paths. Moving the repeated work into a shared memoized reader took the remaining merged-read seam from 2.172s to 0.300s, while typed-tree replay avoided reparsing stored JSON text.

  2. 02

    ~27s serialized wait · 1.22 GiB VRAM padding removed

    The cache was behind the bottleneck

    Prepared texture data initially looked healthy in local hit counters but barely moved the whole launch. The reason was placement: the loading thread could still wait roughly 27 seconds behind a single-threaded texture-prefetch queue before the cache decision happened. Moving that decision earlier changed the critical path. Later upload work also removed 1.22 GiB of power-of-two VRAM padding.

  3. 03

    200.77s → 16.21s · 4.76 GB → ~1.1 GB · 33.53s → 14.174s

    Rebuildable data is not durable data

    Texture preparation originally forced thousands of rebuildable intermediates to disk before publishing the final pack. Streaming those intermediates into one final durable pack changed both preparation cost and footprint. After that, the same logical texture corpus launched much faster when the pack was written in observed startup order instead of alphabetical order.

  4. 04

    18.014s → 2.364s · 36,332 → 280 classes · 145.96 MiB → 1.13 MiB

    Generated code had two different duplication problems

    Memoizing Janino requests removed repeated compilation first. The persisted cache then exposed a second problem: 36,332 generated-class occurrences contained only 280 unique classes. Deduplicating the stored representation collapsed the class maps from 145.96 MiB to 1.13 MiB and made replay dramatically cheaper.

  5. 05

    79.1M entity-reference checks → 0 · 117.9M unchanged recomputations skipped

    The same method found runtime work after startup

    Campaign profiling found repeated sector-wide entity validation and commodity recomputation. Mutation-tracked indexes removed the expensive full-list validation path, while a memoized commodity path served unchanged state directly and delegated real changes back to the original implementation. The work is presented as operation-count reduction rather than an invented universal FPS claim.

Compatibility is part of the optimization

Preflight does not permanently rewrite the game or mod JARs. Prepared artifacts and runtime adapters are bound to the inputs and code they were reviewed against. When a target changes or cannot be proven safe, the optimization declines and the game's original behavior remains available. In a system assembled from obfuscated game code, third-party mods, mutable JSON objects, generated classes, and changing archives, that fallback boundary is part of the performance design rather than cleanup afterward.

Useful reversal

A cache can work perfectly and still be useless.

The prepared-texture cache had healthy hit counters. It was also checked after the loading thread had already waited behind the single-threaded prefetch queue. That failure changed how I approached the rest of the project: measure the owner of the delay, not the component that happens to be nearby, and keep the failed-but-informative experiments in the record.