Skip to content

World generation baseline, measured for the decoupling plan ​

Read this first. Every millisecond and chunks-per-second figure in this note is a wall-clock diagnostic taken on a shared machine. Per AGENTS.md none of them is evidence that a change is faster, that a target is met, or that it is not. Statements below that a target was "met" or "not met" rest on such figures and should be disregarded. The counted results (task counts per chunk, tasks started ahead of a player's chunk, digests, test outcomes) stand.

Measured 2026-11-09 on an Apple M1 Pro (8 cores: 6 performance, 2 efficiency) with Node v26.12.1, for slice 0 of worldgen-decoupling.md. Every figure carries the load average from uptime taken at the start of its run. The machine is shared with other sessions, and every run here had a load average between 3 and 5 while it ran, so treat the figures as indicative. Nothing here was estimated.

Chunks per CPU-second ​

bench:chunks with the stages arm, which is the pool the server runs: 64 chunks in a 8 by 8 block, seed 1234, one warmup and three samples, shared climate tables. Chunks per CPU-second is chunks completed over the summed worker thread CPU time of all tasks, including each worker's first task, which builds its pipeline.

WorkersChunks per second (p50 run)Chunks per CPU-second, three runsLoad average at start
111.012.25, 12.24, 12.224.77
218.810.69, 10.66, 10.743.90
423.76.99, 6.91, 7.083.55
625.35.70, 5.81, 5.364.14

The provisional target of 3 chunks per CPU-second is met at every worker count, and the one-worker figure is far above ADR 0002's old 2.3 chunks per second on one thread. The useful finding is the trend: CPU cost per chunk grows as workers are added. A features task averages 8.5 ms of CPU with one worker and 33.5 ms with six, and a terrain task goes from 29 ms to 49 to 64 ms. Wall time per task grows the same way. Six workers on this machine share six performance cores with a load average of about four, so part of this is other sessions, and part is memory and cache contention between isolates. The table does not separate them.

Task durations (worker wall time) ​

From the same runs. Shares are of tasks longer than 10 ms.

WorkersStatus and bandTasksMean wallMax wallLonger than 10 ms
1terrain, immediate19229.3 ms35 ms100%
1terrain, soon24030.9 ms158 ms100%
1features, immediate1928.5 ms31 ms11%
1features, soon1089.8 ms36 ms20%
6terrain, immediate19255.8 ms165 ms100%
6terrain, soon24074.4 ms430 ms100%
6features, immediate19236.5 ms160 ms100%
6features, soon10829.7 ms103 ms100%

The server's real path runs only terrain and features as pool tasks. Every terrain task is longer than 10 ms at any worker count. features is short with one worker and long with six.

Tick cost of generation ​

bench:bot-fleet -- --terrain real, with the preload phase: a 17 by 17 region requested at idle all at once, 200 ticks per arm, six workers (the default), once with the region's tasks held back and once at full speed. Both arms use the same server. The bot fleets have disconnected by then; see the note on the stall below.

RunLoad average at arm startTick p99, held backTick p99, full speedDifferenceLateness p99, held backLateness p99, full speedChunks loaded
13.0414.16 ms8.58 ms-5.57 ms85 ms393 ms289
23.007.69 ms15.16 ms+7.48 ms75 ms322 ms289
Control, held back in both arms5.0212.24 ms9.29 ms-2.94 ms83 ms268 ms0

What the table says:

  • The 99th percentile of the tick's own duration swings by about 6 ms in either direction between runs, and the control run, which does no generation, swings by 3 ms. The noise floor on this machine is several times the 1 ms target, so the target cannot be confirmed or refuted with these runs. The mean tick duration was lower with generation running in both runs (3.6 ms to 2.0 ms and 3.1 ms to 2.0 ms), which says the tick's own work does not grow.
  • Lateness, the gap between a tick's due time and its start, is where generation shows. It rose from about 80 ms to 320 to 390 ms at the 99th percentile. The control run also reached 268 ms with no generation, so the same caveat applies, but the full-speed arm was highest in both runs. The tick's own work did not grow, so the delay is time the event loop spent on something else between ticks. The likely candidates are receiving and deserializing worker results and garbage collection, and the run does not say which.
  • With the queue held back in both arms, the difference read -2.94 ms for tick duration, not zero. That is the noise, and the plan's "reads zero within noise" check is met only in the sense that the noise is as large as any effect seen here.

Main-thread cost per completed chunk, from the completion queue's labels: worker-result 0.473 ms and 0.459 ms per chunk in the two runs. The preload calls the generator directly, so the registration, lighting, fluid and entity labels are absent; the join phase exercises those.

The queue wait is measured where it happens ​

GenerationPool.test.ts blocks a one-worker pool for 300 ms with a spinning task and asks for an immediate task behind it. The recorded backlog wait lands between 240 and 480 ms. The pool records the worker's own wall and CPU time per task beside the backlog wait.

What did not work, and the cause ​

The preload froze the tick every time while the bot fleets were connected. The tick stood in World.entityUpdates on a Player whose getBlock awaited a chunk, with nothing outstanding in the generation pool. The cause was in the generator, not the pool:

  • GenerationQueue, the admission queue of depth equal to the worker count, was strictly first in, first out and ignored urgency. A player's immediate chunk waited behind every earlier idle or soon request.
  • Its slots could be held by background builds. The benchmark's pause held their pool tasks, so no slot ever freed and the player's chunk was never admitted. Diagnosed from a dump of the queue: six in flight, 289 queued, the pool holding 50 paused tasks.
  • A chunk's neighbor terrain and feature tasks were requested at soon unless someone had asked for that neighbor, so even an admitted immediate chunk waited behind unrelated soon work.

Fixed: the queue admits by urgency and promotes, immediate requests are admitted past the depth, dependency tasks inherit the best band among the chunks that read them, and the governor leaves one worker free. The pool's pause and resume are removed, and the preload's idle arm now asks for nothing.

These are checked by counts, not timings, in schedulingOrder.test.ts: how many background tasks start between a player's request and the start of its last task. With inherited bands and the depth bypass it is bounded by what the workers already held (at most 7 with 6 workers). Without inheritance it is 143, set by the admission depth (six builds of 25 tiles), and without the bypass it is larger again. Those numbers do not move with machine load.

A mistake in the benchmark, found late. generateChunk's fourth parameter is config and urgency is the fifth. The preload phase passed its urgency as the fourth, so every preload request was immediate, not idle as this note says. Every preload figure above this section (tick cost, lateness, the host comparison, and the fleet-connected stalls of 2.5 to 6 s) measured a flood of immediate requests competing with the bots' chunks. They are kept as history and should not be read as a background preload. The fix is in the benchmark, and the ordering fixes above are still right: the counts do not depend on it.

After both fixes, one fleet-connected run with a real idle preload (load average 12 to 15, so indicative only): the worst tick was 472 ms, against 2.5 to 6.2 s before. Mean tick 25.7 ms with nothing requested and 58.0 ms with the region requested, p99 184 ms and 220 ms. The mean is raised by CPU contention on a loaded machine. The 1 ms target is neither met nor testable here.

Reproduction ​

sh
pnpm --filter @jsprismarine/prismarine run bench:bot-fleet -- --terrain real
pnpm --filter @jsprismarine/prismarine run bench:bot-fleet -- --terrain real --preload-control
node --conditions=jsprismarine:dist benchmarks/runExternal.mjs benchmarks/chunkGeneration.ts <workers> 64 stages 8 2 3 shared - local 32 1

Slice 1: what the server thread does for each completed chunk ​

Read from DataDrivenGenerator.pooledChunk, deferChunkCompletion, World.readChunkInto and ChunkCompletionQueue. For a chunk a worker has finished, the server thread runs these in order. Costs are from the join phase of bench:bot-fleet -- --terrain real --no-preload: 165 chunks, all immediate because players were waiting on them, load average 4.9 to 6.4 (second run; the first, with 164 chunks, read within 3% of it).

LabelWhat runsPer chunkMoves off the thread?
worker-resultAdopt the worker's region, then translate Java block states to Bedrock runtime ids and build the Chunk (toChunk)3.15 msThe largest piece. The mapping tables live on this thread, so it needs the wire-ready worker payload the plan describes. Not done.
chunk-lightingsystems.onChunkLoaded, light initialization0.89 msCandidate for a worker; packages/lighting is separate. Not done.
chunk-registerpackBlockStorage, the chunk map, player reference counts, portal index, entity-cache invalidation0.70 msMust stay: it publishes the chunk.
chunk-fluidsScan for fluid that borders air and schedule its ticks0.61 msNeeds no worker; it can wait. See below.
chunk-entities/wallRegister stored and generated entities, wall time0.04 msCheap.

The five add up to about 5.4 ms of server thread per chunk. Also on the thread and not in the table: receiving each worker result, which is deserialization plus a promise reaction on the event loop, and the network encode and send. Slice 0 saw lateness rise by about 250 ms at the 99th percentile during a 289-chunk preload while the tick's own work did not grow, which points at that receive path, but the labels do not cover it.

Per-chunk worker-result cost read 0.46 ms in the preload and 3.15 ms in the join phase. The join phase starts cold, so the mean carries first-use costs for tables and JIT. The preload number is closer to warm cost, and the table above likely overstates steady state. This is not resolved.

What changed. The fluid scan of a background chunk (soon or idle) now waits in the completion queue's lanes, behind lighting, instead of running as part of the load. A chunk a player is waiting on still runs it in place. A test covers both. Output is unchanged: bench:chunks with six workers on the stages arm produced the identical output digest before and after (37b61888…bf1a).

What did not change, and the acceptance result. The plan asks for each moved piece to drop off the server thread and the main-thread cost per completed chunk to fall. In the measured scenario it did not: every chunk was immediate, so the fluid scan still ran in place at 0.61 ms, and the 165-chunk totals before and after agree within noise (fluids 0.62 and 0.61 ms; worker-result 3.23 and 3.15 ms). The deferral bounds the burst a background preload adds, which I did not measure. The two largest pieces, translation and lighting, stay on the thread because moving them is a redesign of what a worker returns and what runs where, and I did not get to it. No client-e2e run was made for this change. Slice 1's target is therefore not met.

Slice 2: the generation host process ​

chunk-host: process runs the stage workers in a forked generation host at a lowered priority. The default stays in-process, for the reasons under "Deviations" below.

Identical output across transports. bench:chunks with six workers, 64 chunks, seed 1234, the real pipeline and registries: the stages arm (threads in the server process) and the stages-host arm (workers in the host) both produced output digest 37b61888638f…. The same digest as before slice 1's change. The unit tests also compare bytes through both transports over a grid of chunks in two different orders.

Throughput and server CPU. Same command, three samples each, load average at start 2.5 for threads and 5.3 for the host, so the comparison is confounded by load.

ArmChunks per second (p50 run)Chunks per worker CPU-secondServer process CPU per 64-chunk run
stages, threads25.55.64, 5.78, 5.6617.5 to 18.1 s, workers included
stages-host23.76.10, 5.64, 5.640.83 to 0.93 s

The server process's CPU falls from about 18 s to under 1 s because the workers moved out of it. The 0.9 s that remains is receiving results, translating them and the harness, about 14 ms per chunk. The 7% lower throughput with the host is inside what the load difference could explain.

Cost of the pipe hop. A one-worker pool returning a buffer of the given size, 300 tasks after 20 warmup tasks, load average 6.1:

PayloadIn process, wall per taskHost, wall per taskHost, server CPU per task
1 KiB0.047 ms0.095 ms0.027 ms
192 KiB0.101 ms0.287 ms0.215 ms
1 MiB0.235 ms0.792 ms0.702 ms

At the size of a column the hop adds about 0.19 ms of wall time and about 0.2 ms of server CPU per task. The in-process CPU column is not comparable because it includes the worker thread's work.

Tick cost of generation with the host, same preload as above, two runs on separate builds (the second with the governor driver running):

RunLoad average at arm startTick p99 differenceLateness p99 held backLateness p99 full speed
Host3.99-1.64 ms92 ms253 ms
Host, governor wired4.53-2.37 ms67 ms256 ms
Threads3.41-1.57 ms75 ms299 ms

These do not separate the host from threads. Lateness rose by about 160 to 225 ms in each. A lowered priority on a machine with six performance cores for six workers, a main thread and some other sessions does not change what delays the event loop here, which is work on the main thread and not competition for a core.

Checked.

  • The host's priority reads as lowered on macOS: os.getPriority(pid) is above zero in the test and the server's own stays at zero.
  • A host killed with SIGKILL in the middle of a 400 ms task is replaced and the task returns the same bytes.
  • A host entry that exits at startup falls back to the in-process pool and reports why.
  • destroy leaves no process behind (process.kill(pid, 0) throws).
  • cancel drops a waiting task with the GenerationCanceledError class intact, and a held task is dropped without asking the host.

Not checked. The host's priority on Linux and Windows. A second Ctrl+C during a preload exiting: the host ignores SIGINT and exits when its channel closes, but I did not run it. A client joining through the client-e2e skill with chunk-host: process. A compiled single-file executable, which cannot start the host role yet and keeps the in-process pool.

Deviations from the plan. chunk-host defaults to in-process where the plan has the server pick the host when it can, because nothing here ran an end-to-end client against it. The host runs the stage pool, which is the path the server uses, and not the sharded whole-chunk pool. The workerHeapStats and workerStartupStats benchmark buffers are shared memory and do not reach the host.

Slice 3: the concurrency governor ​

ConcurrencyGovernor starts at half the workers, rises by one after each calm second, and halves when a tick starts more than 25 ms late or the control group was throttled since the last reading, with a two second hold-off before it rises again. immediate tasks never count against it. GenerationGovernorDriver feeds it every tick from the tick recorder and reads the throttle counter once a second. The server runs it when chunk-host is process.

Checked. Unit tests drive the controller and the driver with a fake clock through a calm period, a burst of late ticks and a recovery, assert the active count, and assert that the pool never limits immediate tasks or holds them. The parsers have tests for both layouts.

The control group layout, read from the kernel documentation on 2026-11-09. In the cgroup v2 document, cpu.stat is a flat keyed file of KEY VALUE lines whose durations are in microseconds. It always has usage_usec, user_usec and system_usec, and with the controller enabled also nr_periods, nr_throttled, throttled_usec, nr_bursts and burst_usec. Those five are non-hierarchical: they count throttling from the group's own limit and not from an ancestor's, and cpu.stat.local has the throttling a group's runqueues actually saw. cpu.max is $MAX $PERIOD, max for no limit, default max 100000. A process's version 2 group is the 0::$PATH line of /proc/$PID/cgroup. The bandwidth control document gives the version 1 cpu.stat fields nr_periods, nr_throttled and throttled_time, in nanoseconds. The sampler reads nr_throttled for both.

Could not verify. Anything on Linux: this machine runs macOS, so the sampler returned nothing and the throttle path ran only against fake files. The documented ancestor caveat means a process inside a nested group whose ancestor holds the quota would not see its counter rise, and the code does not read cpu.stat.local or walk up to the ancestor. The tick-isolation run in a container with a quota at half the machine was not made: the Docker daemon was not running. With the governor wired I could not see it act in the preload run, because the benchmark does not report the limit; the preload ran all 289 chunks in every arm.