Skip to content

Measured Results

TheMeinerLP edited this page Aug 2, 2026 · 3 revisions

Measured results

Every measured table this project publishes, each with the benchmark class and methods its rows came from, the parameter values and run configuration behind them, and what the ± does and does not cover. It deliberately explains nothing — how the loader and the engine work is Anvil Chunk Loader and Light Engine, and how to re-perform any of these runs is Benchmarking — and it is written for someone auditing a number rather than for someone using the library.

Where a measured table appears on more than one page, the copy below is the one that is right, and a correction is made here first and propagated outwards. Not every table in the wiki is here: Anvil Chunk Loader publishes an independent second run of the loader table that appears nowhere else, and the two-fork four-thread control beside it — the only published run whose ± covers more than one JVM launch. A page carrying a run of its own says so where the table stands. The rest of the working record — the structural facts, the decisions these runs fed, the defects found and fixed, and what is still open — is Project Status.

Read this before quoting a number. Everything measured here comes from one machine, on one JVM, under one load, and none of it is an absolute statement about either implementation. What the ± covers is defined once in Rationale: Measurement; how to re-run any of it is in Benchmarking.

Contents

  • Measured — the measurement environment, then every published table with its provenance
  • References

The eight measured sections under Measured, in order, with the environment they were taken in first and the decisions they produced last. Seven of the eight carry a table; scaling by world height publishes three claims and no table, and says so at the point where the table would be:

the measurement environment

save stages · region file against Minestom · light engine against Minestom · mixed brightness · scaling by world height · area against per-chunk · replayed change against full · stages of the light path

optimisations these numbers produced


Measured

Every table in this section carries a provenance line naming the benchmark class and methods, the parameter values, the run configuration and what the ± covers. Reproduce any of them with ./gradlew :falco-benchmarks:jmhJar and the class name — except for the parts of Where the time goes in the light path that are marked as coming from a standalone rebuild, which is not JMH and cannot be re-run from this repository.

Measurement environment

CPU TBD — model, physical cores, SMT on or off. Recorded only as "a 16 core machine", which does not say whether that is 16 physical cores or 8 with SMT
Frequency policy TBD — turbo on or off, governor
RAM TBD
Storage and file system TBD — the loader tables all touch it
OS and kernel TBD
JVM TBD — vendor, full version string, garbage collector. The toolchain is Java 25; the JVM that ran the benchmarks is not recorded anywhere
Machine state not idle
Git commit per table TBD for five of the eight. Recorded only for the two light comparison tables and the light stage table: 69381af and its predecessor for the UNIFORM and stage tables, 0e8fbb5 onwards for MIXED
Run date per table TBD — recorded for none of the eight
Raw results.json not committed for any published table

What follows from the empty rows. Absolute microseconds below are a property of that unrecorded machine and nothing else. Ratios between two methods measured in the same run are the durable part, which is why the tables are read as ratios and why the two cross-run repeats that reproduce a ratio — the nine-chunk re-run under Lighting an area against one neighbourhood per chunk and the earlier run under A replayed block change against lighting the chunks again — are the most useful evidence on this page for reading the tables that way: ratios reproduce, third digits do not. Those are two of the four cross-run repeats this project has; the other two are on the loader table and establish an asymmetry rather than a ratio, and all four are counted under Against the engine Minestom ships with. Background load on a machine that was not idle is also a live candidate explanation for the widest error bars here, which are all in the loader contention table.

One heap gap worth naming. Every benchmark class pins -Xms equal to -Xmx in jvmArgsAppend except AreaVsPerChunkBenchmark and IncrementalVsFullBenchmark, which set no heap flags at all. Those two are also the two that build a world of real Minestom chunks, so they have the largest live set and the most garbage-collector sensitivity in the suite. Their numbers rest on a default, unrecorded heap configuration.

Where the time goes when saving a chunk

Stage Time Lock held Kind
Snapshot 64 µs chunk read lock measured (snapshot)
Codec, without compression 1 356 µs none measured (codecWithoutCompression)
NBT serialisation and zlib compression 2 701 µs none derivedcodeccodecWithoutCompression
Transfer 17 µs region lock measured (transfer)

ChunkSaveStageBenchmark.snapshot / .codec / .codecWithoutCompression / .transfer, distinctStates = 200, 24 sections (BenchmarkConstants.OVERWORLD_SECTIONS), one thread, one fork, 5 warmup and 5 measurement iterations of 1 s per the class annotations — no command line was recorded for this run, -Xms1g -Xmx1g, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. The run printed no ± for these rows and none was recorded, so no significance test can be applied to them and no ratio between them is defensible. One fork: an interval, had one been recorded, would cover variance between iterations of one JVM and not between JVM launches — see Rationale: Measurement.

Half the class is unpublished here too. ChunkSaveStageBenchmark declares @Param({"8", "200"}) and five methods, which is ten configurations; the four rows above are the 200 level of four of them. The 8 level is not published, and neither is full() — the fifth method, which exists so the sum of the stages can be checked against a whole save.

The third row is a difference of two benchmarks, and it does not measure what its old label said. codec() is compress(serialize(encode(…))) while codecWithoutCompression() is encode(…) alone — it returns a CompoundBinaryTag and never serialises it. The difference therefore contains the NBT serialisation and the zlib pass, not zlib alone. The value 2 701 µs is correct for what it actually measures; only the label was wrong.

The structural claim is the strong one here, and it needs no benchmark at all. FalcoAnvilLoader holds the chunk read lock only across the Section.clone() loop and the block-entity collection (FalcoAnvilLoader#snapshot), releases it, and encodes and compresses on the clones; the region lock is taken by RegionFile#writeRaw for the transfer alone. Anyone can verify that by reading the file. The table's job is to say how much of the work sits in the lock-free part, and on these four rows that is 1 356 + 2 701 against 64 + 17.

Two percentages that used to stand here have been withdrawn pending their arithmetic, and ChunkSaveStageBenchmark.full — the method that exists to check the stages against the whole save — has never been published. Publishing it would let a reader verify the decomposition instead of taking it. Until then, what this table supports is an ordering rather than a share: the two lock-free stages are the large ones and the two locked stages are the small ones — 1 356 and 2 701 µs against 64 and 17 — and compression-plus-serialisation is the larger of the two lock-free stages. No ratio between these rows is quoted, because none of them carries an interval. That ordering is what made compression the optimisation target.

The region file against the one Minestom ships with

Reading and writing one chunk, µs/op. The read column carries a verdict rather than a factor, because two of its four rows do not support one.

Threads Falco read Minestom read Read verdict Falco write Minestom write
1 1 203 ± 123 1 060 ± 55 intervals overlap 2 659 ± 212 2 601 ± 100
2 1 181 ± 31 2 200 ± 445 1.9× faster in this run — not reproduced 2 637 ± 204 2 643 ± 153
4 1 378 ± 84 11 021 ± 16 470 no factor admissible 2 672 ± 43 2 678 ± 115
8 2 438 ± 252 530 905 ± 1 928 261 no factor admissible 3 098 ± 658 3 102 ± 245

RegionFileComparisonBenchmark.falcoRead / .minestomRead / .falcoWrite / .minestomWrite, distinctStates = 200, one thread count per row passed as -t (JMH takes one per run, so this is four runs, not one), one fork, 3 warmup and 5 measurement iterations, -Xms1g -Xmx1g, @State(Scope.Benchmark) with a nested per-thread slot, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. Iteration time: the class annotates time = 1 for both warmup and measurement. Earlier documentation of this table said 2 s, and no -r appears on the command line that was recorded, so the run's actual iteration time is unrecorded; the annotation says 1 s. ChunkSaveComparisonBenchmark is the class that really uses 2 s iterations. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

Read the four read rows against their intervals rather than against their means:

  • One thread: no difference is resolvable. Falco's interval is [1 080, 1 326] and Minestom's is [1 005, 1 115]; they overlap between 1 080 and 1 115. The means put Falco 14 % behind, but at this precision that gap is not resolved, and the row must not be quoted as "Falco is 1.14× slower" — nor as a tie. This is the row where Falco looks worse, and it gets the same treatment as the rows where it looks better. What the row does support is that no advantage is visible without contention, which is exactly what the design predicts: the claim was always about lock granularity, and with one thread there is no lock to contend for.
  • Two threads: Falco is faster in this run, and this row does not reproduce. [1 150, 1 212] against [1 755, 2 645] are disjoint, so the difference holds at this precision. Conservative bounds on the ratio are 1.45× to 2.30×; the wider of the two relative half-widths is 20 %, which permits one decimal, hence 1.9× faster rather than the 1.86× the means give. The second run of this table does not reproduce it. An independent run of the identical configuration, published in Anvil Chunk Loader, measured Minestom at 103 437 ± 856 306 µs/op at two threads — 47 times the mean printed above, and a half-width 8.3 times its own mean, which under the rules used here carries no factor at all. Falco's two-thread figure did reproduce, at 1 174 ± 71. The 1.9× is a property of this run and of nothing else; what survives both runs is the direction and the loss of predictability. The whole of what the two runs agree and disagree on is set out under the write rows below.
  • Four threads: the direction holds, no factor does. Minestom's half-width is 16 470 on a mean of 11 021 — 149 % of the mean, putting the lower end of its interval at −5 449. A measurement whose interval contains negative time does not constrain a ratio at any precision. This is the same test that disqualifies the eight-thread row, applied to the row above it.
  • Eight threads: the same, more so. The half-width is 363 % of the mean.

The four- and eight-thread rows are not a null result; they are the interesting one. What they show is that Minestom's read time stops being predictable under contention — it scatters over three orders of magnitude at eight threads — while Falco's half-width stays at or below a tenth of its own mean at every thread count in this table, 10.3 % at its widest. For a server, an unpredictable read is the worse failure of the two, and dispersion is a claim these rows genuinely support where a factor is not.

Writing shows no resolvable difference at any thread count. All four write pairs overlap, which is not the same as the two being equal and is not evidence that they are. The mechanism is what makes a tie the expected outcome: both implementations take a lock to allocate sectors and update the header, so the write path has no lock granularity to win on. Claiming otherwise would be easy and wrong.

This table was run a second time, and the two runs part company on Minestom's side only. The repeat is published in Anvil Chunk Loader at the same settings — same class, same methods, distinctStates = 200, one -t per row, one fork. Falco reproduces at every thread count: 1 089 ± 48, 1 174 ± 71, 1 370 ± 200 and 2 282 ± 248 against the four figures above, every pair of intervals overlapping. Minestom reproduces at one thread only, 1 045 ± 112 against 1 060 ± 55. Above it, the repeat gives 103 437 ± 856 306, 302 704 ± 674 429 and 297 075 ± 593 563 where this table has 2 200 ± 445, 11 021 ± 16 470 and 530 905 ± 1 928 261 — a mean 47 times the published one at two threads, and on every one of the three a half-width larger than its own mean. That asymmetry is the more durable result, and it is worth more than the factor it costs us. Falco's read time is reproducible across JVM launches and Minestom's is not, above one reader — which is the same statement the half-widths make within either run, now made across two of them. What neither run establishes is a magnitude.

A third run of the four-thread row exists at two forks, and it is the only published Falco number whose ± covers more than one JVM launch. Recorded in Anvil Chunk Loader: at four threads, two forks and ten measurement iterations, Falco 1 325.6 ± 21.1 µs/op against Minestom 359 690.8 ± 97 498.3 µs/op. It belongs next to the four-thread row above, and it says three things about it. Falco's figure reproduces — [1 304.5, 1 346.7] against this table's [1 294, 1 462], overlapping — across a configuration that samples JVM launches rather than only iterations, which is the objection every other table on this page is exposed to. Minestom's does not: 359 690.8 against 11 021 is a factor of 33 on disjoint intervals, from the same class at the same thread count. And the two-fork run is the one row in the project where Minestom's read is bounded well enough to divide by at all — its half-width is 27 % of its mean rather than 149 % — which puts the two sides conservatively between roughly 190 and 350 times apart in that run, the bound Anvil Chunk Loader states for it and no more precisely than that. That is a statement about one pair of JVM processes at one thread count and not a property of either implementation, because the operand it rests on moved by a factor of 33 between two runs of the same configuration. It is quoted here because it is the strongest single piece of evidence for the direction, not because it fixes a magnitude.

Two-fork control: RegionFileComparisonBenchmark.falcoRead / .minestomRead, distinctStates = 200, four threads (-t 4), two forks, ten measurement iterations — an override of the class annotations, which are one fork, 3 warmup and 5 measurement iterations of 1 s. The warmup count and iteration time of this run are not recorded, and neither is a full command line. -Xms1g -Xmx1g, JMH 1.37, same 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. At two forks and ten iterations the ± pools iterations across both JVM launches, so unlike every other interval on this page it samples run-to-run JVM variance — see Rationale: Measurement. The conservative ratio bounds are (b − eb) / (a + ea) to (b + eb) / (a − ea).

The comparison is fair by construction, not by assertion. RegionFileComparisonBenchmark#setUp produces the payload once with the Adventure writer both implementations use and hands the same bytes to both region files, so no part of this result is a compression level in disguise. That is a fact about the setup method, checkable without running it.

Only one of the two distinctStates levels is published. The class declares @Param({"8", "200"}), so a full run produces a second set of four read rows and four write rows at 8 states — the cheaper payload, and therefore the case in which the byte transfer weighs more against the inflate and the parse. Those eight rows are not in this document.

Scaling of the series, which no single row shows: from one thread to eight, Falco's read mean grows from 1 203 to 2 438 µs and Minestom's from 1 060 to 530 905 µs. Both are read across four separate runs, so the comparison between thread counts is between runs rather than within one, and the growth ratios inherit that.

Against the engine Minestom ships with

Before any timing is quoted: both engines produce the same bytes, and that is enforced twice. LightEngineEquivalenceTest runs the full cross product of LIGHT_SOURCES = {0, 1, 2, 4, 8, 16, 64, 128, 512} and OCCLUSION_PERCENTS = {0, 10, 30, 50, 70, 90} — 9 × 6 = 54 scenarios — on every build and asserts the 2048 bytes of each side are equal, plus a check that a section holding sources did not simply stay dark, which would make the comparison vacuous. LightEngineComparisonBenchmark.verifyBothEnginesAgree repeats the comparison in @Setup for every trial and every parameter combination and throws before a single measurement is taken if the arrays differ. Both are facts about the code, not measurements, and both are checkable by opening the two files. They close the most common way a benchmark lies — winning time by computing something else.

The measured side of the comparison is LightPropagator against the real BlockLight.buildInternalQueue and LightCompute.compute, reached by placing the benchmark class in net.minestom.server.instance.light because those two methods are package-private there. It is not a reimplementation of Minestom's engine.

Measured before and after 69381af in one session on the same machine, with a Minestom column as the control. The After column is the load-bearing one; the Before ratios do not divide out of the two columns beside them, which is worked through below the table. µs/op:

Sources Solid Falco before Falco after Minestom Before After
1 0 % 74.2 ± 1.9 44.5 ± 0.6 49.4 ± 1.3 1.42× slower 1.11× faster
1 30 % 61.8 ± 1.6 39.3 ± 0.8 62.0 ± 2.0 1.03× slower 1.58× faster
8 0 % 137.7 ± 7.4 98.3 ± 2.4 121.1 ± 5.5 1.18× slower 1.23× faster
8 30 % 144.7 ± 1.8 119.3 ± 3.5 204.2 ± 3.7 1.37× faster 1.71× faster
64 0 % 135.9 ± 2.8 109.2 ± 1.6 126.5 ± 5.6 1.08× slower 1.16× faster
64 30 % 152.7 ± 1.8 122.6 ± 1.3 206.6 ± 4.2 1.37× faster 1.68× faster

LightEngineComparisonBenchmark.falco / .minestom, one section, emissionMix = UNIFORM (every source a glowstone block), lightSources and occlusionPercent as in the first two columns, one thread, run as -f 1 -wi 5 -i 10 — an override of the class annotations, which are 3 warmup and 5 measurement iterations of 1 s — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, commits 69381af and its predecessor, run date not recorded, no results.json committed. Ten measurement iterations put the t multiplier at 4.78 rather than the 8.61 of a five-iteration run, so these intervals are the tightest on this page for a given dispersion. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

All six After rows are supported, and they should be read without hedging. Each Falco interval is disjoint from the corresponding Minestom interval — the closest pair is 1 source at 0 % solid, [43.9, 45.1] against [48.1, 50.7], which still do not touch. Taking the conservative bounds (b − eb) / (a + ea) to (b + eb) / (a − ea) on each row gives 1.07×–1.16×, 1.50×–1.66×, 1.15×–1.32×, 1.63×–1.80×, 1.09×–1.23× and 1.63×–1.74×. The headline "1.11× to 1.71× faster" is inside those bounds on every row. Every one of the six is inside the two-decimal band the rule in Rationale: Measurement allows — the wider relative half-width of every one of the six pairs is under 5 %, and the rule permits two decimals up to 10 % — so the printed factors stand as they are. The bounds are still the better form of each, and they are why the bounds column exists.

One row of the Before column does not support its factor, and it is worth saying so rather than letting it stand because it flatters the After column by contrast. At 1 source and 30 % solid the before figure is [60.2, 63.4] against Minestom's [60.0, 64.0] — overlapping, so 1.03× slower is not a resolved difference and the honest reading of that cell is "level". The other five Before rows are disjoint against the published Minestom column, so their direction is resolved.

Their magnitudes are not reproducible from the columns printed beside them, and a reader checking the table will find that in a minute. Divide Falco before by Minestom and not one of the six Before cells comes back: 74.2 / 49.4 is 1.50, not 1.42; 137.7 / 121.1 is 1.14, not 1.18; 204.2 / 144.7 is 1.41, not 1.37; 206.6 / 152.7 is 1.35, not 1.37; 135.9 / 126.5 is 1.07, not 1.08; and 61.8 against 62.0 puts Falco marginally ahead rather than 1.03× behind, so that cell's direction does not follow either. Five of the six miss by more than a rounding step. The After column reproduces exactly on all six rows, which is what identifies the published Minestom column as the one the After run was measured against. The likeliest explanation for the Before column is that its ratios were taken against a Minestom measurement from the earlier run that was never published — but that is a reading, not a record, and it is marked below rather than assumed. Until it is settled, the Before column carries a direction and an order of magnitude and nothing finer; the After column, whose arithmetic checks out against the two columns beside it, is the load-bearing one.

Minestom is the natural control for this pair of runs, because its code did not change across 69381af — that is what makes a before-and-after comparable at all. What is published is one Minestom column, and the After ratios were computed against it. A reader should in any case treat the Before and After Falco columns as two runs rather than one: they are separated by a rebuild, which means a fresh JVM, a fresh JIT plan and a fresh set of ergonomic decisions on each side.

Falco is ahead in all six scenarios after the change, against two before it — counting the Before rows whose intervals are disjoint from Minestom's in Falco's favour, which is the two 30 % solid rows, and not counting 1 source at 30 % where the two overlap. The lead on solid blocks grew rather than being traded for the empty rows. This is a result under the conditions in the provenance line — one section, one machine that was not idle, sources of equal brightness, one JVM launch — and not a general statement about either engine.

No repeat run of this table exists, and this page is the register of the ones that do. Four cross-run repeats exist in this project: the nine-chunk AreaVsPerChunkBenchmark re-run at Lighting an area against one neighbourhood per chunk, the earlier IncrementalVsFullBenchmark run at A replayed block change against lighting the chunks again, the second single-fork run of the whole loader table, and the two-fork four-thread RegionFileComparisonBenchmark control — the last two both recorded in Anvil Chunk Loader and discussed at The region file against the one Minestom ships with. None of the four is a repeat of this table. The Before/After pair above is a repeat of a different kind — the same benchmark across a code change, with Minestom as the control — which tests whether the change did what it claims and not whether the numbers are stable across JVM launches. Where another page counts these differently, this section is the one that is right.

The occlusion axis is a benchmark parameter, not a survey of real worlds. The lead grows with occlusionPercent: at one source it goes from 1.11× at 0 % solid to 1.58× at 30 %. The reason for measuring at 30 % is that a solid block ends a search early, so an entirely open section is the upper bound of the work rather than the typical case. Nothing here establishes what share of solid blocks a real chunk holds.

The earlier reading of the pattern was wrong and is worth recording. It said an empty section favours Minestom because our opacity table is built unconditionally, and a section with solid blocks favours us because the table is then read many times. The table is still built unconditionally; it now costs a quarter. The stage breakdown below also shows that Falco's search was already the faster of the two before the change — 33.85 µs against a figure for Minestom's search of 53.9 µs which is derived, not measured directly, because Minestom's search consumes the queue it is handed and cannot be run on a prepared one. The entire deficit came from the preparation, never from the algorithm.

With sources of mixed brightness

This is the honest case, and it is here because it is the one where Falco's lead shrinks.

LightEngineComparisonBenchmark gained an emissionMix parameter in 0e8fbb5. MIXED cycles the sources through glowstone, lantern, torch, redstone torch and magma block, which the registry gives 15, 15, 14, 7 and 3; positions are drawn identically to UNIFORM, so the levels are the only difference. µs/op:

Sources Solid Falco Minestom Verdict
8 0 % 118.97 ± 8.89 126.54 ± 9.55 intervals overlap
8 30 % 116.50 ± 7.63 201.46 ± 16.83 1.7× faster, bounds 1.49×–2.01×
64 0 % 150.42 ± 32.73 162.20 ± 3.11 intervals overlap
64 30 % 149.42 ± 12.64 252.26 ± 9.28 1.7× faster, bounds 1.50×–1.91×

LightEngineComparisonBenchmark.falco / .minestom, one section, emissionMix = MIXED, lightSources and occlusionPercent as in the first two columns, one thread, one fork, -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, from 0e8fbb5 onwards, run date not recorded, no results.json committed. No command line was recorded for this run. The class annotates 3 warmup and 5 measurement iterations of 1 s, and its javadoc recommends -p emissionMix=MIXED -p lightSources=8,64 -f 1 -wi 3 -i 5 for exactly these rows — which is five measurement iterations rather than the ten the UNIFORM table above was run at, and would put the t multiplier at 8.61 instead of 4.78. The half-widths here are wider than the UNIFORM ones on every comparable row, which is consistent with that but does not establish it. The lightSources = 1 level of this parameter combination is not published, although the class declares it — the class javadoc recommends running MIXED with -p lightSources=8,64, which is what these four rows are. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

A second run of the 64 / 0 % row, in both emission modes. Re-run on 2026-08-02, the same configuration puts Falco at 142.84 ± 5.44 against Minestom 164.84 ± 12.46 µs/op under MIXED, and at 107.59 ± 1.61 against 124.51 ± 5.88 under UNIFORM. Both pairs are disjoint, at 1.15× faster and 1.16× faster, the wider relative half-width being 7.6 % and 4.7 %; the conservative bounds are 1.03×–1.29× and 1.09×–1.23×. This replaces nothing and no digit above has changed: it is a different run on a different day and at different iteration settings, and the two agree — 142.84 lies well inside the published 150.42 ± 32.73.

LightEngineComparisonBenchmark.falco / .minestom, one section, emissionMix = MIXED and UNIFORM, lightSources = 64, occlusionPercent = 0, one thread, one fork, -Xms512m -Xmx512m, 3 warmup and 5 measurement iterations of 1 s, run as -p emissionMix=UNIFORM,MIXED -p lightSources=64 -p occlusionPercent=0 -f 1 -wi 3 -i 5, JMH 1.37, JVM 25.0.3+9-LTS, one 16-core machine at a load average of 1.66 and therefore not idle, commit ed83cad, results.json produced but not committed. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

What this settles, and what it does not. Falco's own cost rises 32.8 % from UNIFORM to MIXED on disjoint intervals, which is what the sentence withdrawn below claimed as "about a third" — that half reproduces, on an interval narrower than the published MIXED row carries, though not narrower than the published UNIFORM one and not measured at the same iteration settings as either. The lead, however, does not fall: 1.16× against 1.15×, where the withdrawn sentence had it falling from 1.30× to 1.06×. Neither of those two factors is supported by this run, and the 1.06× could not have been restored in any case, having been drawn from overlapping intervals.

Where the published MIXED row could not order the two at all, this one can — but it is a single run at one fork on a machine recorded as not idle, so it settles the direction and not the third digit, and its lower bound of 1.03× is close enough to parity to say so. What remains unresolved is where 112.7 came from: this run measures the UNIFORM cell at 107.59, the published table at 109.2, and neither is 112.7.

The two open-sky rows resolve no difference. At 8 sources, [110.1, 127.9] against [117.0, 136.1] overlap; at 64 sources, [117.7, 183.2] against [159.1, 165.3] overlap, and Falco's own half-width there is 22 % of its mean, the widest in either light table. Neither row is evidence that the two engines are equal — it is the absence of a resolved difference, not a measured tie — and neither may be quoted as a factor in either direction.

The two 30 % rows hold and are the same finding as in the UNIFORM table. The lead where solid blocks are present survives mixed brightness essentially intact; what mixed brightness takes away is the lead in the open-sky rows.

A position is queued more than once when sources of different brightness reach it, which is exactly the case a bucket queue is for; the standalone rebuild put a bucket queue 32–36 % faster on that case, which is a direction rather than a factor. The benchmark can now see it, which is what the decision under Investigated and deliberately not built was waiting for.

The cross-table comparison that used to stand here has been withdrawn because one of its two operands cannot be traced to a published table. What can be said from the tables as they stand: at 64 sources and 0 % solid, UNIFORM measures Falco at 109.2 ± 1.6 and MIXED at 150.42 ± 32.73. Those are two separate runs with different half-widths and the second one is wide, so the difference between them is a direction, not a percentage.

Scaling by world height

  • Block light is linear across the whole range — 33 µs per section at 1 section and at 256.
  • Sky light is not. Cost per section rises from 84 µs to 104 µs past roughly 64 sections.
  • A least-squares fit over the vanilla range (≤ 24 sections) predicts 21 423 µs of sky light at 256 sections. The measured value is 26 597 µs — the forecast understates it by 19.5 %. For block light the same method lands within 1.8 %.

ScalingBenchmark.blockLightBySectionCount / .skyLightBySectionCount, sectionCount ∈ {1, 2, 4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 128, 192, 256}, distinctStates not read by either method, one thread, one fork, 3 warmup and 3 measurement iterations of 1 s per the class annotations — no command line was recorded for this run — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. No table is published and no ± is recorded for any of these figures, so no significance rule applies to them. Three measurement iterations is the smallest sample in the suite: at two degrees of freedom the 99.9 % t multiplier is 31.6, more than three times the 8.61 of a five-iteration run, so an interval here would have been wide and a narrow one would have meant only that three iterations came out alike. One fork, as everywhere on this page — see Rationale: Measurement.

What each of the three claims rests on, separately. "Block light is linear" cites the two endpoints of a fifteen-point axis; linearity is a statement about the thirteen points between them, and those are not shown. The extrapolation figures are derived: they come from a fit whose residuals, coefficients and goodness of fit are not published, so 19.5 % and 1.8 % have no quantified uncertainty of their own and should be read as one significant figure. The arithmetic behind 19.5 % is (26 597 − 21 423) / 26 597, which is reproducible from the two numbers given.

The direction is the durable part, and it does not depend on the fit. ChunkLightPropagator#seedSky walks every column from the top down and queues one entry per cell until the first block that stops the light, so the seeding cost grows with the volume of open sky while a block-light search grows only with what its sources reach. That is a property of the code, readable in that method, and it is the mechanism the non-linearity is evidence for rather than something the fit had to establish.

Measuring the exotic sizes rather than extrapolating from common ones is the only reason the non-linearity is visible at all: a fifteen-point axis reaching 256 sections is what makes the bend past 64 sections observable, and a suite that stopped at the vanilla 24 would have reported a clean straight line.

Every light measurement in this class is already repeated five times and the repeats were never kept. sectionCount and distinctStates are declared in one @State class and therefore form a full cross product, but blockLightBySectionCount and skyLightBySectionCount never read distinctStates. Each of their figures is consequently produced once per distinctStates level, in five independent JMH runs with independent warmups — which is a direct empirical estimate of exactly the run-to-run variability that one fork does not sample. Recovering it costs one benchmark run and no code change.

Lighting an area against one neighbourhood per chunk

The design of the self-maintaining light made area forming conditional on a single number, and this is that number. µs/op:

Chunks area perChunk Area is Conservative bounds
1 542.4 ± 15.6 546.6 ± 27.4 level
4 2 678.4 ± 45.5 10 533.1 ± 162.6 3.93× cheaper 3.81×–4.06×
9 6 408.2 ± 468.2 34 390.8 ± 1 738.9 5.37× cheaper 4.75×–6.08×
16 11 607.5 ± 975.2 70 639.0 ± 1 195.9 6.09× cheaper 5.52×–6.76×

AreaVsPerChunkBenchmark.area / .perChunk, chunkCount as in the first column, a connected square of real Minestom chunks with OCCLUSION_PERCENT = 30 and SOURCES_PER_CHUNK = 4 built from SEED = 20260731, one thread, run as -f 1 -wi 3 -i 5, which matches the class annotations (one fork, 3 warmup and 5 measurement iterations of 1 s), no heap flags — this class sets none, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. MinecraftServer.init() runs in @Setup(Level.Trial), so a real block registry is in play on both sides. One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

All three multi-chunk rows are supported: each pair of intervals is disjoint, so the direction and the order of magnitude hold at this precision, and the bounds column is the honest form of the ratio. The chunkCount = 1 row overlaps, which is the predicted control result and not a weakness — see below.

One caveat about state within a trial. @Setup is Level.Trial, so the chunks are built once and then lit repeatedly for the whole trial; a perChunk invocation is therefore never the first one to light those chunks. What this cannot be is cross-contamination between the two sides: JMH runs each @Benchmark method as its own trial with its own state instance, so area never sees perChunk's output or the reverse. A gap of four to six times is also far outside anything repeated lighting of the same chunks could plausibly produce in one direction only.

The criterion was that an area of n chunks has to be measurably cheaper than n separate calculateWithNeighbours calls, and that if it were not, the simpler per-chunk design would be the better one and area forming should have been dropped rather than tuned until the benchmark agreed. It holds from four chunks on and the margin grows with the area, which is the shape the argument predicted: reading the block states of a chunk and building its opacity tables is the expensive part, and a per-chunk neighbourhood reads every chunk up to nine times while an area reads each of its chunks and each of its ring chunks exactly once, no matter how large it is.

The tie at one chunk is the expected result, not a weak one. A lone chunk has no loaded neighbours, so neither side has a ring to read and there is nothing for either to save. The row is worth keeping precisely because it shows the difference is the repeated reading and nothing else.

The perChunk side used to write nine chunks and now writes one, since calculateWithNeighbours stopped darkening its ring. That removes eight toSections conversions and eight guarded writes per call, so the comparison had to be re-run rather than assumed to carry.

That re-run is one of only four genuinely independent repeats in this whole document, and it passes. At nine chunks it gives area 6 456.7 ± 842.4 against perChunk 35 342.0 ± 4 934.9 — disjoint intervals, ratio 5.47× with conservative bounds 4.17×–7.17×, against the 5.37× and 4.75×–6.08× of the row above. The two ratio intervals overlap, so the second run reproduces the first. This is the evidence that answers "one fork proves nothing" directly: a second JVM launch, after a code change on one side, landed on the same ratio interval. The third digit moved and the ratio did not — which is what two of the four repeats counted under Against the engine Minestom ships with show, this one and the earlier IncrementalVsFullBenchmark run. The other two are both on the loader table, and neither reproduces a ratio at all: Falco's side repeats on every row of both, and Minestom's does not repeat above one thread in either, so what those two establish is an asymmetry rather than a factor. Both are set out under The region file against the one Minestom ships with.

The reason the fix barely shows is the reason area forming wins in the first place — what dominates both sides is reading the block states and building the opacity tables, not writing the result. The table above is therefore left exactly as measured, and the comparison is now between two paths that both write only what they computed correctly.

Re-run: same class, methods and parameters as the table above, chunkCount = 9 only, after the calculateWithNeighbours ring fix, same machine, same one-fork configuration, run commit and run date not recorded, no results.json committed. The ratio bounds are (b − eb) / (a + ea) to (b + eb) / (a − ea) on both runs.

A replayed block change against lighting the chunks again

One block is toggled between glowstone and air in the middle of a 5×5 world; both sides then compute the nine chunks the change marks plus the ring around them, and write the result into the same sections. The only difference is where the light of a chunk comes from: replayed onto the light the area already holds, or searched again from the block states, which is what every pass did before. µs/op:

full incremental Incremental is Kind
Block light 16 028 ± 778 7 747 ± 402 2.07× cheaper, bounds 1.87×–2.29× measured (sky = false)
Sky light 39 585 ± 1 512 7 065 ± 438 5.60× cheaper, bounds 5.07×–6.20× measured (sky = true)
One tick pays for both ≈ 55 613 ± 2 290 ≈ 14 812 ± 840 ≈ 3.7× cheaper, bounds 3.41×–4.14× derived — the sum of the two rows above

IncrementalVsFullBenchmark.full / .incremental, sky false and true, WORLD_EDGE = 5 real Minestom chunks built from SEED = 20260731 with the toggled block in the middle chunk, one thread, run as -f 1 -wi 5 -i 10 — an override of the class annotations, which are 3 warmup and 5 measurement iterations of 1 s — no heap flags, this class sets none, JMH 1.37, one 16-core machine recorded as not idle, run commit and run date not recorded, no results.json committed. MinecraftServer.init() runs in @Setup(Level.Trial). One fork: the ± covers variance between iterations of one JVM, not between JVM launches — see Rationale: Measurement.

Both measured rows are supported — the intervals of each pair are disjoint by a wide margin, and the bounds column is the honest form of the factor.

The third row is not a measurement and its two assumptions should be stated. It is the arithmetic sum of the two rows above it, because a dimension with sky light pays for both kinds in the same pass. Summing them assumes that such a pass shares no work between the two kinds — an assumption about the implementation, not something the benchmark checks. Its uncertainty is the sum of the two absolute half-widths, propagated conservatively, which is why the ratio is given as ≈ 3.7× and a range rather than as the 3.75× the two sums divide to.

An independent earlier run gave 1.96× / 6.40× / 3.95×. Direction and order of magnitude reproduce; the third digit does not. This is the second of the two cross-run repeats that reproduce a ratio — two of the four this document sets out, all of them counted under Against the engine Minestom ships with — a different JVM launch, on the same machine, landing on the same three ratios to their first two digits. Together with the nine-chunk re-run above it is the whole of the project's answer to the single-fork objection in the form of a reproduced ratio; the objection is answered in one other form as well, by the two-fork four-thread control under The region file against the one Minestom ships with, which is the only published run whose ± covers more than one JVM launch. The lesson both re-runs teach is the same: ratios reproduce, third digits do not.

Sky light gains far more than block light, and that is the shape the design predicts. A full sky propagation seeds every open cell of a column, which is what makes it the expensive half and what makes its scaling non-linear (see Scaling by world height). Replaying a position walks one column of the heightmap instead. Block light was never seeded that widely, so there is less to avoid.

The block toggles rather than being placed, so the world alternates between two states instead of drifting, and both directions of an incremental update are measured — adding brightness and taking it back. Measuring only the easy direction would have reported a larger number for less work.

Where the time goes in the light path

This section began as a standalone rebuild of the same call structure, run outside the project — not JMH and not the real code. The part of it that mattered most has since been measured for real: 69381af added LightEngineStageBenchmark, which times the stages of both engines inside the project. Where a rebuild estimate has been replaced by a JMH figure that is said below; the rest is still the rebuild and still carries only its ratios.

Measured, a genuine before-and-after across one commit, µs:

Stage Before 69381af After Kind
opacity — build the table 31.33 8.07 measured (falcoOpacity)
readStates 7.70 7.23 measured (falcoReadStates)
propagate — the search itself 33.85 31.41 measured (falcoPropagate)
collect 0.24 0.23 measured (falcoCollect)
falcoFull (measured, not the sum) 77.1 46.3 measured (falcoFull)

LightEngineStageBenchmark.falcoOpacity / .falcoReadStates / .falcoPropagate / .falcoCollect / .falcoFull, lightSources = 1, occlusionPercent = 0, one section, one thread, one fork, 3 warmup and 5 measurement iterations of 1 s per the class annotations — the run's own command line is not recorded — -Xms512m -Xmx512m, JMH 1.37, one 16-core machine recorded as not idle, commit 69381af against its predecessor, run date not recorded, no results.json committed. No ± was recorded for any cell, so no significance rule can be applied to a single row; what carries this table is the internal control described below. One fork on both sides — see Rationale: Measurement.

This table validates itself, which is why the absent error bars matter less here than elsewhere. Two of the five rows are stages the commit did not touch, and they are the noise floor of this pair of runs: readStates moves by 6 % (7.70 → 7.23) and collect by 4 % (0.24 → 0.23). Against a run-to-run floor of roughly 5 %, the opacity row moving by a factor of 3.9 is far outside anything that pair of runs could produce by chance. An unchanged stage is a control, and having two of them is what turns an unhedged table into a self-checking one.

The last row is measured separately and does not equal the sum of the four above it, deliberately. Before, the four stages sum to 31.33 + 7.70 + 33.85 + 0.24 = 73.12 against a falcoFull of 77.1, a residual of +3.98; after, they sum to 8.07 + 7.23 + 31.41 + 0.23 = 46.94 against 46.3, a residual of −0.64. Exposing that residual is what falcoFull exists for: it is the part of a whole pass the four named stages do not account for, plus whatever the decomposition itself costs.

Allocation while building the table: 74 040 → 8 664 bytes per call, from -prof gc (gc.alloc.rate.norm). Allocation counts are the most robust quantitative evidence on this page. They are essentially deterministic — the same code on the same input allocates the same bytes — so they are immune to the single-fork objection, to machine load and to JIT variance in a way no timing here is. Where an allocation figure and a timing figure support the same conclusion, the allocation is the one to lean on. The profiler output itself is not committed.

Two things fall out of this. The rebuild's estimate for SectionOpacity.of — 29.1 µs against 5–7 µs for a table without boxing — was close enough on both ends; the real path lands at 8.07 µs with a local linear-probing table over the raw state id. And the search was never the problem: at 33.85 µs propagate was already faster than the 53.9 µs figure for Minestom's search. That figure is derived, not measured directly — it is minestomFull − minestomQueue, because Minestom's search consumes the queue it is handed and cannot be run on a prepared one, which the class javadoc states. Neither operand is published with an interval, so the difference carries none either. The whole deficit against Minestom sat in the preparation.

The mechanism the rebuild identified was correct. The lambda handed to computeIfAbsent captures the BlockLightSource, so a fresh instance is created on every loop iteration, and escape analysis does not remove it because computeIfAbsent is too large to inline — 4096 objects per section, roughly 1.8 MB per chunk column.

The published rows are one of six parameter combinations the class produces. LightEngineStageBenchmark declares lightSources ∈ {1, 8, 64} and occlusionPercent ∈ {0, 30} and holds seven measured methods, so a full run yields 42 configurations. Only the five Falco stages at 1 / 0 % are published here, and the two Minestom methods (minestomQueue, minestomFull) appear only through the derived 53.9 µs above.

The rest is still the rebuild: not JMH, not the real code, run on Temurin 25, best of seven — a standalone reimplementation of the same call structure outside this repository, so none of it can be re-run from here, none of it has an interval, and "best of seven" is a minimum rather than a mean and therefore not comparable with any JMH figure above. Treat every number in the list below as an order of magnitude and a direction. The ratios between variants are what the rebuild was built to compare; the absolute microseconds are the coarsest quantities on this page. Ordered by the size of the effect, with what has since been built marked as such:

  • Done in 69381af: the opacity table without a per-block allocation. The rebuild put this at 20–45 % of the path; the stage benchmark above is the real figure.
  • Done in 69381af: collect() as one linear nibble pack instead of writing position by position through LightNibbles.set and cloning afterwards. The rebuild put it at 9.6 → 1.2 µs in the non-uniform case; LightNibbles.ofLevels now packs two neighbours at a time and range-checks once at the end.
  • Open: seeding sky light from a heightmap instead of queueing every open column cell: 79.3 → 55.1 µs, and 81 000 → 19 000 queued positions. Byte identity against the current seeding was verified over 240 randomly generated worlds, zero differing cells. This is what the non-linear sky-light scaling above is made of.
  • Open, and deliberately so: the seed pass is redundant. seed walks all 4096 positions only to find the emitters, which of already visits: 3.6 µs, plus 2.0 µs for the second byte[4096] that then becomes unnecessary. Left out of 69381af because writing the emitters during the table build changes the API of SectionOpacity and both propagators for a gain of that size.
  • Open: column opacity as one flat byte[] instead of List.get(y >> 4) plus a virtual call: −26 % on searching a whole column.
  • Open: skipping the direction an entry arrived from: −7 to −16 %. Testing the level before the opacity: −6 %.
  • A bucket queue (Dial) is 5–7 % slower at equal source brightness and 32–36 % faster at mixed brightness — both figures from the rebuild, so both are directions rather than factors. The JMH suite now produces the mixed case as well, in With sources of mixed brightness; that table shows the two 30 % solid rows holding their lead and the two open-sky rows losing theirs to overlap, which is where a bucket queue would be expected to help.
  • Done: ChunkLightState is about 100 KB per instance instead of about 980 KB. Its three working queues were sized for one entry per position of the chunk — 393 KB, 393 KB and 98 KB against the 98 KB the levels themselves take, so nine tenths of a state was scratch space for buffers that a single-block update fills a few dozen entries of. They now start at 1024 entries and grow, which is what makes a state cheap enough to be kept between two passes rather than thrown away after each, and that is what the incremental path is built on. Nine of them, which is what calculateWithNeighbours builds, come to roughly 900 KB per call instead of roughly 8.8 MB. Derived from the declared buffer sizes, not measured with an allocation profiler — as the earlier version of this bullet was, whose "roughly 28 MB" did not follow from 9 × 980 KB either. The cap on an area size is still set from this figure, because ChunkLightArea builds one state per chunk of the area and of its ring; what now dominates an area is the opacity tables, not the states — see open item 4.

Optimisations these numbers produced

Change Effect Where the figure comes from
zlib level 2 instead of the platform default 6 1.83× faster compression, ~3 % larger files No run record. See the note below
Linear-probing opacity table over the raw state id, no boxing 31.33 µs → 8.07 µs, 74 040 → 8 664 bytes per call Where the time goes in the light path; allocation from -prof gc
LightNibbles.ofLevels instead of 4096 calls to set collect 0.24 µs → 0.23 µs in the stage benchmark; the rebuild had it at 9.6 → 1.2 µs for the non-uniform case Same table; the second pair is the rebuild, not JMH
One area instead of one 3×3 neighbourhood per chunk 3.93× at 4 chunks, 6.09× at 16, level at 1 Lighting an area against one neighbourhood per chunk
Replaying a changed position instead of lighting the chunks again 2.07× on block light, 5.60× on sky light, ≈ 3.7× on a tick that pays for both A replayed block change against lighting the chunks again; the tick figure is derived
Working queues that start at 1024 entries instead of one per position ChunkLightState from roughly 980 KB to roughly 100 KB, which is what makes it keepable Derived from the declared buffer sizes, not from a profiler

Two uniform-section fast paths, and the framing they are usually given is wrong.

Path General section, distinctStates = 200 Uniform section, distinctStates = 1
Palette encode 27.9 µs 0.54 µs
Opacity table 40.8 µs 0.54 µs, and no arrays allocated

These two rows are not a before-and-after. Nothing got 51 or 76 times faster: they are the same code on two different inputs — a section holding 200 distinct block states against a section holding one — and what they show is that detecting the uniform case up front costs almost nothing compared with walking 4 096 blocks. That is a real and useful property, and it is what the fast paths were built for. It is not a speedup of the library, and a reader who parses the two columns as versions of the code will conclude something the measurement does not support.

No benchmark class, method, parameter set, machine or run configuration was recorded for these four figures, and no ±. PaletteDataBenchmark (distinctStates 1, 8, 64, 200) and SectionOpacityBenchmark (distinctStates 1, 8, 64, 200 × resolveCost 0, 50) measure exactly this shape and would produce them with provenance; whether these four came from those classes is not recorded. Treat them as design-time micro-measurements — orders of magnitude, not factors.

ChunkSaveComparisonBenchmark is published in the half that cannot settle its own claim. The class tests a sharp, falsifiable structural claim — that Minestom deduplicates palette entries with a linear search over an IntArrayList, so its cost grows with blockCount × paletteSize, while Falco uses a hash map and grows with blockCount alone — across distinctStates 1, 16, 64, 256 and 1024, with compressFalcoLevel and compressMinestomLevel built in so the compression-level difference can be subtracted out rather than argued about. Four methods over five distinctStates levels make twenty configurations, and ten of them are published: the falcoSave and minestomSave series at those five levels, five rows of two, in Anvil Chunk Loader under Measured: saving a chunk. Exactly one of the five rows resolves a difference, and that row is confounded by the compression level, because both loaders are measured as they ship and they ship different ones. What is unpublished is the compress*Level pair that removes the confound — without it the palette claim cannot be separated from the zlib level. That subtraction is the gap, and it is the cheapest one on this page to close.


References

Sources for the numbers above, all readable without running anything.

  • Benchmark conditions — @Fork, @Warmup, @Measurement, @Param and jvmArgsAppend are explicit on every class in falco-benchmarks/src/jmh/java. Where a table above was produced with a command-line override, its provenance line says so; the annotation is what the class declares and the override is what the run used.
  • What the ± after a JMH mean covers, and what a single fork leaves out of it, is stated once for the whole wiki in Rationale: Measurement, which every provenance line above links to rather than restating.
  • The command lines that re-perform these tables are in Benchmarking under Reproducing a published table, together with what a result of this suite cannot mean.
  • Byte identity between the two light engines, which is what makes the light timings a comparison of the same result rather than of two different ones — LightEngineEquivalenceTest for the 54 scenarios on every build, and verifyBothEnginesAgree in LightEngineComparisonBenchmark for the per-trial check.
  • Minestom is cited throughout as a path relative to net/minestom/server/ plus a line number at version 2026.06.20-26.1.2; Falco by member name. The reason the two conventions differ is in Anvil Chunk Loader.

What is not here. No results.json, no human.txt and no -prof gc output from any published run has been committed. No run date is recorded for any of the eight sections under Measured, and a commit is recorded for only three of them — the two light comparison tables and the light stage table; the other five, and the two uniform-section fast-path figures under Optimisations these numbers produced, have neither. A command line is recorded for three of the eight — the UNIFORM light comparison, the area comparison and the incremental comparison; on a fourth, the region file table, earlier documentation stated an iteration time the class annotation contradicts and no command line survives to settle it. Each provenance line says this at its own table rather than only here. The machine is described only as a 16-core machine that was not idle — no CPU model, no frequency policy, no JVM build, no kernel. Those gaps are listed field by field under Measurement environment and are the reason every table on this page states its conditions inline rather than pointing at a run record.

Falco


Start here

The measured record

Why it is built this way

Working on the build

Clone this wiki locally