Skip to content

Draw the map from the palette, not from every block position - #158

Merged
CaYatur merged 2 commits into
mainfrom
perf/tile-palette-hoist
Aug 5, 2026
Merged

Draw the map from the palette, not from every block position#158
CaYatur merged 2 commits into
mainfrom
perf/tile-palette-hoist

Conversation

@CaYatur

@CaYatur CaYatur commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Fixes the map taking up to a minute to appear. Measured, not guessed.

What was wrong

A region took 14 seconds to render, not the 180 ms every comment in worldTiles.ts claimed. On a 4 MB fixture of 1024 fully-sectioned chunks, built to match a real world's ~5 MB/811:

slice out of the file       1 ms   0.0%
inflate                    83 ms   0.6%
nbt.parseUncompressed     515 ms   3.7%
tileFromChunk           13305 ms  95.7%

The 180 ms was the decompress and the NBT parse. Nobody had measured the surface extraction, which is the other 96%.

tileFromChunk did all per-name work per block position instead of per palette entry — a regex to strip the namespace, a Set lookup for air, seeThrough (a Set miss then ten endsWith calls), and blockColour with a second regex inside it. 4096 times a section, for a palette of at most a few dozen entries. Above the surface a chunk is ~15 single-entry air sections, and each ran all 4096 positions to rediscover that air is air: ~53k string operations per chunk.

That is why big worlds and slower machines were worse: both mean more generated regions, each costing 14 s, and the queue serialises them behind a 250 ms gap.

What changed

  • Per palette entry, not per position. One invisible[] and one colour[] per section. A section a map sees nothing in is skipped before the indices are unpacked and before anything is allocated — under a roof it still records the air gap, in one pass over 256 columns instead of 4096 positions.
  • unpackIndices off BigInt. Each long split into two 32-bit halves once per long instead of a shift/mask/Number() per index.
  • Lazily. The scan stops as soon as every column has an answer, usually after two or three of a section's sixteen layers; unpacking all 4096 up front did the rest for nothing and allocated a 4096-element array per section. Now a prepared reader.
  • SLICE_SLOTS 32 → 8. Picked to keep a slice near 6 ms against the wrong 180 ms figure; at the real cost those slices were blocking the main thread — the one that answers every IPC call — for ~600 ms each.

Result

before after
one region, cold 13 962 ms 1 442 ms
four regions, as the queue does it 38 348 ms 6 129 ms
longest uninterrupted block 584 ms 41 ms
region from the disk cache 16 ms 13 ms (untouched)

Verification

The rendered output is unchanged. 6144 chunk renders — an overworld-shaped and a nether-shaped fixture, each 1024 chunks, in all three dimensions — hash identical to the previous implementation, built from git show HEAD: so it is the real old code and not a paraphrase. The nether fixture was added after noticing the first one hashed the same in all three dimensions, i.e. it never exercised the under-roof path at all, which is the riskiest part of this change. No TILE_CACHE_VERSION bump: existing caches stay valid.

Two gates, both proved failable before being trusted (MSMS_SMOKE_WORLDS, MSMS_SMOKE_MODUPDATE, both by exit code):

  • The WORLDS fixture was vacuous. It wrote chunks with no sections, so tileFromChunk returned null on its first line and the existing "no slice over 60 ms" assertion was timing an empty parse. Given real sections it fails on the parent commit at 661 ms and passes here at 45 ms. It now also asserts a full surface was rendered, so it cannot go vacuous the same way again.
  • unpackIndices is fuzzed against a verbatim copy of the pre-change implementation — 1500 trials x both packings x bits 4..31, with longs whose sign bit is set. It counts that half-boundary straddles, long-spanning indices and sign-bit longs were actually reached, because a fuzz that never hits the interesting case passes with the interesting case deleted. Removing the straddle branch fails it by name: unpackIndices differs from the reference: bits=29 packing=padded index=1 reference=499939792 got=0.

Deliberately not in this PR

The profile has changed shape — nbt.parseUncompressed is now 37.6% of a parse, where it was 3.7%. These are worth measuring against the new numbers rather than the old ones, and each is independently revertable:

  • the 64-chunk request cap: a full viewport is still 64 serialised round trips carrying ~12 MB of JSON
  • prewarming the disk cache, which is a 13 ms path once warm
  • parsing in worker threads. Note for whoever picks this up: blockColour reads module-level textureColours populated at runtime from the client jar, so a worker without that table renders different colours and writeCachedRegion persists them.

Closes #157

A region took 14 seconds to render, not the 180 ms every comment in the
file claimed. Measured on a 4 MB region of 1024 fully-sectioned chunks:

  inflate                    83 ms   0.6%
  nbt.parseUncompressed     515 ms   3.7%
  tileFromChunk           13305 ms  95.7%

The 180 ms was the decompress and the NBT parse. Nobody had measured the
surface extraction, which is the other 96%.

tileFromChunk did all per-name work per BLOCK POSITION instead of per
palette entry: a regex to strip the namespace, a Set lookup for air,
seeThrough (a Set miss then ten endsWith calls) and blockColour with a
second regex inside it — 4096 times a section, for a palette of at most a
few dozen entries. Above the surface a chunk is about fifteen single-entry
air sections, and each one ran all 4096 positions to rediscover that air is
air, roughly 53k string operations per chunk.

Resolved once per palette entry instead, with an all-invisible section
skipped before anything is unpacked or allocated. unpackIndices then
dominated, so it moved off BigInt onto the two 32-bit halves of each long,
converted per long rather than per index; and it became a prepared reader
rather than an eager array, because the scan stops as soon as every column
has an answer and the other thirteen layers were being unpacked for nothing.

  one region, cold          13962 ms -> 1442 ms
  four regions, as queued   38348 ms -> 6129 ms
  longest uninterrupted       584 ms ->   41 ms

The cached path is untouched at ~13 ms, and the rendered output is
unchanged: 6144 chunk renders across an overworld-shaped and a
nether-shaped fixture, in all three dimensions, hash identical to the
previous implementation. No TILE_CACHE_VERSION bump, so existing caches
stay valid.

SLICE_SLOTS drops 32 -> 8. It was picked to keep a slice near 6 ms against
the wrong 180 ms figure; at the real cost those slices were blocking for
about 600 ms, ten times the limit the smoke asserts.

Two gates, both proved failable before being trusted:

- The WORLDS fixture wrote chunks with NO SECTIONS, so tileFromChunk
  returned null on its first line and the existing "no slice over 60 ms"
  assertion measured an empty parse. Given real sections it fails on the
  parent commit at 661 ms and passes here at 45 ms. It now also asserts a
  full surface was rendered, so it cannot go vacuous the same way again.
- unpackIndices is fuzzed against a verbatim copy of the pre-change
  implementation over 1500 trials x both packings x 4..31 bits, counting
  that half-boundary straddles, long-spanning indices and sign-bit longs
  were actually reached. Deleting the straddle branch fails it by name.

Closes #157
Copilot AI lite review requested due to automatic review settings August 5, 2026 17:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes region/chunk surface extraction so map tiles are derived from section palettes (and lazily-read indices) rather than doing expensive string/rule work per block position, substantially reducing worst-case region render time and main-thread blocking.

Changes:

  • Refactors tileFromChunk to precompute per-palette invisible/colour arrays and scan lazily (early-exit once all 256 columns are resolved), avoiding full 4096-index unpacking in common cases.
  • Reworks unpackIndices to use a prepared index reader with 32-bit halves (with a BigInt fallback for wide palettes) and adds targeted fuzzing to ensure parity with the prior implementation.
  • Adjusts region parse slicing (SLICE_SLOTS 32 → 8) and strengthens smoke fixtures so timing gates exercise real surface extraction work.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/shared/tileCache.ts Updates cache rationale/comments to reflect measured region parse + surface extraction costs.
src/shared/regionFormat.ts Adds prepared/lazy index reading (prepareIndices/indexAt) and refactors unpackIndices to use it for performance.
src/main/smoke.ts Adds fuzz verification against the pre-change unpacking reference and fixes the worlds fixture to include real sections/surfaces.
src/main/core/worldTiles.ts Implements palette-driven surface extraction, lazy index reads, and reduces parse slice size to limit main-thread stalls.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Two things the self-review turned up.

The all-invisible skip claimed to happen "before anything is allocated" and
did not: `packedColour` was built above it, so every air section — the
majority of them — still allocated an array and called `blockColour` for
each palette entry before being thrown away. Moved below the skip. Output
re-verified identical on both fixtures.

SLICE_SLOTS 8 -> 4. The WORLDS gate failed once at 61 ms against its 60 ms
limit, which turned out not to be a flake: the smoke parses exactly one
region, so it always measures the FIRST parse in the process, and that one
blocks for 40 ms where every later one blocks for 15-19 — V8 has not
optimised these loops yet. The first parse is also the one an operator
meets, since they open the map and it is the only parse that has happened.
Sized for that: 28 ms cold and 8-10 ms steady, and three consecutive gate
runs at 25/41/43 ms.
@CaYatur

CaYatur commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Self review

Read the diff back against the measurements. Two things worth changing, both now on the branch (1b25fe1).

1. The all-invisible skip did not skip as much as its comment claimed

const packedColour = names.map(...)   // ← blockColour per palette entry
if (invisible.every(Boolean)) { ...; continue }

The comment said the section is skipped "before the index array is unpacked and before anything is allocated". It was not: packedColour was built above the guard, so every all-air section — about fifteen per chunk, the exact sections the guard exists to eliminate — still allocated an array and ran blockColour for each palette entry before throwing it away. Small next to the ~53k string operations this PR removes, which is presumably why it slipped past, but it is on the hottest path in the file and the comment asserting otherwise is the kind of thing the next person trusts.

Moved below the guard. Output re-verified identical on both fixtures.

2. The 61 ms gate failure was not a flake

MSMS_SMOKE_WORLDS failed once at 61 ms against its 60 ms limit, after passing at 45 ms. Tempting to re-run and move on. Instrumenting the parse with event-loop sampling over five consecutive runs says otherwise:

run 1  max slice  40 ms · loop lag p99  17.6  max  43.9
run 2  max slice  17 ms · loop lag p99  14.3  max  15.8
run 3  max slice  19 ms · loop lag p99  14.2  max  18.5
run 4  max slice  16 ms · loop lag p99  12.4  max  15.9
run 5  max slice  15 ms · loop lag p99  11.9  max  12.9

The first parse in a process costs 2-3x every later one, because V8 has not optimised these loops yet. The smoke parses exactly one region, so it always measures that first parse — and so does an operator, who opens the map when no parse has happened yet. So the number the gate was reporting is the honest one and 8 slots was sized for the average rather than for the case anyone experiences.

SLICE_SLOTS 8 → 4: 28 ms cold, 8-10 ms steady, and three consecutive gate runs at 25/41/43 ms. The assertion itself is untouched at 60 ms.

Residual, stated rather than tuned away

43 ms against a 60 ms bar is about 28% headroom, and the remaining variance looks like GC and JIT rather than slice work (loop-lag p99 was 21 ms on the cold run against 6.5 ms steady, so halving again would not buy much). On a machine several times slower this gate will go red — which is what it is for. The worst block observed has gone 661 ms → 43 ms.

Checked and fine

  • unpackIndices now has no production caller — only the smoke. Deliberate: it is the entry point the differential fuzz pins, and it is implemented on top of prepareIndices/indexAt, so fuzzing it covers the paths tileFromChunk actually uses.
  • The sawAir.fill(true) shortcut for an air section under the nether roof: every section reaching that line has its bottom layer at or below the ceiling (the outer guard drops the rest), so a full air layer covers all 256 columns and the fill matches what the per-position walk concluded. This is the change I trusted least, which is why the nether fixture exists — the first fixture hashed identically in all three dimensions, i.e. it was not testing this at all.
  • Gates green by exit code: MSMS_SMOKE, MSMS_SMOKE_WORLDS, MSMS_SMOKE_MODUPDATE, MSMS_SMOKE_WEB, MSMS_SMOKE_ANALYSIS.

@CaYatur

CaYatur commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Correction: the "output identical" claim was narrower than I stated

Re-reading my own evidence: bitsPerIndex floors at 4, so every non-uniform section in the original 6144 renders was 4 bits wide.

fixture section palette entries bitsPerIndex
overworld dense 16 4
nether roof / gap / floor 4 / 2 / 8 4
single-entry sections 1 uniform path, no indices at all

At 4 bits every offset is a multiple of 4 and offset + bits never crosses 32, so the straddle branch — ((lo >>> offset) | (hi << (32 - offset))) & mask — was never reached through tileFromChunk. Both fixtures were also DataVersion 3953, so spanning was never reached either, and MSMS still manages 1.12 servers where that is the live path.

The unit fuzz did cover both (3572 straddles, 3571 spans, widths 4..31), so I do not believe anything was hiding there. But "identical at 4 bits" is not the claim I made, and this is exactly the trap this project has been caught by before — a fixture that never crosses the boundary in the code passes with the boundary handling deleted.

Widened and re-run

Both fixtures now carry palettes past the 16-entry line (20 entries → 5 bits, where index 6 runs from bit 30 to 34) and half their chunks at DataVersion 2565 → spanning. The harness asserts the crossing happened rather than assuming it:

index widths reached 4,5
packings reached padded,spanning

Hashes, before vs after, 6144 renders:

overworld  38694897838d4bcb…    nether  c4fa7bb32729d8e9…    end  38694897838d4bcb…

Identical on both fixtures. The nether hash still differs from the other two, so the under-roof path is genuinely being exercised and not just agreeing trivially.

Scope, stated precisely

The differential render hashes colour and height. It does not compare marksstructuresOf is untouched by this PR and neither fixture has a structures key, so there is nothing to compare, but the claim should not be read as covering them.

The permanent guards in the tree are the MODUPDATE fuzz (bit math, widths 4..31, both packings, proved failable) and the existing MODUPDATE tileFromChunk assertions (stone renders as stone, heights, foliage seen through, nether roof). The differential render is a one-off: it needs the pre-change code from git show HEAD:, so it cannot live in the repo.

The WORLDS fixture stays at a 16-entry palette deliberately — it is a timing gate, and widening it would move the number it asserts against for coverage the fuzz already owns.

@CaYatur
CaYatur merged commit d7bb8c5 into main Aug 5, 2026
1 check passed
@CaYatur
CaYatur deleted the perf/tile-palette-hoist branch August 5, 2026 17:51
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.

The map takes ~14 seconds per region because surface extraction runs per block position, not per palette entry

2 participants