Skip to content

Parse regions on worker threads, and warm the cache before anyone looks - #165

Merged
CaYatur merged 2 commits into
mainfrom
perf/tile-workers-and-warming
Aug 5, 2026
Merged

Parse regions on worker threads, and warm the cache before anyone looks#165
CaYatur merged 2 commits into
mainfrom
perf/tile-workers-and-warming

Conversation

@CaYatur

@CaYatur CaYatur commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #160 and #161. They belong together: warming a world on the main thread would be the freeze it exists to avoid, at a larger scale.

Off the main thread (#160)

A region is ~1.4 s after #157, spent on the thread that answers every IPC call, serves the web panel and reads the console. Slicing made that interruptible, not absent — four cold regions still measured six seconds, of which three quarters of a second was the deliberate gap between them, and that gap exists only because the parse was in the way.

regionParse.ts is the parse with no Electron in it, split out of worldTiles.ts so a worker can import it. The worker hands back the encoded, gzipped region — byte for byte what the disk cache stores — so a result costs one decode and nothing rebuilds 1024 objects across a thread boundary.

Pool is half the cores, capped at four. This is a server manager; the machines cores belong to the Minecraft server it is running.

The colour table is the whole risk

blockColour reads module-level state that clientAssets.ts fills at runtime from the operators client jar. A worker starts with it empty, so a worker that parsed without it would render the fallback palette — and the caller would write those colours into the on-disk cache, where they outlive the process and keep serving a wrong map with a cache version that still matches.

Three things stop that:

  1. an epoch on the table, bumped by setTextureColours
  2. the pool sends the table whenever a threads copy is stale (not on every job — it is ~1000 entries and re-cloning it per region would cost more than some parses)
  3. the worker refuses to parse if it was never given one

The warmer is started after the colour table loads, never before.

Warming (#161)

Nothing parsed a region until somebody looked at it, so the map was cold the first time it was opened on a world MSMS had not seen: 1.4 s a region against ~13 ms from the cache, over four to nine regions.

The warmer walks the region directories outward from the origin — spawn is where a map is first pointed, and a world accumulates far corners from one player who once walked a long way. It:

  • stands aside whenever the pool has interactive work queued (a visitor looking at the map wins)
  • is bounded per pass (24 regions) and resumable, never a loop that has to finish
  • does nothing when the operator has set cache: false — there would be nothing to warm into
  • remembers a finished world, so a warmed one costs one directory listing a minute

Verification

MSMS_SMOKE_WORLDS, by exit code. Measured on the fixture: 820 ms on a worker against 871 ms on-thread for the same 1024 chunks.

break failure
pool never sends the colour table the worker refused the region: stale-colours
workers guard removed so it parses anyway the worker did not use the colour table it was given

The workers region is compared to this threads encoded, byte for byte — every column of every chunk, not a sample. Two renderers that agree on a fixture and diverge on a real world is the failure mode here, so the comparison is over the whole region.

Warming is asserted in three states: off with the cache disabled, parses with it on, and parses nothing on a second pass because everything is current.

The sliced on-thread parse is still asserted, with workers switched off for that one gate — with them on the main thread never blocks and the gate would be measuring nothing, exactly the way its fixture used to before #157. MSMS_NO_TILE_WORKERS is the same switch for a machine that needs it.

Gates green: MSMS_SMOKE, _WORLDS, _WEB, _MODUPDATE, _ANALYSIS, _AUDIT, _ALERTS.

CaYatur added 2 commits August 6, 2026 01:33
Closes #160 and #161. They belong together: warming a world on the main
thread would be the freeze it exists to avoid, at a larger scale.

**Off the main thread.** A region is about 1.4 s after #157 and it was being
spent on the thread that answers every IPC call, serves the web panel and
reads the console. Slicing made that interruptible, not absent — four cold
regions still measured six seconds, three quarters of a second of which was
the politeness gap between them, and that gap exists only because the parse
was in the way. A pool of half the cores, capped at four, parses several at
once and blocks nothing.

`regionParse.ts` is the parse with no Electron in it, split out of
worldTiles so a worker can import it. The worker hands back the ENCODED,
gzipped region — which is byte for byte what the disk cache stores, so a
result costs one decode and nothing rebuilds 1024 objects across a thread
boundary.

**The colour table is the whole risk.** `blockColour` reads module-level
state that clientAssets fills at runtime from the operator's client jar, and
a worker starts with it EMPTY. A worker that parsed without it would render
the fallback palette and the caller would write those colours into the cache,
where they would outlive the process and keep serving a wrong map with a
cache version that still matched. So: an epoch on the table, the pool sends
it whenever a thread's copy is stale, and the worker REFUSES to parse if it
was never given one. Both halves are proved failable below.

**Warming.** Nothing parsed a region until somebody looked at it, so the map
was cold the first time it was opened on a world MSMS had not seen — 1.4 s a
region against 13 ms from the cache, over four to nine regions. The warmer
walks the region directories outward from the origin (spawn is where a map is
first pointed) and parses into the cache when nobody is waiting. It stands
aside whenever the pool has interactive work, is bounded per pass and
resumable, does nothing at all when the operator has turned the cache off,
and remembers a finished world so a warmed one costs a directory listing.

Started AFTER the colour table loads, never before, for the reason above.

Verification (MSMS_SMOKE_WORLDS, by exit code):

- the worker's region is compared to this thread's ENCODED byte for byte —
  every column of every chunk, not a sample. Measured 820 ms on a worker
  against 871 ms on-thread for the same 1024 chunks.
- the colour table is set to an unmistakable value before the worker runs,
  and the tile has to carry it. Never sending the table fails with "the
  worker refused the region: stale-colours"; removing the worker's guard so
  it parses anyway fails with "the worker did not use the colour table it
  was given".
- warming with the cache off must do nothing, with it on must parse, and a
  second pass must find everything current and parse nothing.
- the sliced on-thread parse is still asserted, with workers switched off for
  that one gate — with them on the main thread never blocks, and the gate
  would have been measuring nothing, the way its fixture used to (#157).
  `MSMS_NO_TILE_WORKERS` is the same switch for a machine that needs it.

Gates green: MSMS_SMOKE, _WORLDS, _WEB, _MODUPDATE, _ANALYSIS, _AUDIT, _ALERTS.
The warmer writes NEAREST TO SPAWN FIRST and sweepCache evicts OLDEST FIRST,
which are opposite orders. On a world bigger than the cache limit the warmer
would therefore have spent every pass deleting the area around spawn — the
part a map is actually pointed at — and keeping whichever far corner it
happened to write last. Warming would have made the map worse, and only on
the big worlds it exists for.

It now stops when the cache is at its limit and says so once. An interactive
parse may still evict; that one is answering somebody.

A limit of zero is no room rather than no limit — the first version read it
as "unlimited" and warmed straight past it, which the new gate caught.

Proved failable: removing the guard fails with "warming ran past a full
cache: 1".
@CaYatur

CaYatur commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Self review

One real defect, and it is the kind that only shows up on the worlds this feature exists for.

The warmer would have eaten the area around spawn

Two policies pointing in opposite directions:

  • the warmer writes nearest to spawn first (deliberately — spawn is where a map is pointed)
  • sweepCache evicts oldest first (files.sort((a, b) => a.at - b.at), worldTiles.ts:186)

So on any world larger than cacheLimitMB, warming would have spent every pass deleting the spawn area and keeping whichever far corner it wrote last. The feature would have made the map measurably worse, and only on big worlds — exactly the case it was built for, and exactly the case my fixture (one region) could not reach.

A full cache now stops the warmer and says so once, naming the setting to raise. An interactive parse may still evict — that one is answering somebody.

Writing the test then found a second bug in the fix: I had guarded with limit > 0 && …, reading a cacheLimitMB of 0 as "no limit" when it means no room — the sweep would delete whatever was written the moment it landed. The gate caught it on the first run (warming ran past a full cache: 1), which is the only reason I know.

Proved failable: removing the guard fails with the same message.

Checked and deliberate

  • worker.unref(). The threads must not be the reason the process stays alive, and before-quit terminates them — a packaged app that quits with threads running can stay resident. In-flight work still completes because Electron keeps the loop alive regardless.
  • A dead worker answers its jobs. worker-died resolves everything in flight rather than leaving promises hanging: a map that draws nothing is recoverable, a promise that never settles is not. The caller then falls through to the on-thread parse rather than showing an empty map.
  • The colour table is sent on staleness, not per job. It is ~1000 entries; cloning it for every region would cost more than some parses do. That is what the epoch is for, and both halves of the hazard are gate-proved.
  • warmOneRegion deliberately does not use loadRegionSliced. Warming a thousand regions through the memory cache would evict the handful an operator is looking at, over and over. The disk cache is the target; memory belongs to whoever is looking.
  • The pool is capped at four threads and half the cores. The machines cores belong to the Minecraft server, not to drawing its map.

Disclosed

  • The 60-second warming tick and its 24-region budget are not exercised end to end — the gate calls warmServer directly. What is asserted is the decision-making: off with the cache disabled, parses with it on, parses nothing when everything is current, stops at a full cache.
  • poolBacklog() > 0 as the "stand aside" signal is inspection-only. It is checked once per region rather than mid-parse, so a warm region already in flight finishes before the maps job starts — up to about a second of waiting for an interactive request in the worst case, against the 1.4 s it would have cost to parse it on the spot.
  • Measured on one machine (16 usable threads → pool of 4). The relative numbers on a two-core machine will differ; the structural claim — that the main thread no longer blocks — does not depend on core count.

Gates green by exit code: MSMS_SMOKE, _WORLDS, _WEB, _MODUPDATE.

@CaYatur
CaYatur merged commit 3c8788f into main Aug 5, 2026
1 check passed
@CaYatur
CaYatur deleted the perf/tile-workers-and-warming branch August 5, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parse regions off the main thread — but a worker renders different colours unless it is given the texture table

1 participant