From 2006d4e736760133d32b13f111fa22cdbe1a8e6d Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 5 Aug 2026 20:38:19 +0300 Subject: [PATCH 1/2] Draw the map from the palette, not from every block position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main/core/worldTiles.ts | 148 +++++++++++++++++++++---------- src/main/smoke.ts | 172 ++++++++++++++++++++++++++++++++++-- src/shared/regionFormat.ts | 169 ++++++++++++++++++++++++++++++----- src/shared/tileCache.ts | 10 +-- 4 files changed, 420 insertions(+), 79 deletions(-) diff --git a/src/main/core/worldTiles.ts b/src/main/core/worldTiles.ts index fad1d7c..9462b42 100644 --- a/src/main/core/worldTiles.ts +++ b/src/main/core/worldTiles.ts @@ -36,11 +36,12 @@ import { bitsPerIndex, blockColour, chunkSlot, + indexAt, localChunk, packingFor, parseLocationTable, + prepareIndices, regionOf, - unpackIndices, scanRuleFor, seeThrough, structureKind, @@ -92,9 +93,10 @@ export function _resetWorldTiles(): void { // ---- the on-disk cache (#133) ---- // // #119 kept parsed regions in memory only, so every restart re-parsed the -// world: 180ms per region just to decompress and parse the NBT, before any -// surface extraction. The encoded form of the same region gunzips in about a -// millisecond. +// world. Measured on a 4 MB region of 1024 chunks: 0.6 s to decompress and +// parse the NBT, and 0.8 s more to extract the surfaces — 1.5 s in total, and +// 14 s before #157. The encoded form of the same region reads back in about +// 16 ms, which is the whole reason this exists. function cacheDirFor(): string { return ensureDir(join(cacheDir(), 'worldtiles')) @@ -373,6 +375,49 @@ export function tileFromChunk(chunk: any, dim = 'overworld'): ChunkTile | null { const paletteRaw = listOf(states?.palette).length ? listOf(states?.palette) : listOf(s.Palette) if (!paletteRaw.length) continue const names: string[] = paletteRaw.map((p: any) => String(tag(tag(p)?.Name) ?? '')) + + /** + * The block rules, resolved once per PALETTE ENTRY. + * + * This is the whole cost of a map (#157). A section is 4096 positions and + * its palette is at most a few dozen entries, and every one of these used + * to run per position: 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. A fully generated chunk is + * about 53 thousand of those to render 256 columns — 13 ms a chunk, 14 + * seconds a region, and the reason a viewport could take over a minute. + * + * Resolved per entry it is a few dozen, and the inner loop is array + * indexing. Same answers: the arrays are built by the same functions in the + * same order. + */ + const invisible = names.map((n) => { + // Air, and the plants a map looks through — see `seeThrough`. Without it + // the surface is whatever is standing ON the ground rather than the + // ground, which is how a bamboo jungle rendered as a maroon smear. + const short = n.replace(/^minecraft:/, '') + return !short || INVISIBLE.has(short) || seeThrough(short) + }) + const packedColour = names.map((n) => { + const c = blockColour(n.replace(/^minecraft:/, '')) + return (c.r << 16) | (c.g << 8) | c.b + }) + + // A section a map sees nothing in — and above the surface that is most of + // them, fifteen or so single-entry air palettes per chunk. Skipped HERE, + // before the index array is unpacked and before anything is allocated, + // because the point is not to make those sections cheaper but to stop + // touching them at all. + if (invisible.every(Boolean)) { + // Except under a roof, where an air section is not nothing: it is the gap + // the scan is looking for. Recorded for every column in one pass instead + // of by walking 4096 positions to reach the same conclusion. Every + // section still standing here has its bottom layer at or below the + // ceiling, so a full air layer covers all 256 columns. + if (sawAir) sawAir.fill(true) + continue + } + // A section whose palette is one entry has no data array at all — it is // 4096 of that block, which is how a solid stone or all-air section is // stored. Reading `data` there would skip the section entirely. @@ -380,43 +425,48 @@ export function tileFromChunk(chunk: any, dim = 'overworld'): ChunkTile | null { // it, which is a list and wrapped twice. const longs = toLongs(tag(states?.data) ?? tag(s.BlockStates)) const bits = bitsPerIndex(names.length) + // `null` rather than 4096 zeroes: a uniform section reads palette entry 0 + // at every position, so the array only existed to say so. + // + // And prepared rather than unpacked: the loop below walks down from the top + // layer and stops the moment every column has an answer, which on real + // terrain is after two or three of the sixteen. Unpacking all 4096 did the + // rest for nothing. const indices = - names.length === 1 || !longs.length - ? new Array(4096).fill(0) - : unpackIndices(longs, bits, 4096, packing) + names.length === 1 || !longs.length ? null : prepareIndices(longs, bits, packing) for (let y = CHUNK_AXIS - 1; y >= 0 && remaining > 0; y--) { - for (let z = 0; z < CHUNK_AXIS; z++) { - for (let x = 0; x < CHUNK_AXIS; x++) { - const col = x + z * CHUNK_AXIS - if (colour[col] >= 0) continue - const worldY = sectionY * CHUNK_AXIS + y - if (rule.ceiling !== null && worldY > rule.ceiling) continue - const name = names[indices[y * 256 + z * CHUNK_AXIS + x]] ?? '' - const short = name.replace(/^minecraft:/, '') - // Air, and the plants a map looks through — see `seeThrough`. Without - // it the surface is whatever is standing ON the ground rather than - // the ground, which is how a bamboo jungle rendered as a maroon smear. - const invisible = !short || INVISIBLE.has(short) || seeThrough(short) - if (sawAir) { - // Under a roof: remember the gap, and skip everything solid until - // one has been seen. Without this the first hit is the roof itself. - if (invisible) sawAir[col] = true - if (!sawAir[col]) { - if (!invisible && fallbackColour && fallbackColour[col] < 0) { - const fc = blockColour(short) - fallbackColour[col] = (fc.r << 16) | (fc.g << 8) | fc.b - if (fallbackHeight) fallbackHeight[col] = worldY - } - continue + // Neither of these depends on the column, and both used to be recomputed + // 256 times a layer. + const worldY = sectionY * CHUNK_AXIS + y + if (rule.ceiling !== null && worldY > rule.ceiling) continue + const base = y * 256 + // `x + z * CHUNK_AXIS` IS the column index, so the two loops the original + // had over x and z collapse into one over the column in the same order. + for (let col = 0; col < 256; col++) { + if (colour[col] >= 0) continue + const pi = indices ? indexAt(indices, base + col) : 0 + // An index past the end of its palette: the width comes from + // `bitsPerIndex`, which rounds up, so a three-entry palette is read + // four bits wide and corrupt data can address entry 15. The old code + // resolved that to an empty name and treated it as invisible. + const inv = pi >= names.length || invisible[pi] + if (sawAir) { + // Under a roof: remember the gap, and skip everything solid until + // one has been seen. Without this the first hit is the roof itself. + if (inv) sawAir[col] = true + if (!sawAir[col]) { + if (!inv && fallbackColour && fallbackColour[col] < 0) { + fallbackColour[col] = packedColour[pi] + if (fallbackHeight) fallbackHeight[col] = worldY } + continue } - if (invisible) continue - const c = blockColour(short) - colour[col] = (c.r << 16) | (c.g << 8) | c.b - height[col] = worldY - remaining-- } + if (inv) continue + colour[col] = packedColour[pi] + height[col] = worldY + remaining-- } } } @@ -537,16 +587,24 @@ function parseSlot( * How many of a region's 1024 chunks to parse before letting the event loop * run. * - * A region takes about 180 ms to parse and the main process serves every IPC - * call, so parsing one in a single pass freezes the whole app for that long — - * the console stops reading, stats stop arriving, and the interface stutters - * while the map loads. Slicing does not make the work shorter; it makes it - * interruptible, which is the part that was hurting. + * The main process serves every IPC call, so parsing a region in a single pass + * freezes the whole app for as long as it takes — the console stops reading, + * stats stop arriving, and the interface stutters while the map loads. Slicing + * does not make the work shorter; it makes it interruptible, which is the part + * that was hurting. + * + * 32 was chosen against a measurement of 180 ms a region, which was wrong: that + * was the decompress and the NBT parse, about 4% of the real cost, and a region + * actually took 14 seconds (#157). The same 32 slots were therefore blocking + * for around 600 ms each, ten times the limit the smoke asserts — it did not + * catch it because its fixture had no sections to render. * - * 32 puts the longest uninterrupted block around 6 ms — under a frame — at the - * cost of 32 extra event-loop turns per region, which is nothing. + * A region is now about 1.5 s here, so 8 slots is 12 ms — under a frame with + * room for a machine several times slower, which is the machine that complained. + * The price is 128 event-loop turns per region instead of 32, and a + * `setImmediate` costs microseconds. */ -const SLICE_SLOTS = 32 +const SLICE_SLOTS = 8 function loadRegion( serverId: string, @@ -860,9 +918,9 @@ async function drain(): Promise { // the very next call was about to do anyway. const before = lastParseAt try { - // Sliced: a region takes ~180 ms and this thread answers every IPC + // Sliced: a region takes about 1.5 s and this thread answers every IPC // call, so parsing one in a single block froze the interface for that - // long. Same work, interruptible. + // long. Same work, interruptible — see `SLICE_SLOTS`. await chunkTileSliced(job.serverId, job.dim, job.cx, job.cz) } catch { /* one bad region must not stop the queue */ diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 0467ecb..1d93515 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -110,6 +110,7 @@ import { shade, unpackIndices } from '@shared/regionFormat' +import type { IndexPacking } from '@shared/regionFormat' import { publicVerifyReply, verifyDecision } from '@shared/playerVerify' import { newRefreshState, tryRefresh, INVENTORY_REFRESH } from '@shared/refreshLimit' import { @@ -1038,6 +1039,106 @@ export async function runModUpdateSmoke(): Promise { const negative = unpackIndices([-1n], 4, 16, 'padded') if (negative.some((v) => v !== 15)) return fail('a negative long decoded to ' + negative[0]) + // The 32-bit extraction against the arbitrary-precision one it replaced. + // + // #157 rewrote this off BigInt for speed, and a bit-packing bug does not + // throw — it produces a plausible map with the wrong blocks in it. The + // reference below is a VERBATIM copy of the pre-#157 implementation, kept + // here on purpose: the cases above pin the handful of layouts somebody + // thought of, and this pins every other one to the answer that shipped. + { + const reference = (l: bigint[], bits: number, count: number, p: IndexPacking): number[] => { + const out = new Array(count).fill(0) + if (bits <= 0 || !l.length) return out + const mask = (1n << BigInt(bits)) - 1n + const u = (v: bigint): bigint => BigInt.asUintN(64, v) + if (p === 'padded') { + const perLong = Math.floor(64 / bits) + for (let i = 0; i < count; i++) { + const li = Math.floor(i / perLong) + if (li >= l.length) break + out[i] = Number((u(l[li]) >> BigInt((i % perLong) * bits)) & mask) + } + return out + } + for (let i = 0; i < count; i++) { + const bitPos = i * bits + const li = Math.floor(bitPos / 64) + if (li >= l.length) break + const offset = bitPos % 64 + let value = (u(l[li]) >> BigInt(offset)) & mask + if (offset + bits > 64 && li + 1 < l.length) { + const taken = 64 - offset + value |= (u(l[li + 1]) & ((1n << BigInt(bits - taken)) - 1n)) << BigInt(taken) + } + out[i] = Number(value & mask) + } + return out + } + + let seed = 0x9e3779b9 + const rnd = (): number => { + seed ^= seed << 13 + seed |= 0 + seed ^= seed >>> 17 + seed ^= seed << 5 + seed |= 0 + return seed >>> 0 + } + // Counted, not assumed. A run that never produced a straddling layout + // would pass with the straddle deleted, which is the shape of vacuous + // test this file has been caught by before. + let straddled = 0 + let spanned = 0 + let signed = 0 + for (let trial = 0; trial < 1500; trial++) { + const bits = 4 + (rnd() % 28) // 4..31 + const n = 1 + (rnd() % 40) + const longs: bigint[] = [] + for (let i = 0; i < n; i++) { + const v = BigInt.asIntN(64, (BigInt(rnd()) << 32n) | BigInt(rnd())) + // The sign bit is DATA. A plain shift fills with ones instead. + if (v < 0n) signed++ + longs.push(v) + } + const count = 1 + (rnd() % 4096) + for (const packing of ['padded', 'spanning'] as IndexPacking[]) { + if (packing === 'padded') { + const perLong = Math.floor(64 / bits) + for (let i = 0; i < Math.min(count, perLong * n); i++) { + const o = (i % perLong) * bits + // Padded means an index never spans two LONGS — not that it + // never spans the two 32-bit halves of one. + if (o < 32 && o + bits > 32) { + straddled++ + break + } + } + } else { + for (let i = 0; i < count; i++) { + if (((i * bits) % 64) + bits > 64) { + spanned++ + break + } + } + } + const want = reference(longs, bits, count, packing) + const got = unpackIndices(longs, bits, count, packing) + for (let i = 0; i < count; i++) { + if (want[i] !== got[i]) { + return fail( + `unpackIndices differs from the reference: bits=${bits} packing=${packing} ` + + `index=${i} reference=${want[i]} got=${got[i]}` + ) + } + } + } + } + if (straddled < 100) return fail('the fuzz never straddled a 32-bit half: ' + straddled) + if (spanned < 100) return fail('the fuzz never spanned two longs: ' + spanned) + if (signed < 100) return fail('the fuzz never used a long with the sign bit set: ' + signed) + } + // Four bits minimum whatever the palette holds. if (bitsPerIndex(1) !== 4 || bitsPerIndex(16) !== 4) return fail('small palettes must still use 4 bits') if (bitsPerIndex(17) !== 5) return fail('17 entries needs 5 bits') @@ -2287,16 +2388,68 @@ export async function runWorldsSmoke(): Promise { // --- 12a. a region parse must not freeze the process (#151) ------------- { - // A real region file: 1024 slots, each a deflated NBT compound. The - // chunks carry no sections, so `tileFromChunk` yields nothing — but the - // expensive half, decompress plus NBT parse, runs exactly as it does on a - // real world, which is what the timing here is about. + // A real region file: 1024 slots, each a deflated NBT compound. + // + // The chunks carry REAL SECTIONS, and that is the whole point. This + // fixture used to write chunks with no sections at all, so + // `tileFromChunk` returned null on its first line and the timing below + // measured the decompress and the NBT parse and nothing else — which are + // together 4% of what a region actually costs. The assertion was sound + // and the fixture made it vacuous: the real longest slice was 584 ms + // against a limit of 60, and this gate stayed green for months (#157). + // + // Shaped like a real chunk rather than uniformly dense: above the surface + // everything is a single-entry air palette with no data array, below it + // is mostly stone, and only the band around the surface carries a full + // palette. A uniformly dense region came out at 52 MB against a real + // world's ~5, which would have measured something no operator has. + const BLOCKS = [ + 'minecraft:air', 'minecraft:stone', 'minecraft:dirt', 'minecraft:grass_block', + 'minecraft:water', 'minecraft:sand', 'minecraft:oak_log', 'minecraft:oak_leaves', + 'minecraft:gravel', 'minecraft:deepslate', 'minecraft:andesite', 'minecraft:diorite', + 'minecraft:granite', 'minecraft:coal_ore', 'minecraft:iron_ore', 'minecraft:snow' + ] + // 4 bits per index across 4096 blocks is 256 longs, the usual dense + // section. Repetitive, like real terrain — random longs do not deflate + // and the fixture would be six times the size of the thing it imitates. + const packed = (seed: number): number[][] => { + const pattern: number[][] = [] + for (let i = 0; i < 12; i++) pattern.push([(seed * 2654435761 + i * 40503) | 0, (seed + i) | 0]) + return Array.from({ length: 256 }, (_, i) => pattern[i % pattern.length]) + } + const section = (y: number, seed: number): unknown => { + // The surface band. Everything above it is air, everything below stone. + const dense = y >= -2 && y <= 6 + const names = dense ? BLOCKS : [BLOCKS[y > 6 ? 0 : 1]] + return { + Y: { type: 'byte', value: y }, + block_states: { + type: 'compound', + value: { + palette: { + type: 'list', + value: { + type: 'compound', + value: names.map((n) => ({ Name: { type: 'string', value: n } })) + } + }, + ...(dense ? { data: { type: 'longArray', value: packed(seed + y) } } : {}) + } + } + } + } const chunkNbt = nbt.writeUncompressed({ type: 'compound', name: '', value: { DataVersion: { type: 'int', value: 3953 }, - // Padding, so one chunk is a realistic size rather than 30 bytes. + sections: { + type: 'list', + value: { + type: 'compound', + value: Array.from({ length: 24 }, (_, i) => section(i - 4, 7)) + } + }, Heightmaps: { type: 'longArray', value: Array.from({ length: 256 }, () => [0, 1]) } } } as never) @@ -2337,6 +2490,15 @@ export async function runWorldsSmoke(): Promise { const total = Date.now() - t0 const slice = tilesMod.lastParseSliceMs() if (slice <= 0) return fail('the sliced parse never ran; the fixture was not read') + // The fixture has to have produced a real surface, or the timing above is + // measuring the parse of something that renders to nothing — which is + // exactly how this gate came to certify a 584 ms freeze as 3 ms (#157). + // Asserting the interesting case was REACHED, not merely that the loop ran. + const drawn = tilesMod.peekChunkTile(SID, 'overworld', 0, 0) + if (!drawn) return fail('the fixture rendered no tile; the timing measured an empty parse') + if (drawn.colour.some((c) => c < 0)) { + return fail('the fixture left columns unresolved; it is not a full surface') + } if (slice > 60) return fail('a parse slice blocked for ' + slice + ' ms; the interface will stutter') console.log( 'WORLDS-SMOKE: region parsed in ' + total + ' ms, longest uninterrupted slice ' + slice + ' ms' diff --git a/src/shared/regionFormat.ts b/src/shared/regionFormat.ts index 3e00643..de74e3b 100644 --- a/src/shared/regionFormat.ts +++ b/src/shared/regionFormat.ts @@ -105,37 +105,158 @@ export function unpackIndices( ): number[] { const out = new Array(count).fill(0) if (bits <= 0 || !longs.length) return out - const mask = (1n << BigInt(bits)) - 1n - const asUnsigned = (v: bigint): bigint => BigInt.asUintN(64, v) + const src = prepareIndices(longs, bits, packing) + for (let i = 0; i < count; i++) { + if (!indexInRange(src, i)) break + out[i] = indexAt(src, i) + } + return out +} - if (packing === 'padded') { - const perLong = Math.floor(64 / bits) - for (let i = 0; i < count; i++) { - const longIndex = Math.floor(i / perLong) - if (longIndex >= longs.length) break - const shift = BigInt((i % perLong) * bits) - out[i] = Number((asUnsigned(longs[longIndex]) >> shift) & mask) +/** + * Longs prepared for repeated single-index reads. + * + * The reason this exists rather than a plain `number[]` of every index: a + * surface scan reads the top layer of a section, then the next one down, and + * stops as soon as every column has an answer — usually after two or three of + * the sixteen. Unpacking all 4096 up front did the other thirteen layers' + * work for nothing AND allocated a 4096-element array per section, which + * together were 562 ms of the 1030 ms a region spent here and a sixth of the + * process's garbage (#157). + */ +export interface IndexSource { + /** High and low 32-bit halves of each long. Empty when `big` is in use. */ + hi: Int32Array + lo: Int32Array + /** Set only for palettes too wide for 32-bit arithmetic — see below. */ + big: bigint[] | null + bits: number + mask: number + /** Indices per long under `padded`; 0 when spanning. */ + perLong: number + spanning: boolean + longs: number +} + +export function prepareIndices( + longs: bigint[], + bits: number, + packing: IndexPacking +): IndexSource { + const spanning = packing === 'spanning' + // Above 31 the 32-bit extraction cannot hold the answer. No real palette gets + // near it — 4096 blocks in a section is 12 bits — but a corrupt one must not + // read as a wrong index, so it keeps the arbitrary-precision path rather than + // truncating silently. + if (bits > 31) { + return { + hi: new Int32Array(0), lo: new Int32Array(0), big: longs, bits, + mask: 0, perLong: spanning ? 0 : Math.floor(64 / bits), spanning, longs: longs.length } - return out } + /** + * Each long split into two 32-bit halves, ONCE. + * + * The BigInt version did a shift, a mask and a `Number()` per index, at about + * 120 ns each. BigInt is arbitrary-precision and allocates; the 32-bit + * integer ops below do not. The conversion is still BigInt, but it happens + * per LONG (256 a section) rather than per index (4096). + */ + const n = longs.length + const hi = new Int32Array(n) + const lo = new Int32Array(n) + for (let i = 0; i < n; i++) { + const u = BigInt.asUintN(64, longs[i]) + lo[i] = Number(u & 0xffffffffn) | 0 + hi[i] = Number(u >> 32n) | 0 + } + return { + hi, lo, big: null, bits, + mask: bits === 31 ? 0x7fffffff : (1 << bits) - 1, + perLong: spanning ? 0 : Math.floor(64 / bits), + spanning, + longs: n + } +} - for (let i = 0; i < count; i++) { - const bitPos = i * bits - const longIndex = Math.floor(bitPos / 64) - if (longIndex >= longs.length) break - const offset = bitPos % 64 - let value = (asUnsigned(longs[longIndex]) >> BigInt(offset)) & mask - // Straddling: take the remaining high bits from the next long. - if (offset + bits > 64 && longIndex + 1 < longs.length) { - const taken = 64 - offset - const rest = asUnsigned(longs[longIndex + 1]) & ((1n << BigInt(bits - taken)) - 1n) - value |= rest << BigInt(taken) - } - out[i] = Number(value & mask) +/** + * Whether index `i` is backed by a long at all. + * + * The old loop `break`s when it runs past the end, leaving the rest of the + * output at zero. Callers that read one index at a time need the same answer + * without a loop to break out of. + */ +export function indexInRange(src: IndexSource, i: number): boolean { + const li = src.spanning ? ((i * src.bits) / 64) | 0 : (i / src.perLong) | 0 + return li < src.longs +} + +/** + * One index, `bits` wide. + * + * The straddle is the case worth naming: "padded" means an index never spans + * two LONGS, not that it never spans the two halves of one. At 5 bits index 6 + * starts at bit 30 and runs to 34, so it takes two bits from the low half and + * three from the high one. Reading only one half there gives a plausible wrong + * index, which is the failure this format specialises in. + */ +export function indexAt(src: IndexSource, i: number): number { + const { bits, mask, hi, lo } = src + if (src.big) return indexAtBig(src, i) + if (!src.spanning) { + const li = (i / src.perLong) | 0 + if (li >= src.longs) return 0 + const offset = (i % src.perLong) * bits + if (offset >= 32) return (hi[li] >>> (offset - 32)) & mask + if (offset + bits <= 32) return (lo[li] >>> offset) & mask + return ((lo[li] >>> offset) | (hi[li] << (32 - offset))) & mask } - return out + const bitPos = i * bits + const li = (bitPos / 64) | 0 + if (li >= src.longs) return 0 + const offset = bitPos % 64 + if (offset + bits <= 64) { + if (offset >= 32) return (hi[li] >>> (offset - 32)) & mask + if (offset + bits <= 32) return (lo[li] >>> offset) & mask + return ((lo[li] >>> offset) | (hi[li] << (32 - offset))) & mask + } + // Spanning two longs: the low bits finish this one, the rest start the next. + // `taken` is under `bits` here, so the shift stays inside 32 bits, and + // `offset` is above 33 for any width this path handles. + const taken = 64 - offset + let value = (hi[li] >>> (offset - 32)) & ((1 << taken) - 1) + if (li + 1 < src.longs) { + const rest = lo[li + 1] & ((1 << (bits - taken)) - 1) + value |= rest << taken + } + return value & mask } +/** The arbitrary-precision read, for palettes too wide for 32-bit arithmetic. */ +function indexAtBig(src: IndexSource, i: number): number { + const longs = src.big as bigint[] + const bits = src.bits + const mask = (1n << BigInt(bits)) - 1n + const asUnsigned = (v: bigint): bigint => BigInt.asUintN(64, v) + if (!src.spanning) { + const li = Math.floor(i / src.perLong) + if (li >= longs.length) return 0 + return Number((asUnsigned(longs[li]) >> BigInt((i % src.perLong) * bits)) & mask) + } + const bitPos = i * bits + const li = Math.floor(bitPos / 64) + if (li >= longs.length) return 0 + const offset = bitPos % 64 + let value = (asUnsigned(longs[li]) >> BigInt(offset)) & mask + if (offset + bits > 64 && li + 1 < longs.length) { + const taken = 64 - offset + const rest = asUnsigned(longs[li + 1]) & ((1n << BigInt(bits - taken)) - 1n) + value |= rest << BigInt(taken) + } + return Number(value & mask) +} + + /** * Blocks that are not surface. * diff --git a/src/shared/tileCache.ts b/src/shared/tileCache.ts index fc8cadd..3e66679 100644 --- a/src/shared/tileCache.ts +++ b/src/shared/tileCache.ts @@ -5,11 +5,11 @@ * mis-reads its own file produces a *plausible* map rather than an error, and * the cache then serves that wrong picture until somebody deletes it by hand. * - * Why it is worth having at all, measured on a real world: one 5.4 MB region of - * 811 chunks costs 180 ms to decompress and NBT-parse before any surface work, - * and #119's cache was a Map in memory — so every restart paid it again. The - * encoded form of the same region is about 1 MB, gzips in 2-3 ms and gunzips in - * about 1. + * Why it is worth having at all: a 4 MB region of 1024 chunks costs about 1.5 s + * to decompress, NBT-parse and extract surfaces from (#157 measured it — it was + * 14 s before that), and #119's cache was a Map in memory, so every restart + * paid it again. The encoded form of the same region is about 1 MB and reads + * back in roughly 16 ms. */ import type { StructureKind } from './regionFormat' From 1b25fe158bb34719b947efb15a8684a1ed7cddf7 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 5 Aug 2026 20:44:12 +0300 Subject: [PATCH 2/2] Review: skip the colour lookup too, and size the slice for a cold JIT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/main/core/worldTiles.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/core/worldTiles.ts b/src/main/core/worldTiles.ts index 9462b42..8fc7453 100644 --- a/src/main/core/worldTiles.ts +++ b/src/main/core/worldTiles.ts @@ -398,16 +398,11 @@ export function tileFromChunk(chunk: any, dim = 'overworld'): ChunkTile | null { const short = n.replace(/^minecraft:/, '') return !short || INVISIBLE.has(short) || seeThrough(short) }) - const packedColour = names.map((n) => { - const c = blockColour(n.replace(/^minecraft:/, '')) - return (c.r << 16) | (c.g << 8) | c.b - }) - // A section a map sees nothing in — and above the surface that is most of // them, fifteen or so single-entry air palettes per chunk. Skipped HERE, - // before the index array is unpacked and before anything is allocated, - // because the point is not to make those sections cheaper but to stop - // touching them at all. + // before the colours are looked up, before the indices are unpacked and + // before anything is allocated, because the point is not to make those + // sections cheaper but to stop touching them at all. if (invisible.every(Boolean)) { // Except under a roof, where an air section is not nothing: it is the gap // the scan is looking for. Recorded for every column in one pass instead @@ -418,6 +413,12 @@ export function tileFromChunk(chunk: any, dim = 'overworld'): ChunkTile | null { continue } + // Only now, for the sections that can actually contribute a colour. + const packedColour = names.map((n) => { + const c = blockColour(n.replace(/^minecraft:/, '')) + return (c.r << 16) | (c.g << 8) | c.b + }) + // A section whose palette is one entry has no data array at all — it is // 4096 of that block, which is how a solid stone or all-air section is // stored. Reading `data` there would skip the section entirely. @@ -599,12 +600,17 @@ function parseSlot( * for around 600 ms each, ten times the limit the smoke asserts — it did not * catch it because its fixture had no sections to render. * - * A region is now about 1.5 s here, so 8 slots is 12 ms — under a frame with - * room for a machine several times slower, which is the machine that complained. - * The price is 128 event-loop turns per region instead of 32, and a + * A region is now about 1.5 s here, so 4 slots is around 6 ms in steady state. + * 8 would be enough for that, and is not enough for the FIRST region a process + * parses: measured over five runs, the first blocks for 40 ms against 15-19 ms + * for every one after it, because V8 has not optimised these loops yet. The + * first is the one an operator meets — they open the map, and it is the only + * parse that has happened. Sized for that rather than for the average. + * + * The price is 256 event-loop turns per region instead of 32, and a * `setImmediate` costs microseconds. */ -const SLICE_SLOTS = 8 +const SLICE_SLOTS = 4 function loadRegion( serverId: string,