Skip to content

Commit a09c71f

Browse files
TheMeinerLPclaude
andauthored
test(benchmarks): measure what a light pass actually spends on opacity tables (#26)
Open item 3 of Project Status says building the tables is "the largest remaining cost block by a wide margin" and proposes a cache with an LRU bound and position-driven invalidation. Measured, that premise does not hold. Area | whole pass | opacity tables | share 1 | 4795.7 us | 1027.8 us | 21.4 % 4 | 9449.7 us | 1829.7 us | 19.4 % 16 | 22296.2 us | 4199.5 us | 18.8 % Roughly a fifth, and the share falls as the area grows, because the ring becomes relatively smaller. The wording predates 69381af, which took the per-section table from 31.33 to 8.07 us and made propagate the dominant stage at 31.41 - a change item 5 of the same page records while item 3 was not pulled along. The benchmark exists because no existing one could answer this. AreaVsPerChunkBenchmark loads only the chunks of the area, so read skips the absent ring - correct for comparing area against per-chunk on identical work, and the wrong shape here, since the ring is exactly what a table cache would pay for. The ring is also filled with the same layout as the area: SectionOpacity.of returns early for a section of one repeated state, so an empty ring chunk would have made the tables look free. Both methods run over the identical chunk set, so the share is an upper bound on what a perfect cache could remove. A real one pays for lookup, invalidation and eviction, and cannot cache the sections a change touched. AMD Ryzen 7 5800X, JDK 25.0.3, 1 fork, 3 warmup and 5 measurement iterations of 1 s, single thread. One fork, so the figure carries no cross-fork variance. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0a33764 commit a09c71f

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package net.onelitefeather.falco.benchmark.light;
2+
3+
import net.minestom.server.MinecraftServer;
4+
import net.minestom.server.instance.Chunk;
5+
import net.minestom.server.instance.Instance;
6+
import net.minestom.server.instance.block.Block;
7+
import net.onelitefeather.falco.light.ChunkArea;
8+
import net.onelitefeather.falco.light.ChunkLightArea;
9+
import net.onelitefeather.falco.light.ChunkLightService;
10+
import org.openjdk.jmh.annotations.Benchmark;
11+
import org.openjdk.jmh.annotations.BenchmarkMode;
12+
import org.openjdk.jmh.annotations.Fork;
13+
import org.openjdk.jmh.annotations.Level;
14+
import org.openjdk.jmh.annotations.Measurement;
15+
import org.openjdk.jmh.annotations.Mode;
16+
import org.openjdk.jmh.annotations.OutputTimeUnit;
17+
import org.openjdk.jmh.annotations.Param;
18+
import org.openjdk.jmh.annotations.Scope;
19+
import org.openjdk.jmh.annotations.Setup;
20+
import org.openjdk.jmh.annotations.State;
21+
import org.openjdk.jmh.annotations.Warmup;
22+
23+
import java.util.ArrayList;
24+
import java.util.LinkedHashSet;
25+
import java.util.List;
26+
import java.util.Random;
27+
import java.util.Set;
28+
import java.util.concurrent.TimeUnit;
29+
30+
/**
31+
* The {@link AreaPassStageBenchmark} class answers one question the other light benchmarks do not:
32+
* how much of a pass is spent building opacity tables rather than propagating light.
33+
* <p>
34+
* <b>Why a second area benchmark exists.</b> {@code AreaVsPerChunkBenchmark} loads only the chunks
35+
* of the area, so the ring around it is absent and {@code read} skips it. That is the right shape
36+
* for the question it asks — area against per-chunk on identical work — and the wrong one here: the
37+
* ring is read once per pass and never written, so it is precisely the part a table cache would pay
38+
* for. A benchmark that leaves it out measures the case the cache does not target.
39+
* </p>
40+
* <p>
41+
* The ring is filled with the same layout as the area for the same reason.
42+
* {@code SectionOpacity.of} returns early for a section of one repeated state, so an empty ring
43+
* chunk costs almost nothing and would make the tables look free.
44+
* </p>
45+
* <p>
46+
* Both methods run over the identical set of chunks, so the ratio between them is the share of a
47+
* pass that a perfectly effective table cache could remove — an upper bound, since a cache still has
48+
* to look up, invalidate and evict.
49+
* </p>
50+
*
51+
* @author TheMeinerLP
52+
* @version 1.0.0
53+
* @since 1.0.0
54+
*/
55+
@State(Scope.Benchmark)
56+
@BenchmarkMode(Mode.AverageTime)
57+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
58+
@Fork(value = 1, jvmArgsAppend = {"-Xms2g", "-Xmx2g"})
59+
@Warmup(iterations = 3, time = 1)
60+
@Measurement(iterations = 5, time = 1)
61+
public class AreaPassStageBenchmark {
62+
63+
/**
64+
* The seed of the block layout, so every run measures the same world.
65+
*/
66+
private static final int SEED = 20260802;
67+
68+
/**
69+
* The share of blocks which are solid, in percent.
70+
*/
71+
private static final int OCCLUSION_PERCENT = 30;
72+
73+
/**
74+
* The amount of light sources placed into every chunk.
75+
*/
76+
private static final int SOURCES_PER_CHUNK = 4;
77+
78+
/**
79+
* The amount of connected chunks the area holds.
80+
*/
81+
@Param({"1", "4", "16"})
82+
private int chunkCount;
83+
84+
private Instance instance;
85+
private ChunkLightService service;
86+
private ChunkLightArea area;
87+
private List<ChunkArea> areaPositions;
88+
private List<ChunkArea> passPositions;
89+
90+
/**
91+
* Creates a new benchmark instance.
92+
*/
93+
public AreaPassStageBenchmark() {
94+
}
95+
96+
/**
97+
* Builds the square, loads the ring around it and fills both with the same layout.
98+
*/
99+
@Setup(Level.Trial)
100+
public void setUp() {
101+
if (MinecraftServer.process() == null) {
102+
MinecraftServer.init();
103+
}
104+
105+
this.service = new ChunkLightService();
106+
this.area = new ChunkLightArea(this.service);
107+
this.instance = MinecraftServer.getInstanceManager().createInstanceContainer();
108+
this.areaPositions = new ArrayList<>(this.chunkCount);
109+
110+
int edge = (int) Math.round(Math.sqrt(this.chunkCount));
111+
112+
for (int index = 0; index < this.chunkCount; index++) {
113+
this.areaPositions.add(new ChunkArea(index % edge, index / edge));
114+
}
115+
116+
Set<ChunkArea> pass = new LinkedHashSet<>(this.areaPositions);
117+
118+
for (ChunkArea position : this.areaPositions) {
119+
for (int offsetX = -1; offsetX <= 1; offsetX++) {
120+
for (int offsetZ = -1; offsetZ <= 1; offsetZ++) {
121+
pass.add(new ChunkArea(position.x() + offsetX, position.z() + offsetZ));
122+
}
123+
}
124+
}
125+
this.passPositions = List.copyOf(pass);
126+
127+
Random random = new Random(SEED);
128+
129+
for (ChunkArea position : this.passPositions) {
130+
fill(this.instance.loadChunk(position.x(), position.z()).join(), random);
131+
}
132+
}
133+
134+
/**
135+
* Puts solid blocks and light sources into the given chunk.
136+
*
137+
* @param chunk the chunk to fill
138+
* @param random the source of the layout
139+
*/
140+
private static void fill(Chunk chunk, Random random) {
141+
chunk.lockWriteLock();
142+
try {
143+
for (int y = 32; y < 48; y++) {
144+
for (int z = 0; z < 16; z++) {
145+
for (int x = 0; x < 16; x++) {
146+
if (random.nextInt(100) < OCCLUSION_PERCENT) {
147+
chunk.setBlock(x, y, z, Block.STONE);
148+
}
149+
}
150+
}
151+
}
152+
153+
for (int placed = 0; placed < SOURCES_PER_CHUNK; placed++) {
154+
chunk.setBlock(random.nextInt(16), 32 + random.nextInt(16), random.nextInt(16), Block.GLOWSTONE);
155+
}
156+
} finally {
157+
chunk.unlockWriteLock();
158+
}
159+
}
160+
161+
/**
162+
* Measures a whole pass over the area, with its ring present.
163+
*
164+
* @return the chunks whose light was written
165+
*/
166+
@Benchmark
167+
public List<ChunkArea> wholePass() {
168+
return this.area.compute(this.instance, this.areaPositions, false);
169+
}
170+
171+
/**
172+
* Measures only the opacity tables a pass builds, over the identical set of chunks.
173+
*
174+
* @return the amount of section tables that were built
175+
*/
176+
@Benchmark
177+
public int opacityTablesOnly() {
178+
int sections = 0;
179+
180+
for (ChunkArea position : this.passPositions) {
181+
Chunk chunk = this.instance.getChunk(position.x(), position.z());
182+
183+
if (chunk != null) {
184+
sections += this.service.opacityOf(chunk).size();
185+
}
186+
}
187+
return sections;
188+
}
189+
}

0 commit comments

Comments
 (0)