Skip to content

perf(light): three changes to the propagation loop from item 2 of Open - #36

Merged
TheMeinerLP merged 2 commits into
mainfrom
perf/flat-column
Aug 2, 2026
Merged

perf(light): three changes to the propagation loop from item 2 of Open#36
TheMeinerLP merged 2 commits into
mainfrom
perf/flat-column

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

The three entries of the last bullet under
Project Status → Open → Smaller items:

Column opacity is a List.get(y >> 4) plus a virtual call rather than one flat byte[], and the
search neither skips the direction an entry arrived from nor tests the level before the opacity.
All three figures come from the standalone rebuild and are directions rather than factors: −26 %,
−7 to −16 %, and −6 %. None was measured by this suite and none carries an interval.

That last sentence is why this is two commits rather than one: the cheap, certain changes are
separated from the one that trades memory and setup cost for a faster inner loop, so the measurement
can tell them apart.

3b74e04 — order the two tests by cost, and stop walking back

Both propagators tested a neighbour's occlusion before its level. The level is one array read; the
occlusion is two and a branch. The level also rejects far more often — a position is reached from up
to six directions and only the first raises it — so the cheaper and more selective test goes first.
Both predicates are pure, so the order is free to choose.

A queued position now also carries, in the bits above its index, the face pointing back at whoever
queued it, and the loop skips that face. This is not a heuristic: whoever queued the position holds a
level exactly one higher, so levels[neighbour] >= next could never fail in that direction. The work
was always wasted, not merely redundant. Seeds carry a face value no face uses.

237d60a — lay the occlusion of a column out flat

sections.get(y >> 4).blocksFace(…) ran once per face per queued position: an interface call, a
bounds check and a null test before the byte anyone wanted. prepare() now writes the whole column
into one flat byte[] and the search reads it with a precomputed face bit.

The layout falls out for free — a section indexes its table with the same (y << 8) | (z << 4) | x
the column uses, so each section is one contiguous 4096-byte run and goes in with a single
arraycopy. A uniform section holds no table at all and is filled instead, so the shortcut that
makes uniform sections cheap is not given up, only paid for differently. That is the part of this
commit worth watching: it converts a per-read saving into a per-pass cost, and whether that trade
pays is exactly what the rebuild figure could not say.

copyOcclusionInto is package private — it hands out the internal layout of the table for the two
propagators of this package and is not API.

Correctness

Neither commit changes an output byte, and both were checked by breaking them rather than by
assertion:

  • storing the travelled face instead of its opposite, the plausible way to get the skip wrong →
    20 tests red
  • filling the column with the sections in reverse, the plausible way to get the offset wrong →
    20 tests red

So the suite covers direction and layout, not only levels. ./gradlew build is green.

Measurement

LightEngineStageBenchmark is the right instrument here because it carries its own controls:
falcoPropagate is what these commits change, while falcoOpacity, falcoReadStates and
falcoCollect are untouched stages measured in the same jar and the same job. 69381af used exactly
that structure. ChunkLightPropagatorBenchmark runs alongside it for the whole-column case.

Running through the Benchmark workflow, custom profile, three forks, on main and on this
branch. Numbers follow in a comment — including if they say one of these should be reverted. The
flat column is the one with a plausible way to lose.

…back

Both propagators tested the occlusion of a neighbour before its level.
The level is one array read; the occlusion is two and a branch, and the
level rejects far more often — a position is reached from up to six
directions and only the first of them raises it. The two are pure, so
the order is free to choose and the cheaper, more selective one goes
first.

A queued position also carries the face pointing back at whoever queued
it, in the bits above its index, and the loop skips that face. Whoever
queued it holds a level exactly one higher, so the level test could never
pass in that direction; the work was always wasted rather than merely
redundant. Seeds carry a face value no face uses, so nothing is skipped
for them.

Neither changes a single output byte. Storing the travelled face instead
of its opposite — the plausible way to get this wrong — turns 20 tests
red, so the suite does cover the direction rather than only the levels.
The chunk propagator reached the occlusion of a neighbour through
sections.get(y >> 4) followed by a call into that section, once per face
per queued position — an interface call, a bounds check and a null test
before the byte anyone wanted. The column is now written into one flat
byte[] in prepare(), and the search reads it directly with a precomputed
face bit.

The layout is free: a section indexes its table with the same
(y << 8) | (z << 4) | x the column uses, so a section occupies one
contiguous 4096-byte run and goes in with a single arraycopy. A uniform
section, which holds no table at all, is filled instead — still the
cheaper of the two cases, so the shortcut that makes uniform sections
free is not given up, only paid for differently.

The buffer is sized and reused like levels and queue, so it costs one
byte per position of the largest column an instance has seen and nothing
per run.

copyOcclusionInto is package private. It hands out the internal layout of
the table for the two propagators of this package and is not API.

Filling the column with the sections in reverse — the plausible way to
get an offset wrong — turns 20 tests red.
@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner August 2, 2026 17:28
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Test results

  198 files    198 suites   3m 56s ⏱️
  677 tests   677 ✅ 0 💤 0 ❌
2 040 runs  2 040 ✅ 0 💤 0 ❌

Results for commit 237d60a.

@TheMeinerLP
TheMeinerLP merged commit c206ef3 into main Aug 2, 2026
10 checks passed
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

Measured through the workflow

LightEngineStageBenchmark and ChunkLightPropagatorBenchmark in one job, custom profile, three
forks, on 2375f68a (main, a CI-comment change on top of #34 — the light code is ec35f61) and on
237d60a (this branch). Both runs landed on the same CPU model this time, an EPYC 9V74, which
the controls confirm.

The controls first

falcoReadStates, falcoOpacity, falcoCollect, minestomFull and minestomQueue are all
untouched by these commits and were measured in the same jar and the same job.

control spread across its six configurations
falcoReadStates −0.2 % … +1.7 %, intervals ± 0.01–0.19 on ~10 µs
minestomFull −0.9 % … +7.6 %
falcoOpacity −24.7 % … +2.4 %
falcoCollect +0.2 % … +14.2 %

falcoReadStates is the one to read: ten microseconds of pure array work, error bars of a hundredth
of a microsecond, and under 2 % between the two runs. That is how far apart these two machines are.

The wide-looking numbers are noise at small absolute values or in single cells, and saying so is not
a convenience: falcoCollect runs at 0.09–0.11 µs, where one rounding step is 10 %. falcoOpacity
at −24.7 % has an error of ± 1.99 on the main side against ± 0.26 on the branch — one bad cell in
the baseline, and the other five configurations sit between −1.1 % and +2.4 %. Nothing in these
commits touches table building.

The changed code

configurations range
LightEngineStageBenchmark.falcoPropagate 6 of 6 faster −7.3 % … −22.0 %
ChunkLightPropagatorBenchmark.propagate 6 of 6 faster −27.3 % … −39.7 %
ChunkLightPropagatorBenchmark.propagateSky, 16 and 24 sections 4 of 4 faster −26.1 % … −29.7 %
LightEngineStageBenchmark.falcoFull 6 of 6 faster −5.8 % … −16.5 %

Every one of those intervals is disjoint from its baseline, and every one clears the control by a
large multiple. Selected cells:

main this branch
propagate 8 sources, 16 sections 734.95 ± 1.26 443.26 ± 2.53 −39.7 %
propagate 1 source, 24 sections 667.58 ± 3.39 458.00 ± 6.26 −31.4 %
propagateSky 8 sources, 24 sections 2461.24 ± 12.33 1738.15 ± 19.47 −29.4 %
falcoPropagate 8 sources, 0 % occlusion 88.13 ± 1.78 68.74 ± 0.57 −22.0 %

The wiki's rebuild figure for the flat column was −26 % on searching a whole column. The chunk
propagator lands between −27 % and −40 %, so the direction the rebuild predicted holds and the
magnitude is if anything understated — measured, this time, by this suite and with intervals.

The one place it does not win

propagateSky at 4 sections measures +5.2 % and −3.7 % — two configurations that disagree in
direction, with branch-side errors of ± 17.88 and ± 61.96 against baseline errors of ± 0.81 and
± 0.50. That is a noisy pair of cells, not an established regression, and I am not going to call it
either way on this data.

It is worth flagging rather than averaging away, because 4 sections is exactly where the flat column
should be at its weakest: the fill is a fixed cost per section and the sky pass at that height does
the least searching to amortise it. If this change has a losing case, that is where it lives.

Still open: which commit did what

Both commits are in this measurement together, so it does not say whether the flat column earns its
96 KB buffer on top of the two free changes. A third run on 3b74e04 alone is in flight and the
split follows.

@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

The split run was contaminated, and its own control says so

The third run — 3b74e04 alone, to separate the two commits — cannot be used. minestomFull is
Minestom's engine and neither commit touches a line of it, and it moved like this against the same
baseline:

minestomFull main split run
1 source, 0 % occlusion 77.00 75.62 (−1.8 %)
64 sources, 30 % 181.18 298.62 (+64.8 %)
8 sources, 30 % 177.49 287.84 (+62.2 %)
64 sources, 0 % 152.69 176.31 (+15.5 %)

falcoReadStates in the same run is fine — 9.41 to 9.55 µs against 9.84 to 10.02, tight and
consistent — so the runner was healthy at the start and degraded later. The pattern follows the
configuration order rather than the code: everything at occlusionPercent = 30 and the higher source
counts is inflated, in the changed methods and in the untouched ones alike. falcoPropagate at
64 sources / 30 % reads +66.2 % there, which would mean this branch made its own change slower by
two thirds while Minestom's engine slowed by the same amount on the same machine.

Two things worth saying about that:

The merged result is unaffected. It came from runs 30758921758 and 30758926454, both on an EPYC
9V74, where falcoReadStates stays inside 1.7 % and minestomFull inside 7.6 %. That pair is
control-checked and stands.

The split is simply not established yet. The uncontaminated part of the third run — the
ChunkLightPropagator rows, which sit at −23 % to −35 % for 3b74e04 alone against −27 % to −40 %
for both commits — suggests the two free changes carry most of it and the flat column adds the
rest. I am not quoting that as a result: the run demonstrably lost its footing partway through and
that class carries no control of its own, so there is no way to show from this data which side of the
degradation those rows fell on.

A re-run is in flight. If it holds up, the open question it answers is a real one and worth the CI
time: whether the flat column earns its 96 KB buffer on top of the two changes that cost nothing.
If it turns out it does not, the second commit should come back out.

This is also the second time today a benchmark comparison was saved by having an untouched method in
the same jar. Without minestomFull here, a +66 % reading on the changed method would have looked
like a real regression in the code rather than a sick runner.

@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

The split, on a clean run — and it found a real regression

Re-run of 3b74e04 alone, this time on an EPYC 9V74 like the other two. The controls are tight
enough to trust the split: minestomFull moved by −1.0 % to +0.8 % across its six
configurations and falcoReadStates by −0.8 % to +2.2 %. Neither commit touches either.

The section propagator: the flat column contributes nothing, as it should

falcoPropagate 3b74e04 alone flat column adds
1 source, 0 % −13.3 % −1.3 %
8 sources, 0 % −21.5 % −0.6 %
64 sources, 30 % −11.8 % −0.1 %

The flat column lives in ChunkLightPropagator only, so LightPropagator should see nothing from
it, and it sees nothing: six configurations between −1.3 % and +1.9 %. That is a second control —
one where the expected answer was zero and zero is what came out.

The chunk propagator: the flat column earns its buffer

propagate 3b74e04 alone flat column adds together
1 source, 4 sections −22.5 % −9.6 % −29.9 %
1 source, 16 sections −16.3 % −13.1 % −27.3 %
8 sources, 16 sections −30.5 % −13.2 % −39.7 %
8 sources, 24 sections −29.4 % −13.1 % −38.7 %

Between −8.2 % and −14.5 % on top of the free changes, on every configuration. The 96 KB buffer is
paid for.

And the case I said to watch is a real regression

propagateSky 3b74e04 alone flat column adds together
1 source, 16 sections −12.1 % −15.9 % −26.1 %
8 sources, 24 sections −17.1 % −14.8 % −29.4 %
8 sources, 4 sections −13.5 % +11.4 % −3.7 %
1 source, 4 sections −10.9 % +18.2 % +5.2 %

At four sections the flat column makes the sky pass slower, and this time both configurations
agree in direction on a run whose controls sit inside 2 %; the tighter of the two (8 4, ± 7.32)
reads +11.4 %. In the earlier pair this looked like noise. It is not. It is exactly the case I
flagged when opening this: the fill is a fixed cost per section and the shortest sky pass has the
least searching to amortise it.

Note it is sky only — block light at four sections still gains −9.6 % from the flat column. So
it is not the fill on its own; it is the fill plus seedSky, which queues nearly every position of
an open column and now reads the flat array where a uniform transparent section used to answer from
a field without touching memory at all.

What I would do about it, and why not a revert

Keep it, and record the losing case rather than average it away.

The trade in absolute terms: at four sections the sky pass costs +22 µs; at twenty-four it saves
−733 µs. A real overworld chunk is twenty-four sections, and sectionCount = 4 is a benchmark
configuration chosen to bracket the range rather than a height a world actually has. Reverting a
change that takes a real chunk's sky pass from 2465 µs to 1732 µs to protect a four-section column
would be optimising for the case that does not occur.

The targeted fix — leave seedSky reading through the sections and keep the flat array for search
— is possible and would probably recover it, but it costs the uniform-section fast path a second
code path and two more workflow runs to show it works. Worth doing only if four-section columns turn
out to matter to someone; I would rather have it written down than fixed speculatively.

Say the word if you want the seedSky variant measured.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant