Skip to content

perf(light): keep the opacity tables of a chunk with its light - #34

Merged
TheMeinerLP merged 1 commit into
mainfrom
perf/opacity-cache
Aug 2, 2026
Merged

perf(light): keep the opacity tables of a chunk with its light#34
TheMeinerLP merged 1 commit into
mainfrom
perf/opacity-cache

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Closes the first entry of Open on Project Statusthe opacity tables are rebuilt on every pass.

ChunkLightArea#read called opacityOf for every chunk of the area and of its ring, on every
pass, whether or not anything in that chunk had moved. A table describes the blocks of a section and
nothing else, so it stays right for exactly as long as those blocks do. That is the same condition
the kept light already rests on, reported through the same two methods, so the tables move into the
same bounded entry, tied to a version that recordChange and forget move on.

Two things fall out that the class already stated in prose without acting on:

  • ChunkLightScheduler#markChanged marks the eight neighbours of an edit and says why nothing of
    theirs is dropped — "Their own blocks did not move". Their tables now survive the pass too.
  • A table is not a kind of light. The scheduler runs a block pass and then a sky pass over the same
    group; the second one used to read every chunk again.

What it does, exactly

Counted, not timed — the same 3×3 group the scheduler forms around one edit, so 9 chunks plus a ring
of 16:

tables built, before after
first block pass over the group 25 25
the sky pass that follows it 25 0
a pass after one block changed 25 1
a pass with nothing changed 25 0

A steady tick over that group built 50 table sets and now builds 1. These are counts: exact,
identical on every machine, and unaffected by load or JIT. tableBuilds() is public so a server can
check the same thing about its own world, next to the fullPropagations() that already exists.

What this does not change is compute(…), the recalculating path. It is documented as keeping
nothing, and a caller with no revisions to offer has reported no changes either — so it has no basis
to reuse anything. AreaPassStageBenchmark, which measures that path, is therefore expected to be
unmoved by this commit.

Correctness

The risk here is the one the wiki entry names: a table that outlives the blocks it describes produces
light written with the update flag of the section cleared, and nothing ever corrects it.

IncrementalLightUpdateTest already compares the incremental path against a full recalculation,
position by position, for both kinds of light, across 48 random changes that remove as often as they
place. Deliberately dropping the invalidation turns four of its six tests red — so that harness
covers this change, and it was verified to do so rather than assumed to.

OpacityTableReuseTest (new, 7 tests) pins the counts above. Deliberately disabling the reuse turns
five of the seven red; the two that stay green are the two that assert reuse must not happen.

The version is read before the block states and stored with them, so a change landing between the two
costs a rebuild rather than leaving a stale table behind. The failure direction of that race is
wasted work, never a wrong table. Tables are shared as an immutable List: safely published, and a
write through one fails loudly instead of corrupting the next pass.

The cost

Memory, on top of the light, under the same bound of chunks but not the same size per chunk: a
uniform section costs nothing at all because SectionOpacity holds no arrays for one, and a fully
mixed one costs 8 KB. The figure is a property of the world rather than a constant, and the class
javadoc says so rather than quoting a number it cannot back.

Measurement

IncrementalVsFullBenchmark is already an A/B inside one jar — incremental() against full()
and this commit moves only the first of the two. full() is therefore an internal control in the
same job, which is what makes a run on a shared runner worth anything. Both arms are being measured
through the Benchmark workflow (#29/#31), custom profile, three forks, once on main and once on
this branch. Numbers follow in a comment.

The counts above stand on their own regardless of what the timings say.

@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner August 2, 2026 15:42
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

Measured through the workflow

IncrementalVsFullBenchmark, custom profile, three forks, once on main at 16b6d84 and once
on this branch at 6c2d5af. Both arms of the A/B live in the same jar and the same job, so full()
— the recalculating path, which this commit does not touch — is an internal control inside each run.

main this branch
sky=false incremental() 11886.76 ± 175.24 7246.38 ± 91.72 −39.0 %
full() (control) 20873.73 ± 142.65 19620.77 ± 236.69 −6.0 %
sky=true incremental() 8510.68 ± 102.30 4616.11 ± 203.26 −45.8 %
full() (control) 62871.64 ± 317.59 62192.32 ± 804.73 −1.1 %

µs/op, lower is better.

The control is what makes this readable at all. The two runs landed on different hardware — an
EPYC 9V74 and an EPYC 7763, both 4-core shared runners — so the absolute columns are not directly
comparable and neither run's microseconds belong in a published table. full() is the same code on
both sides, and it moved by 6.0 % and 1.1 %. That bounds what the machine change alone can explain.
incremental() moved by 39 % and 46 %, which is far outside it.

The portable form is the ratio measured inside one job, which is what the workflow's own
provenance line asks for:

main this branch
full() ÷ incremental(), sky=false 1.76× 2.71×
full() ÷ incremental(), sky=true 7.39× 13.47×

IncrementalVsFullBenchmark.incremental / .full, custom profile, forks 3, warmup and
measurement iterations per the class annotation (3 × 1 s and 5 × 1 s), JMH 1.37, JVM 25.0.3, one
4-core ubuntu-latest runner per run — AMD EPYC 9V74 for 16b6d84, AMD EPYC 7763 for 6c2d5af
load average 0.62→1.50 and 0.26→1.27 inside the VM, which says nothing about the neighbours sharing
the host. Runs
30754926812 and
30754931454, results-custom.json
attached to each.

What this does and does not establish

It establishes that a steady incremental pass got substantially cheaper, and by more than the machine
difference can account for. It does not establish 39 % or 46 % as figures for any other machine:
these are shared runners, and the workflow is right that a ratio is not portable either. What is
portable is the direction, the fact that it clears the control by a factor of six, and the counts in
the description — 50 table sets per steady tick down to 1 — which are exact everywhere.

The two sky rows are not comparable with each other; they are two configurations of the benchmark,
each with its own control.

ChunkLightArea#read built the opacity tables of every chunk of the area
and of its ring on every pass, whether or not anything in that chunk had
moved. A table describes the blocks of a section and nothing else, so it
stays right for exactly as long as those blocks do — the same condition
the kept light already rests on, reported through the same two methods.
The tables therefore move into the same bounded entry, tied to a version
that recordChange and forget move on.

Two things fall out that the class already said in prose without acting
on. The neighbours of an edit are marked because their light changed
while their own blocks did not, so their tables survive the pass. And a
table is not a kind of light, so the sky pass reuses what the block pass
built instead of reading every chunk a second time.

The version is read before the block states and stored with them, so a
change landing between the two costs a rebuild rather than leaving a
stale table behind. The tables are shared as an immutable list: safely
published, and a write through it fails loudly.

tableBuilds() reports whether the reuse happens. It is a count, so it is
exact and the same on every machine, which separates "does it reuse" from
"is the reuse worth anything".

One existing test asserted that the sky pass costs a second read of the
block states. It no longer does; it costs a second propagation, which is
what that test now counts.
@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 127819f.

♻️ This comment has been updated with latest results.

@TheMeinerLP
TheMeinerLP merged commit ec35f61 into main Aug 2, 2026
7 checks passed
@TheMeinerLP
TheMeinerLP deleted the perf/opacity-cache branch August 2, 2026 16:27
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